diff --git a/src/components/chat/message-input.tsx b/src/components/chat/message-input.tsx index 46a8a9b..256b676 100644 --- a/src/components/chat/message-input.tsx +++ b/src/components/chat/message-input.tsx @@ -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 `$`, other agents use `/` — 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({ ))} - {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 ( ) diff --git a/src/hooks/use-agent-skills.ts b/src/hooks/use-agent-skills.ts new file mode 100644 index 0000000..2ce887f --- /dev/null +++ b/src/hooks/use-agent-skills.ts @@ -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() +const inflightMap = new Map>() + +const EMPTY: AgentSkillItem[] = [] + +function fetchForAgent(agentType: AgentType): Promise { + 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() + } +}