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:
xintaofei
2026-04-11 10:20:13 +08:00
parent 1bb8624160
commit b97efb5f72
2 changed files with 145 additions and 51 deletions

View File

@@ -12,6 +12,7 @@ import {
} from "@/components/ui/popover"
import { Textarea } from "@/components/ui/textarea"
import {
BookOpenText,
Check,
ChevronUp,
Ellipsis,
@@ -41,6 +42,7 @@ import { readFileBase64 } from "@/lib/api"
import { openFileDialog } from "@/lib/platform"
import { disposeTauriListener } from "@/lib/tauri-listener"
import type {
AgentSkillItem,
AgentType,
AvailableCommandInfo,
ExpertListItem,
@@ -67,6 +69,7 @@ import { DropdownRadioItemContent } from "@/components/chat/dropdown-radio-item-
import { useFileTree } from "@/hooks/use-file-tree"
import { useBuiltInExperts } from "@/hooks/use-built-in-experts"
import { useAgentExperts } from "@/hooks/use-agent-experts"
import { useAgentSkills } from "@/hooks/use-agent-skills"
import { joinFsPath } from "@/lib/path-utils"
import {
clearMessageInputDraft,
@@ -320,8 +323,22 @@ export function MessageInput({
[builtInExperts]
)
// 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)
// 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" ? "$" : "/"
// Stable presentation order for expert categories in the button
// dropdown. Keep this in sync with experts-settings.tsx so both surfaces
@@ -502,27 +519,23 @@ export function MessageInput({
// ── 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
// from the slash list to avoid showing the same item twice. Autocomplete
// for `/` merges the filtered agent commands and the built-in experts
// into a single flat list — agent commands first, then experts — so
// typing `/brain` still completes `brainstorming`.
// from the slash list to avoid showing the same item twice. For non-Codex
// agents the dropdown only shows the agent's own `availableCommands` —
// Codex additionally gets a `$`-triggered skills list because its native
// command set is very small.
const [slashMenuOpen, setSlashMenuOpen] = useState(false)
const [slashSelectedIndex, setSlashSelectedIndex] = useState(0)
const slashCommands = useMemo(
() => (availableCommands ?? []).filter((cmd) => !expertIdSet.has(cmd.name)),
[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(
() => (agentType === "codex" ? /^[/$](\S*)$/ : /^\/(\S*)$/),
[agentType]
)
const expertPrefixRegex = useMemo(
() => (agentType === "codex" ? /^\$(\S*)$/ : /^\/(\S*)$/),
[agentType]
)
const filteredSlashCommands = useMemo(() => {
if (!slashMenuOpen || slashCommands.length === 0) return []
const match = text.match(/^\/(\S*)$/)
@@ -532,17 +545,19 @@ export function MessageInput({
cmd.name.toLowerCase().startsWith(filter)
)
}, [slashMenuOpen, slashCommands, text])
const filteredSlashExperts = useMemo(() => {
if (!slashMenuOpen || availableExperts.length === 0) return []
const match = text.match(expertPrefixRegex)
const filteredSlashSkills = useMemo(() => {
// Skills autocomplete is Codex-only and triggered by `$`.
if (agentType !== "codex") return []
if (!slashMenuOpen || nonExpertSkills.length === 0) return []
const match = text.match(/^\$(\S*)$/)
if (!match) return []
const filter = match[1].toLowerCase()
return availableExperts.filter((item) =>
item.metadata.id.toLowerCase().startsWith(filter)
return nonExpertSkills.filter((skill) =>
skill.id.toLowerCase().startsWith(filter)
)
}, [slashMenuOpen, availableExperts, text, expertPrefixRegex])
}, [slashMenuOpen, nonExpertSkills, text, agentType])
const slashAutocompleteCount =
filteredSlashCommands.length + filteredSlashExperts.length
filteredSlashCommands.length + filteredSlashSkills.length
// Keep the highlighted row inside the current result window. As the user
// types and the filter narrows, the previously-highlighted index can point
@@ -918,9 +933,11 @@ export function MessageInput({
})
}, [])
const handleExpertAutocompleteSelect = useCallback(
(expert: ExpertListItem) => {
setText(`${expertPrefix}${expert.metadata.id} `)
const handleSkillAutocompleteSelect = useCallback(
(skill: AgentSkillItem) => {
// Codex uses `$<id>`, other agents use `/<id>` — matching the prefix
// that triggered the autocomplete list.
setText(`${expertPrefix}${skill.id} `)
setSlashMenuOpen(false)
},
[expertPrefix]
@@ -996,11 +1013,13 @@ export function MessageInput({
const value = e.target.value
setText(value)
// Slash command detection (only at start of input). Either an agent
// command or an agent-enabled expert can satisfy the prompt, so open
// the menu whenever at least one of them is available.
// Slash command detection (only at start of input). Any of agent
// commands, agent-enabled experts, or (for Codex) skills can satisfy
// the prompt, so open the menu whenever at least one is available.
const hasSlashSource =
slashCommands.length > 0 || availableExperts.length > 0
slashCommands.length > 0 ||
availableExperts.length > 0 ||
nonExpertSkills.length > 0
if (hasSlashSource && menuTriggerRegex.test(value)) {
setSlashSelectedIndex(0)
setSlashMenuOpen(true)
@@ -1030,6 +1049,7 @@ export function MessageInput({
[
slashCommands.length,
availableExperts.length,
nonExpertSkills.length,
defaultPath,
menuTriggerRegex,
]
@@ -1331,14 +1351,14 @@ export function MessageInput({
}
if (e.key === "Enter" || e.key === "Tab") {
e.preventDefault()
// The merged list is [commands, skills].
if (slashSelectedIndex < filteredSlashCommands.length) {
handleSlashSelect(filteredSlashCommands[slashSelectedIndex])
} else {
const expertIndex =
slashSelectedIndex - filteredSlashCommands.length
const expert = filteredSlashExperts[expertIndex]
if (expert) {
handleExpertAutocompleteSelect(expert)
const skillIndex = slashSelectedIndex - filteredSlashCommands.length
const skill = filteredSlashSkills[skillIndex]
if (skill) {
handleSkillAutocompleteSelect(skill)
}
}
return
@@ -1409,10 +1429,10 @@ export function MessageInput({
slashMenuOpen,
slashAutocompleteCount,
filteredSlashCommands,
filteredSlashExperts,
filteredSlashSkills,
slashSelectedIndex,
handleSlashSelect,
handleExpertAutocompleteSelect,
handleSkillAutocompleteSelect,
atMenuOpen,
filteredAtFiles,
atSelectedIndex,
@@ -1608,19 +1628,11 @@ export function MessageInput({
</span>
</button>
))}
{filteredSlashExperts.map((expert, i) => {
{filteredSlashSkills.map((skill, 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 (
<button
key={`expert-${expert.metadata.id}`}
key={`skill-${skill.scope}-${skill.id}`}
type="button"
className={cn(
"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) => {
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="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">
{expertPrefix}
{expert.metadata.id}
{skill.id}
</span>
</div>
{description && (
<div className="truncate text-xs text-muted-foreground">
{description}
</div>
)}
</div>
</button>
)

View 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()
}
}