From 80fd3d9fa9c2e8069382fb087de4aadac54dcd6e Mon Sep 17 00:00:00 2001 From: indifferentketchup Date: Mon, 18 May 2026 01:10:51 +0000 Subject: [PATCH] feat(web): /skill slash command with autocomplete Trigger /, dropdown lists all skills filtered by name prefix, arg passthrough sends the rest as the user message. Synthetic skill_use tool_use renders identically to model-invoked skills. --- apps/web/src/components/ChatInput.tsx | 91 +++++++++++- apps/web/src/components/SkillSlashCommand.tsx | 137 ++++++++++++++++++ apps/web/src/components/panes/ChatPane.tsx | 13 ++ apps/web/src/hooks/useSkills.ts | 43 ++++++ 4 files changed, 282 insertions(+), 2 deletions(-) create mode 100644 apps/web/src/components/SkillSlashCommand.tsx create mode 100644 apps/web/src/hooks/useSkills.ts diff --git a/apps/web/src/components/ChatInput.tsx b/apps/web/src/components/ChatInput.tsx index 9c4aa70..5e7b235 100644 --- a/apps/web/src/components/ChatInput.tsx +++ b/apps/web/src/components/ChatInput.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useRef, useState, type DragEvent, type KeyboardEvent } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState, type DragEvent, type KeyboardEvent } from 'react'; import { Check, Plus, Send } from 'lucide-react'; import { toast } from 'sonner'; import { Textarea } from '@/components/ui/textarea'; @@ -22,8 +22,10 @@ import { AttachmentPreviewModal } from '@/components/AttachmentPreviewModal'; import { FileMentionPopover } from '@/components/FileMentionPopover'; import { DropOverlay } from '@/components/DropOverlay'; import { AgentPicker } from '@/components/AgentPicker'; +import { SkillSlashCommand } from '@/components/SkillSlashCommand'; import { api } from '@/api/client'; import { sessionEvents } from '@/hooks/sessionEvents'; +import { useSkills } from '@/hooks/useSkills'; import { useViewport } from '@/hooks/useViewport'; const MAX_ATTACHMENTS = 10; @@ -44,9 +46,14 @@ interface Props { webSearchEnabled?: boolean | null; onSend: (content: string) => void | Promise; onForceSend?: (content: string) => void | Promise; + // Batch 9.6: slash-command dispatch. When the input parses to a known skill, + // ChatInput calls this with the skill name + the post-name args (possibly + // empty). Callers wire this to api.chats.skillInvoke. Omitting the prop + // disables slash-command dispatch (input is sent as literal text). + onSlashCommand?: (skillName: string, userMessage: string) => void | Promise; } -export function ChatInput({ disabled, projectId, agentId, onAgentChange, sessionId, webSearchEnabled, onSend, onForceSend }: Props) { +export function ChatInput({ disabled, projectId, agentId, onAgentChange, sessionId, webSearchEnabled, onSend, onForceSend, onSlashCommand }: Props) { const { isMobile } = useViewport(); const [value, setValue] = useState(''); const [busy, setBusy] = useState(false); @@ -61,6 +68,19 @@ export function ChatInput({ disabled, projectId, agentId, onAgentChange, session atIdx: number; anchorRect: { top: number; left: number }; } | null>(null); + // Batch 9.6: slash-command dropdown. Opens when `/` is the first char of + // the input and stays open while the input is `/` with no whitespace. + // Disabled entirely when the caller doesn't pass onSlashCommand. + const [slashState, setSlashState] = useState<{ + query: string; + anchorRect: { top: number; left: number }; + } | null>(null); + const { skills } = useSkills(); + const skillsLookup = useMemo(() => { + const m = new Map(); + for (const s of skills) m.set(s.name, true); + return m; + }, [skills]); const [fileIndex, setFileIndex] = useState(null); const textareaRef = useRef(null); @@ -95,6 +115,31 @@ export function ChatInput({ disabled, projectId, agentId, onAgentChange, session const text = value.trim(); if (!text && attachments.length === 0) return; if (disabled || busy) return; + + // Batch 9.6: slash-command dispatch. Only when no attachments and the + // input parses to a known skill. Falls through to onSend for unknown + // slash names (literal text) or when slash dispatch isn't wired. + if (onSlashCommand && attachments.length === 0 && text.startsWith('/')) { + const match = text.match(/^\/(\S+)\s*([\s\S]*)$/); + if (match && skillsLookup.has(match[1]!)) { + const skillName = match[1]!; + const args = (match[2] ?? '').trim(); + setBusy(true); + try { + await onSlashCommand(skillName, args); + setValue(''); + setAttachments([]); + setSlashState(null); + } catch (err) { + toast.error(err instanceof Error ? err.message : 'skill invocation failed'); + } finally { + setBusy(false); + } + return; + } + // Unknown skill name — fall through and send as literal text. + } + setBusy(true); try { const body = flattenToMessage(attachments, text); @@ -108,6 +153,19 @@ export function ChatInput({ disabled, projectId, agentId, onAgentChange, session } } + function handleSlashSelect(skillName: string) { + const next = `/${skillName} `; + setValue(next); + setSlashState(null); + requestAnimationFrame(() => { + const ta = textareaRef.current; + if (ta) { + ta.selectionStart = ta.selectionEnd = next.length; + ta.focus(); + } + }); + } + function getCaretCoords(textarea: HTMLTextAreaElement): { top: number; left: number } { const mirror = document.createElement('div'); const style = window.getComputedStyle(textarea); @@ -158,6 +216,23 @@ export function ChatInput({ disabled, projectId, agentId, onAgentChange, session const ta = e.target; const pos = ta.selectionStart; + // Batch 9.6: slash-command trigger. Active while the input is a single + // slash-prefixed token with no whitespace (i.e. user is still typing the + // skill name). Hand off to args mode the moment a space appears or the + // slash leaves position 0. + if (onSlashCommand && /^\/[^\s]*$/.test(newValue)) { + const query = newValue.slice(1); + if (!slashState) { + const rect = ta.getBoundingClientRect(); + setSlashState({ query, anchorRect: { top: rect.top, left: rect.left } }); + } else if (slashState.query !== query) { + setSlashState({ ...slashState, query }); + } + if (mentionState?.open) setMentionState(null); + return; + } + if (slashState) setSlashState(null); + // Check for @ trigger if (pos > 0 && newValue[pos - 1] === '@') { const charBefore = pos >= 2 ? newValue[pos - 2] : null; @@ -374,6 +449,9 @@ export function ChatInput({ disabled, projectId, agentId, onAgentChange, session function onKeyDown(e: KeyboardEvent) { if (mentionState?.open) return; + // SkillSlashCommand owns Arrow/Enter/Tab/Esc via a document listener; let + // it consume them so the textarea doesn't also submit on Enter. + if (slashState) return; // IME safety: never act on Enter while an IME composition is in flight // (CJK input methods commit composition via Enter). Without this, the // first Enter of a Japanese/Chinese/Korean composition would submit @@ -524,6 +602,15 @@ export function ChatInput({ disabled, projectId, agentId, onAgentChange, session onClose={closeMention} /> )} + {slashState && ( + setSlashState(null)} + /> + )} ); } diff --git a/apps/web/src/components/SkillSlashCommand.tsx b/apps/web/src/components/SkillSlashCommand.tsx new file mode 100644 index 0000000..8f43256 --- /dev/null +++ b/apps/web/src/components/SkillSlashCommand.tsx @@ -0,0 +1,137 @@ +import { useEffect, useMemo, useRef, useState } from 'react'; +import { cn } from '@/lib/utils'; +import type { Skill } from '@/api/types'; + +interface Props { + query: string; + skills: Skill[]; + anchorRect: { top: number; left: number }; + onSelect: (skillName: string) => void; + onClose: () => void; +} + +// Batch 9.6: slash-command dropdown. Models FileMentionPopover's pattern — +// fixed-positioned popover, keyboard nav, click-outside-to-close. shadcn +// `Command` (cmdk) isn't installed in this project; per the addendum we use +// a plain div + Tailwind instead of pulling a new primitive autonomously. + +// Case-insensitive prefix match on `name` only. Description is display-only +// in v1 (substring search across description is deferred to a polish batch). +function filterByPrefix(skills: Skill[], query: string): Skill[] { + const q = query.toLowerCase(); + const filtered = q + ? skills.filter((s) => s.name.toLowerCase().startsWith(q)) + : skills; + // Stable alphabetical ordering matches the server's cache order (skills.ts + // sorts on name asc) but we re-sort here so a stale client cache doesn't + // surprise the user. + return [...filtered].sort((a, b) => a.name.localeCompare(b.name)); +} + +export function SkillSlashCommand({ query, skills, anchorRect, onSelect, onClose }: Props) { + const [highlightIndex, setHighlightIndex] = useState(0); + const popoverRef = useRef(null); + const filtered = useMemo(() => filterByPrefix(skills, query), [skills, query]); + + useEffect(() => { setHighlightIndex(0); }, [query]); + + // Arrow / Enter / Tab / Escape. Bound on document so keystrokes from the + // textarea reach the popover even though focus stays in the textarea. + useEffect(() => { + function handleKeyDown(e: KeyboardEvent) { + if (e.key === 'ArrowDown') { + e.preventDefault(); + setHighlightIndex((prev) => (prev < filtered.length - 1 ? prev + 1 : 0)); + } else if (e.key === 'ArrowUp') { + e.preventDefault(); + setHighlightIndex((prev) => (prev > 0 ? prev - 1 : filtered.length - 1)); + } else if (e.key === 'Enter' || e.key === 'Tab') { + if (filtered.length === 0) return; + e.preventDefault(); + const target = filtered[highlightIndex] ?? filtered[0]; + if (target) onSelect(target.name); + } else if (e.key === 'Escape') { + e.preventDefault(); + onClose(); + } + } + document.addEventListener('keydown', handleKeyDown); + return () => document.removeEventListener('keydown', handleKeyDown); + }, [filtered, highlightIndex, onSelect, onClose]); + + useEffect(() => { + function handleMouseDown(e: MouseEvent) { + if (popoverRef.current && !popoverRef.current.contains(e.target as Node)) { + onClose(); + } + } + document.addEventListener('mousedown', handleMouseDown); + return () => document.removeEventListener('mousedown', handleMouseDown); + }, [onClose]); + + useEffect(() => { + const el = popoverRef.current?.querySelector('[data-highlighted="true"]'); + if (el) el.scrollIntoView({ block: 'nearest' }); + }, [highlightIndex]); + + // Anchor sits above the input — translate(-100%) on Y so the dropdown + // expands upward from the anchor point rather than over the textarea. + const style = { + top: anchorRect.top, + left: anchorRect.left, + transform: 'translateY(-100%)', + } as const; + + if (filtered.length === 0) { + return ( +
+
+ {query ? `No skill starts with "/${query}"` : 'No skills available'} +
+
+ ); + } + + return ( +
+ {filtered.map((skill, i) => ( + + ))} +
+ ); +} diff --git a/apps/web/src/components/panes/ChatPane.tsx b/apps/web/src/components/panes/ChatPane.tsx index 49da14c..8141cc7 100644 --- a/apps/web/src/components/panes/ChatPane.tsx +++ b/apps/web/src/components/panes/ChatPane.tsx @@ -96,6 +96,18 @@ export function ChatPane({ sessionId, chatId, projectId, agentId, onAgentChange, } }, [chatId]); + // Batch 9.6: slash-command dispatch. Sent regardless of streaming state — + // matches the existing /compact precedent (which also fires immediately). + // Empty args go to the server as null; the server fills in a default user + // message ("Apply this skill.") so the model has something to act on. + const handleSlashCommand = useCallback(async (skillName: string, userMessage: string) => { + try { + await api.chats.skillInvoke(chatId, skillName, userMessage.length > 0 ? userMessage : null); + } catch (err) { + toast.error(err instanceof Error ? err.message : `/${skillName} failed`); + } + }, [chatId]); + function removeQueued(idx: number) { setQueue((prev) => prev.filter((_, i) => i !== idx)); } @@ -183,6 +195,7 @@ export function ChatPane({ sessionId, chatId, projectId, agentId, onAgentChange, webSearchEnabled={webSearchEnabled} onSend={handleSend} onForceSend={streaming ? handleForceSend : undefined} + onSlashCommand={handleSlashCommand} /> diff --git a/apps/web/src/hooks/useSkills.ts b/apps/web/src/hooks/useSkills.ts new file mode 100644 index 0000000..086b191 --- /dev/null +++ b/apps/web/src/hooks/useSkills.ts @@ -0,0 +1,43 @@ +import { useEffect, useState } from 'react'; +import { api } from '@/api/client'; +import type { Skill } from '@/api/types'; + +// Batch 9.6: shared in-memory cache for the slash-command dropdown. One fetch +// per process; subsequent mounts of useSkills() return the cached list and +// don't re-hit /api/skills. Matches the useSidebar / useChatStatus module- +// singleton pattern so the dropdown stays cheap even with many ChatInputs +// mounted at once. + +let cachedSkills: Skill[] | null = null; +let inflight: Promise | null = null; +const subscribers = new Set<(s: Skill[]) => void>(); + +async function loadSkills(): Promise { + if (inflight) return inflight; + inflight = api.skills + .list() + .then((r) => { + cachedSkills = r.skills; + for (const sub of subscribers) { + try { sub(cachedSkills); } catch { /* swallow */ } + } + return cachedSkills; + }) + .finally(() => { inflight = null; }); + return inflight; +} + +export function useSkills(): { skills: Skill[]; loaded: boolean } { + const [skills, setSkills] = useState(cachedSkills ?? []); + const [loaded, setLoaded] = useState(cachedSkills !== null); + + useEffect(() => { + subscribers.add(setSkills); + if (cachedSkills === null) { + void loadSkills().then(() => setLoaded(true)).catch(() => setLoaded(true)); + } + return () => { subscribers.delete(setSkills); }; + }, []); + + return { skills, loaded }; +}