feat(chat): show skills list on $ trigger for Codex instead of experts
The Codex $ prefix autocomplete now lists skills from acp_list_agent_skills (excluding ids that match built-in experts), while experts remain reachable via the dedicated Sparkles button. Non-Codex agents are unchanged and still show only their native ACP commands on the / trigger. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -12,6 +12,7 @@ import {
|
|||||||
} from "@/components/ui/popover"
|
} from "@/components/ui/popover"
|
||||||
import { Textarea } from "@/components/ui/textarea"
|
import { Textarea } from "@/components/ui/textarea"
|
||||||
import {
|
import {
|
||||||
|
BookOpenText,
|
||||||
Check,
|
Check,
|
||||||
ChevronUp,
|
ChevronUp,
|
||||||
Ellipsis,
|
Ellipsis,
|
||||||
@@ -41,6 +42,7 @@ import { readFileBase64 } from "@/lib/api"
|
|||||||
import { openFileDialog } from "@/lib/platform"
|
import { openFileDialog } from "@/lib/platform"
|
||||||
import { disposeTauriListener } from "@/lib/tauri-listener"
|
import { disposeTauriListener } from "@/lib/tauri-listener"
|
||||||
import type {
|
import type {
|
||||||
|
AgentSkillItem,
|
||||||
AgentType,
|
AgentType,
|
||||||
AvailableCommandInfo,
|
AvailableCommandInfo,
|
||||||
ExpertListItem,
|
ExpertListItem,
|
||||||
@@ -67,6 +69,7 @@ import { DropdownRadioItemContent } from "@/components/chat/dropdown-radio-item-
|
|||||||
import { useFileTree } from "@/hooks/use-file-tree"
|
import { useFileTree } from "@/hooks/use-file-tree"
|
||||||
import { useBuiltInExperts } from "@/hooks/use-built-in-experts"
|
import { useBuiltInExperts } from "@/hooks/use-built-in-experts"
|
||||||
import { useAgentExperts } from "@/hooks/use-agent-experts"
|
import { useAgentExperts } from "@/hooks/use-agent-experts"
|
||||||
|
import { useAgentSkills } from "@/hooks/use-agent-skills"
|
||||||
import { joinFsPath } from "@/lib/path-utils"
|
import { joinFsPath } from "@/lib/path-utils"
|
||||||
import {
|
import {
|
||||||
clearMessageInputDraft,
|
clearMessageInputDraft,
|
||||||
@@ -320,8 +323,22 @@ export function MessageInput({
|
|||||||
[builtInExperts]
|
[builtInExperts]
|
||||||
)
|
)
|
||||||
// Experts linked to the current agent via symlinks in the settings page.
|
// Experts linked to the current agent via symlinks in the settings page.
|
||||||
// This is the single source of truth — no dependency on ACP availableCommands.
|
// Kept so the dedicated expert (Sparkles) button can still surface them.
|
||||||
const availableExperts = useAgentExperts(agentType ?? null)
|
const availableExperts = useAgentExperts(agentType ?? null)
|
||||||
|
// The `$` prefix autocomplete is Codex-only: Codex advertises very few
|
||||||
|
// native slash commands, so we augment the dropdown with the agent's
|
||||||
|
// skills read from disk. Other agents already surface their full command
|
||||||
|
// set through ACP `availableCommands`, so injecting skills there would
|
||||||
|
// be duplicate/extra UI noise — skip the skills fetch for them entirely.
|
||||||
|
const skillAgentType = agentType === "codex" ? "codex" : null
|
||||||
|
const availableSkills = useAgentSkills(skillAgentType)
|
||||||
|
// Expert skills are symlinked into the agent's skill directories, so they
|
||||||
|
// also show up in `acp_list_agent_skills`. Strip them out — experts remain
|
||||||
|
// reachable via the expert button, and the `$` list is skills-only.
|
||||||
|
const nonExpertSkills = useMemo(
|
||||||
|
() => availableSkills.filter((skill) => !expertIdSet.has(skill.id)),
|
||||||
|
[availableSkills, expertIdSet]
|
||||||
|
)
|
||||||
const expertPrefix = agentType === "codex" ? "$" : "/"
|
const expertPrefix = agentType === "codex" ? "$" : "/"
|
||||||
// Stable presentation order for expert categories in the button
|
// Stable presentation order for expert categories in the button
|
||||||
// dropdown. Keep this in sync with experts-settings.tsx so both surfaces
|
// dropdown. Keep this in sync with experts-settings.tsx so both surfaces
|
||||||
@@ -502,27 +519,23 @@ export function MessageInput({
|
|||||||
|
|
||||||
// ── Slash command autocomplete ──
|
// ── Slash command autocomplete ──
|
||||||
//
|
//
|
||||||
// Built-in experts are always surfaced via a dedicated button, so any
|
// Built-in experts are always surfaced via the Sparkles button, so any
|
||||||
// agent-advertised command whose name matches an expert id is hidden
|
// agent-advertised command whose name matches an expert id is hidden
|
||||||
// from the slash list to avoid showing the same item twice. Autocomplete
|
// from the slash list to avoid showing the same item twice. For non-Codex
|
||||||
// for `/` merges the filtered agent commands and the built-in experts
|
// agents the dropdown only shows the agent's own `availableCommands` —
|
||||||
// into a single flat list — agent commands first, then experts — so
|
// Codex additionally gets a `$`-triggered skills list because its native
|
||||||
// typing `/brain` still completes `brainstorming`.
|
// command set is very small.
|
||||||
const [slashMenuOpen, setSlashMenuOpen] = useState(false)
|
const [slashMenuOpen, setSlashMenuOpen] = useState(false)
|
||||||
const [slashSelectedIndex, setSlashSelectedIndex] = useState(0)
|
const [slashSelectedIndex, setSlashSelectedIndex] = useState(0)
|
||||||
const slashCommands = useMemo(
|
const slashCommands = useMemo(
|
||||||
() => (availableCommands ?? []).filter((cmd) => !expertIdSet.has(cmd.name)),
|
() => (availableCommands ?? []).filter((cmd) => !expertIdSet.has(cmd.name)),
|
||||||
[availableCommands, expertIdSet]
|
[availableCommands, expertIdSet]
|
||||||
)
|
)
|
||||||
// For Codex the menu triggers on both "/" (commands) and "$" (experts).
|
// For Codex the menu triggers on both "/" (commands) and "$" (skills).
|
||||||
const menuTriggerRegex = useMemo(
|
const menuTriggerRegex = useMemo(
|
||||||
() => (agentType === "codex" ? /^[/$](\S*)$/ : /^\/(\S*)$/),
|
() => (agentType === "codex" ? /^[/$](\S*)$/ : /^\/(\S*)$/),
|
||||||
[agentType]
|
[agentType]
|
||||||
)
|
)
|
||||||
const expertPrefixRegex = useMemo(
|
|
||||||
() => (agentType === "codex" ? /^\$(\S*)$/ : /^\/(\S*)$/),
|
|
||||||
[agentType]
|
|
||||||
)
|
|
||||||
const filteredSlashCommands = useMemo(() => {
|
const filteredSlashCommands = useMemo(() => {
|
||||||
if (!slashMenuOpen || slashCommands.length === 0) return []
|
if (!slashMenuOpen || slashCommands.length === 0) return []
|
||||||
const match = text.match(/^\/(\S*)$/)
|
const match = text.match(/^\/(\S*)$/)
|
||||||
@@ -532,17 +545,19 @@ export function MessageInput({
|
|||||||
cmd.name.toLowerCase().startsWith(filter)
|
cmd.name.toLowerCase().startsWith(filter)
|
||||||
)
|
)
|
||||||
}, [slashMenuOpen, slashCommands, text])
|
}, [slashMenuOpen, slashCommands, text])
|
||||||
const filteredSlashExperts = useMemo(() => {
|
const filteredSlashSkills = useMemo(() => {
|
||||||
if (!slashMenuOpen || availableExperts.length === 0) return []
|
// Skills autocomplete is Codex-only and triggered by `$`.
|
||||||
const match = text.match(expertPrefixRegex)
|
if (agentType !== "codex") return []
|
||||||
|
if (!slashMenuOpen || nonExpertSkills.length === 0) return []
|
||||||
|
const match = text.match(/^\$(\S*)$/)
|
||||||
if (!match) return []
|
if (!match) return []
|
||||||
const filter = match[1].toLowerCase()
|
const filter = match[1].toLowerCase()
|
||||||
return availableExperts.filter((item) =>
|
return nonExpertSkills.filter((skill) =>
|
||||||
item.metadata.id.toLowerCase().startsWith(filter)
|
skill.id.toLowerCase().startsWith(filter)
|
||||||
)
|
)
|
||||||
}, [slashMenuOpen, availableExperts, text, expertPrefixRegex])
|
}, [slashMenuOpen, nonExpertSkills, text, agentType])
|
||||||
const slashAutocompleteCount =
|
const slashAutocompleteCount =
|
||||||
filteredSlashCommands.length + filteredSlashExperts.length
|
filteredSlashCommands.length + filteredSlashSkills.length
|
||||||
|
|
||||||
// Keep the highlighted row inside the current result window. As the user
|
// Keep the highlighted row inside the current result window. As the user
|
||||||
// types and the filter narrows, the previously-highlighted index can point
|
// types and the filter narrows, the previously-highlighted index can point
|
||||||
@@ -918,9 +933,11 @@ export function MessageInput({
|
|||||||
})
|
})
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const handleExpertAutocompleteSelect = useCallback(
|
const handleSkillAutocompleteSelect = useCallback(
|
||||||
(expert: ExpertListItem) => {
|
(skill: AgentSkillItem) => {
|
||||||
setText(`${expertPrefix}${expert.metadata.id} `)
|
// Codex uses `$<id>`, other agents use `/<id>` — matching the prefix
|
||||||
|
// that triggered the autocomplete list.
|
||||||
|
setText(`${expertPrefix}${skill.id} `)
|
||||||
setSlashMenuOpen(false)
|
setSlashMenuOpen(false)
|
||||||
},
|
},
|
||||||
[expertPrefix]
|
[expertPrefix]
|
||||||
@@ -996,11 +1013,13 @@ export function MessageInput({
|
|||||||
const value = e.target.value
|
const value = e.target.value
|
||||||
setText(value)
|
setText(value)
|
||||||
|
|
||||||
// Slash command detection (only at start of input). Either an agent
|
// Slash command detection (only at start of input). Any of agent
|
||||||
// command or an agent-enabled expert can satisfy the prompt, so open
|
// commands, agent-enabled experts, or (for Codex) skills can satisfy
|
||||||
// the menu whenever at least one of them is available.
|
// the prompt, so open the menu whenever at least one is available.
|
||||||
const hasSlashSource =
|
const hasSlashSource =
|
||||||
slashCommands.length > 0 || availableExperts.length > 0
|
slashCommands.length > 0 ||
|
||||||
|
availableExperts.length > 0 ||
|
||||||
|
nonExpertSkills.length > 0
|
||||||
if (hasSlashSource && menuTriggerRegex.test(value)) {
|
if (hasSlashSource && menuTriggerRegex.test(value)) {
|
||||||
setSlashSelectedIndex(0)
|
setSlashSelectedIndex(0)
|
||||||
setSlashMenuOpen(true)
|
setSlashMenuOpen(true)
|
||||||
@@ -1030,6 +1049,7 @@ export function MessageInput({
|
|||||||
[
|
[
|
||||||
slashCommands.length,
|
slashCommands.length,
|
||||||
availableExperts.length,
|
availableExperts.length,
|
||||||
|
nonExpertSkills.length,
|
||||||
defaultPath,
|
defaultPath,
|
||||||
menuTriggerRegex,
|
menuTriggerRegex,
|
||||||
]
|
]
|
||||||
@@ -1331,14 +1351,14 @@ export function MessageInput({
|
|||||||
}
|
}
|
||||||
if (e.key === "Enter" || e.key === "Tab") {
|
if (e.key === "Enter" || e.key === "Tab") {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
|
// The merged list is [commands, skills].
|
||||||
if (slashSelectedIndex < filteredSlashCommands.length) {
|
if (slashSelectedIndex < filteredSlashCommands.length) {
|
||||||
handleSlashSelect(filteredSlashCommands[slashSelectedIndex])
|
handleSlashSelect(filteredSlashCommands[slashSelectedIndex])
|
||||||
} else {
|
} else {
|
||||||
const expertIndex =
|
const skillIndex = slashSelectedIndex - filteredSlashCommands.length
|
||||||
slashSelectedIndex - filteredSlashCommands.length
|
const skill = filteredSlashSkills[skillIndex]
|
||||||
const expert = filteredSlashExperts[expertIndex]
|
if (skill) {
|
||||||
if (expert) {
|
handleSkillAutocompleteSelect(skill)
|
||||||
handleExpertAutocompleteSelect(expert)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
@@ -1409,10 +1429,10 @@ export function MessageInput({
|
|||||||
slashMenuOpen,
|
slashMenuOpen,
|
||||||
slashAutocompleteCount,
|
slashAutocompleteCount,
|
||||||
filteredSlashCommands,
|
filteredSlashCommands,
|
||||||
filteredSlashExperts,
|
filteredSlashSkills,
|
||||||
slashSelectedIndex,
|
slashSelectedIndex,
|
||||||
handleSlashSelect,
|
handleSlashSelect,
|
||||||
handleExpertAutocompleteSelect,
|
handleSkillAutocompleteSelect,
|
||||||
atMenuOpen,
|
atMenuOpen,
|
||||||
filteredAtFiles,
|
filteredAtFiles,
|
||||||
atSelectedIndex,
|
atSelectedIndex,
|
||||||
@@ -1608,19 +1628,11 @@ export function MessageInput({
|
|||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
{filteredSlashExperts.map((expert, i) => {
|
{filteredSlashSkills.map((skill, i) => {
|
||||||
const absoluteIndex = filteredSlashCommands.length + i
|
const absoluteIndex = filteredSlashCommands.length + i
|
||||||
const Icon = getExpertIcon(expert.metadata.icon)
|
|
||||||
const name =
|
|
||||||
pickExpertLocalized(expert.metadata.display_name, locale) ||
|
|
||||||
expert.metadata.id
|
|
||||||
const description = pickExpertLocalized(
|
|
||||||
expert.metadata.description,
|
|
||||||
locale
|
|
||||||
)
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={`expert-${expert.metadata.id}`}
|
key={`skill-${skill.scope}-${skill.id}`}
|
||||||
type="button"
|
type="button"
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex w-full items-start gap-2 rounded-lg px-3 py-2 text-left text-sm",
|
"flex w-full items-start gap-2 rounded-lg px-3 py-2 text-left text-sm",
|
||||||
@@ -1630,23 +1642,18 @@ export function MessageInput({
|
|||||||
)}
|
)}
|
||||||
onMouseDown={(e) => {
|
onMouseDown={(e) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
handleExpertAutocompleteSelect(expert)
|
handleSkillAutocompleteSelect(skill)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Icon className="mt-0.5 size-4 shrink-0 text-primary/80" />
|
<BookOpenText className="mt-0.5 size-4 shrink-0 text-primary/80" />
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
<span className="truncate font-medium">{name}</span>
|
<span className="truncate font-medium">{skill.name}</span>
|
||||||
<span className="shrink-0 font-mono text-[11px] text-muted-foreground">
|
<span className="shrink-0 font-mono text-[11px] text-muted-foreground">
|
||||||
{expertPrefix}
|
{expertPrefix}
|
||||||
{expert.metadata.id}
|
{skill.id}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{description && (
|
|
||||||
<div className="truncate text-xs text-muted-foreground">
|
|
||||||
{description}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
)
|
)
|
||||||
|
|||||||
87
src/hooks/use-agent-skills.ts
Normal file
87
src/hooks/use-agent-skills.ts
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useMemo, useState } from "react"
|
||||||
|
|
||||||
|
import { acpListAgentSkills } from "@/lib/api"
|
||||||
|
import type { AgentSkillItem, AgentType } from "@/lib/types"
|
||||||
|
|
||||||
|
const agentCache = new Map<AgentType, AgentSkillItem[]>()
|
||||||
|
const inflightMap = new Map<AgentType, Promise<AgentSkillItem[]>>()
|
||||||
|
|
||||||
|
const EMPTY: AgentSkillItem[] = []
|
||||||
|
|
||||||
|
function fetchForAgent(agentType: AgentType): Promise<AgentSkillItem[]> {
|
||||||
|
let promise = inflightMap.get(agentType)
|
||||||
|
if (!promise) {
|
||||||
|
promise = acpListAgentSkills({ agentType })
|
||||||
|
.then((result) => {
|
||||||
|
const skills = result.supported ? result.skills : EMPTY
|
||||||
|
agentCache.set(agentType, skills)
|
||||||
|
inflightMap.delete(agentType)
|
||||||
|
return skills
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
inflightMap.delete(agentType)
|
||||||
|
console.warn("[useAgentSkills] failed:", err)
|
||||||
|
return EMPTY
|
||||||
|
})
|
||||||
|
inflightMap.set(agentType, promise)
|
||||||
|
}
|
||||||
|
return promise
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAgentSkills(agentType: AgentType | null): AgentSkillItem[] {
|
||||||
|
const cached = useMemo(
|
||||||
|
() => (agentType ? (agentCache.get(agentType) ?? null) : null),
|
||||||
|
[agentType]
|
||||||
|
)
|
||||||
|
// Track which agent type the fetched result belongs to so stale data
|
||||||
|
// from a previous agent is never returned after a switch.
|
||||||
|
const [fetched, setFetched] = useState<{
|
||||||
|
agentType: AgentType
|
||||||
|
skills: AgentSkillItem[]
|
||||||
|
} | null>(null)
|
||||||
|
|
||||||
|
const doFetch = useCallback(() => {
|
||||||
|
if (!agentType || agentCache.has(agentType)) return
|
||||||
|
let cancelled = false
|
||||||
|
fetchForAgent(agentType).then((list) => {
|
||||||
|
if (!cancelled) setFetched({ agentType, skills: list })
|
||||||
|
})
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
}
|
||||||
|
}, [agentType])
|
||||||
|
|
||||||
|
// Initial fetch
|
||||||
|
useEffect(() => doFetch(), [doFetch])
|
||||||
|
|
||||||
|
// Re-fetch when window regains focus (covers cross-window cache
|
||||||
|
// invalidation — e.g. settings window creates/removes skills while the
|
||||||
|
// conversation window stays mounted).
|
||||||
|
useEffect(() => {
|
||||||
|
const onFocus = () => {
|
||||||
|
if (!agentType) return
|
||||||
|
agentCache.delete(agentType)
|
||||||
|
inflightMap.delete(agentType)
|
||||||
|
doFetch()
|
||||||
|
}
|
||||||
|
window.addEventListener("focus", onFocus)
|
||||||
|
return () => window.removeEventListener("focus", onFocus)
|
||||||
|
}, [agentType, doFetch])
|
||||||
|
|
||||||
|
if (!agentType) return EMPTY
|
||||||
|
if (cached) return cached
|
||||||
|
if (fetched && fetched.agentType === agentType) return fetched.skills
|
||||||
|
return EMPTY
|
||||||
|
}
|
||||||
|
|
||||||
|
export function invalidateAgentSkillsCache(agentType?: AgentType) {
|
||||||
|
if (agentType) {
|
||||||
|
agentCache.delete(agentType)
|
||||||
|
inflightMap.delete(agentType)
|
||||||
|
} else {
|
||||||
|
agentCache.clear()
|
||||||
|
inflightMap.clear()
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user