diff --git a/webui/src/App.tsx b/webui/src/App.tsx index 1eb19792..13beb114 100644 --- a/webui/src/App.tsx +++ b/webui/src/App.tsx @@ -1586,6 +1586,7 @@ function Shell({ onWorkspaceScopeChange={applyWorkspaceScope} settingsSnapshot={settingsSnapshot} onOpenModelSettings={onOpenModelSettings} + skills={skills} /> {view !== "chat" && ( diff --git a/webui/src/components/thread/ThreadComposer.tsx b/webui/src/components/thread/ThreadComposer.tsx index 0ff1301d..07f82780 100644 --- a/webui/src/components/thread/ThreadComposer.tsx +++ b/webui/src/components/thread/ThreadComposer.tsx @@ -74,6 +74,7 @@ import type { OutboundCliAppMention, OutboundMcpPresetMention, SlashCommand, + SkillSummary, WorkspaceScopePayload, WorkspacesPayload, } from "@/lib/types"; @@ -159,6 +160,7 @@ interface ThreadComposerProps { slashCommands?: SlashCommand[]; cliApps?: CliAppInfo[]; mcpPresets?: McpPresetInfo[]; + skills?: SkillSummary[]; onStop?: () => void; onTranscribeAudio?: (dataUrl: string, options?: { durationMs?: number }) => Promise; /** Unix seconds from server; turn elapsed timer above input while set. */ @@ -772,6 +774,7 @@ export function ThreadComposer({ slashCommands = [], cliApps = [], mcpPresets = [], + skills = [], onStop, onTranscribeAudio, runStartedAt = null, @@ -909,6 +912,19 @@ export function ThreadComposer({ return commandToken.toLowerCase(); }, [disabled, slashMenuDismissed, value]); + const skillQuery = useMemo(() => { + if (disabled || slashMenuDismissed) return null; + const caret = Math.min(Math.max(cursorPosition, 0), value.length); + const beforeCaret = value.slice(0, caret); + const match = /\$([A-Za-z0-9_-]*)$/i.exec(beforeCaret); + if (!match) return null; + return { + end: caret, + start: match.index, + text: match[1].toLowerCase(), + }; + }, [cursorPosition, disabled, slashMenuDismissed, value]); + const visibleSlashCommands = useMemo(() => { const baseCommands = slashCommands.filter((command) => command.command !== "/stop"); if (!(isStreaming && onStop)) return baseCommands; @@ -925,6 +941,31 @@ export function ThreadComposer({ }, [isStreaming, onStop, slashCommands]); const filteredSlashCommands = useMemo(() => { + if (skillQuery !== null) { + const query = skillQuery.text; + return skills + .filter((skill) => skill.available) + .filter((skill) => { + const haystack = [ + skill.name, + skill.description, + ].join(" ").toLowerCase(); + return haystack.includes(query); + }) + .map((skill) => { + const command = `$${skill.name}`; + const description = skill.description || skill.name; + return { + command, + title: skill.name, + description, + detail: description, + icon: "brain", + recent: recentSlashCommands.includes(command), + }; + }) + .slice(0, 8); + } if (slashQuery === null) return []; const withDetails = visibleSlashCommands .filter((command) => { @@ -989,7 +1030,7 @@ export function ThreadComposer({ return withDetails .slice(0, 8); - }, [goalState?.active, isStreaming, modelLabel, recentSlashCommands, slashQuery, t, visibleSlashCommands]); + }, [goalState?.active, isStreaming, modelLabel, recentSlashCommands, skills, skillQuery, slashQuery, t, visibleSlashCommands]); const showSlashMenu = filteredSlashCommands.length > 0; const cliAppMention = useMemo(() => { @@ -1232,7 +1273,7 @@ export function ThreadComposer({ }, [onTranscribeAudio, voiceRecorder.beginShortcutHold, voiceRecorder.endShortcutHold]); const chooseSlashCommand = useCallback( - (command: SlashCommand) => { + (command: SlashPaletteCommand) => { if (command.command === "/stop" && isStreaming && onStop) { onStop(); setValue(""); @@ -1250,13 +1291,28 @@ export function ThreadComposer({ setRecentSlashCommands(nextRecents); storeSlashRecents(nextRecents); - setValue(command.argHint ? `${command.command} ` : command.command); + if (skillQuery !== null) { + const suffix = value.slice(skillQuery.end); + const inserted = `${command.command}${suffix.startsWith(" ") ? "" : " "}`; + const next = `${value.slice(0, skillQuery.start)}${inserted}${suffix}`; + const nextCursor = skillQuery.start + inserted.length; + setValue(next); + setCursorPosition(nextCursor); + requestAnimationFrame(() => { + const el = textareaRef.current; + if (!el) return; + el.focus(); + el.setSelectionRange(nextCursor, nextCursor); + }); + } else { + setValue(command.argHint ? `${command.command} ` : command.command); + } setSlashMenuDismissed(true); setCliAppMenuDismissed(false); setInlineError(null); resizeTextarea(); }, - [isStreaming, onStop, recentSlashCommands, resizeTextarea], + [isStreaming, onStop, recentSlashCommands, resizeTextarea, skillQuery, value], ); const chooseMentionCandidate = useCallback( diff --git a/webui/src/components/thread/ThreadShell.tsx b/webui/src/components/thread/ThreadShell.tsx index 4c1292a4..6f619fd7 100644 --- a/webui/src/components/thread/ThreadShell.tsx +++ b/webui/src/components/thread/ThreadShell.tsx @@ -32,6 +32,7 @@ import type { ChatSummary, SettingsPayload, SlashCommand, + SkillSummary, UIMessage, WorkspaceScopePayload, WorkspacesPayload, @@ -142,6 +143,7 @@ interface ThreadShellProps { onWorkspaceScopeChange?: (scope: WorkspaceScopePayload) => void; settingsSnapshot?: SettingsPayload | null; onOpenModelSettings?: () => void; + skills?: SkillSummary[]; } function toModelBadgeLabel(modelName: string | null): string | null { @@ -292,6 +294,7 @@ export function ThreadShell({ onWorkspaceScopeChange, settingsSnapshot = null, onOpenModelSettings, + skills = [], }: ThreadShellProps) { const { t } = useTranslation(); const chatId = session?.chatId ?? null; @@ -726,6 +729,7 @@ export function ThreadShell({ slashCommands={slashCommands} cliApps={cliApps} mcpPresets={mcpPresets} + skills={skills} onStop={stop} onTranscribeAudio={transcribeAudio} runStartedAt={runStartedAt} @@ -758,6 +762,7 @@ export function ThreadShell({ slashCommands={slashCommands} cliApps={cliApps} mcpPresets={mcpPresets} + skills={skills} runStartedAt={runStartedAt} onTranscribeAudio={transcribeAudio} goalState={goalState} diff --git a/webui/src/tests/thread-composer.test.tsx b/webui/src/tests/thread-composer.test.tsx index 761b0da7..c5e0ba0a 100644 --- a/webui/src/tests/thread-composer.test.tsx +++ b/webui/src/tests/thread-composer.test.tsx @@ -120,6 +120,7 @@ const MCP_PRESETS: McpPresetInfo[] = [ connection_summary: "", }, ]; + const ORIGINAL_INNER_HEIGHT = window.innerHeight; const ORIGINAL_MEDIA_DEVICES = navigator.mediaDevices; @@ -1114,6 +1115,36 @@ describe("ThreadComposer", () => { }); }); + it("opens skills only from a $ reference anywhere", () => { + render( + , + ); + + const input = screen.getByLabelText("Message input"); + fireEvent.change(input, { target: { value: "/git", selectionStart: 4 } }); + expect(screen.queryByRole("listbox", { name: "Slash commands" })).not.toBeInTheDocument(); + + fireEvent.change(input, { target: { value: "please use $git", selectionStart: 15 } }); + + const palette = screen.getByRole("listbox", { name: "Slash commands" }); + expect(within(palette).getByRole("option", { name: /github/i })).toHaveTextContent("$github"); + expect(within(palette).queryByText("/model")).not.toBeInTheDocument(); + + fireEvent.keyDown(input, { key: "Tab" }); + + expect(input).toHaveValue("please use $github "); + }); + it("shows right-side source badges so users can distinguish CLI apps from MCP servers", () => { render(