feat(webui): add dollar skill shortcuts

Add a WebUI-only $<skill> completion shortcut without changing slash command behavior.

Keep slash autocomplete command-only and allow dollar skill shortcuts anywhere in the composer.

Co-authored-by: Alan Chen <zc2610@nyu.edu>
This commit is contained in:
chengyongru 2026-07-01 15:52:34 +08:00 committed by Xubin Ren
parent a6d5e4f3b5
commit 2ec4044217
4 changed files with 97 additions and 4 deletions

View File

@ -1586,6 +1586,7 @@ function Shell({
onWorkspaceScopeChange={applyWorkspaceScope} onWorkspaceScopeChange={applyWorkspaceScope}
settingsSnapshot={settingsSnapshot} settingsSnapshot={settingsSnapshot}
onOpenModelSettings={onOpenModelSettings} onOpenModelSettings={onOpenModelSettings}
skills={skills}
/> />
</div> </div>
{view !== "chat" && ( {view !== "chat" && (

View File

@ -74,6 +74,7 @@ import type {
OutboundCliAppMention, OutboundCliAppMention,
OutboundMcpPresetMention, OutboundMcpPresetMention,
SlashCommand, SlashCommand,
SkillSummary,
WorkspaceScopePayload, WorkspaceScopePayload,
WorkspacesPayload, WorkspacesPayload,
} from "@/lib/types"; } from "@/lib/types";
@ -159,6 +160,7 @@ interface ThreadComposerProps {
slashCommands?: SlashCommand[]; slashCommands?: SlashCommand[];
cliApps?: CliAppInfo[]; cliApps?: CliAppInfo[];
mcpPresets?: McpPresetInfo[]; mcpPresets?: McpPresetInfo[];
skills?: SkillSummary[];
onStop?: () => void; onStop?: () => void;
onTranscribeAudio?: (dataUrl: string, options?: { durationMs?: number }) => Promise<string>; onTranscribeAudio?: (dataUrl: string, options?: { durationMs?: number }) => Promise<string>;
/** Unix seconds from server; turn elapsed timer above input while set. */ /** Unix seconds from server; turn elapsed timer above input while set. */
@ -772,6 +774,7 @@ export function ThreadComposer({
slashCommands = [], slashCommands = [],
cliApps = [], cliApps = [],
mcpPresets = [], mcpPresets = [],
skills = [],
onStop, onStop,
onTranscribeAudio, onTranscribeAudio,
runStartedAt = null, runStartedAt = null,
@ -909,6 +912,19 @@ export function ThreadComposer({
return commandToken.toLowerCase(); return commandToken.toLowerCase();
}, [disabled, slashMenuDismissed, value]); }, [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 visibleSlashCommands = useMemo(() => {
const baseCommands = slashCommands.filter((command) => command.command !== "/stop"); const baseCommands = slashCommands.filter((command) => command.command !== "/stop");
if (!(isStreaming && onStop)) return baseCommands; if (!(isStreaming && onStop)) return baseCommands;
@ -925,6 +941,31 @@ export function ThreadComposer({
}, [isStreaming, onStop, slashCommands]); }, [isStreaming, onStop, slashCommands]);
const filteredSlashCommands = useMemo<SlashPaletteCommand[]>(() => { const filteredSlashCommands = useMemo<SlashPaletteCommand[]>(() => {
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 []; if (slashQuery === null) return [];
const withDetails = visibleSlashCommands const withDetails = visibleSlashCommands
.filter((command) => { .filter((command) => {
@ -989,7 +1030,7 @@ export function ThreadComposer({
return withDetails return withDetails
.slice(0, 8); .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 showSlashMenu = filteredSlashCommands.length > 0;
const cliAppMention = useMemo<CliAppMentionQuery | null>(() => { const cliAppMention = useMemo<CliAppMentionQuery | null>(() => {
@ -1232,7 +1273,7 @@ export function ThreadComposer({
}, [onTranscribeAudio, voiceRecorder.beginShortcutHold, voiceRecorder.endShortcutHold]); }, [onTranscribeAudio, voiceRecorder.beginShortcutHold, voiceRecorder.endShortcutHold]);
const chooseSlashCommand = useCallback( const chooseSlashCommand = useCallback(
(command: SlashCommand) => { (command: SlashPaletteCommand) => {
if (command.command === "/stop" && isStreaming && onStop) { if (command.command === "/stop" && isStreaming && onStop) {
onStop(); onStop();
setValue(""); setValue("");
@ -1250,13 +1291,28 @@ export function ThreadComposer({
setRecentSlashCommands(nextRecents); setRecentSlashCommands(nextRecents);
storeSlashRecents(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); setSlashMenuDismissed(true);
setCliAppMenuDismissed(false); setCliAppMenuDismissed(false);
setInlineError(null); setInlineError(null);
resizeTextarea(); resizeTextarea();
}, },
[isStreaming, onStop, recentSlashCommands, resizeTextarea], [isStreaming, onStop, recentSlashCommands, resizeTextarea, skillQuery, value],
); );
const chooseMentionCandidate = useCallback( const chooseMentionCandidate = useCallback(

View File

@ -32,6 +32,7 @@ import type {
ChatSummary, ChatSummary,
SettingsPayload, SettingsPayload,
SlashCommand, SlashCommand,
SkillSummary,
UIMessage, UIMessage,
WorkspaceScopePayload, WorkspaceScopePayload,
WorkspacesPayload, WorkspacesPayload,
@ -142,6 +143,7 @@ interface ThreadShellProps {
onWorkspaceScopeChange?: (scope: WorkspaceScopePayload) => void; onWorkspaceScopeChange?: (scope: WorkspaceScopePayload) => void;
settingsSnapshot?: SettingsPayload | null; settingsSnapshot?: SettingsPayload | null;
onOpenModelSettings?: () => void; onOpenModelSettings?: () => void;
skills?: SkillSummary[];
} }
function toModelBadgeLabel(modelName: string | null): string | null { function toModelBadgeLabel(modelName: string | null): string | null {
@ -292,6 +294,7 @@ export function ThreadShell({
onWorkspaceScopeChange, onWorkspaceScopeChange,
settingsSnapshot = null, settingsSnapshot = null,
onOpenModelSettings, onOpenModelSettings,
skills = [],
}: ThreadShellProps) { }: ThreadShellProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const chatId = session?.chatId ?? null; const chatId = session?.chatId ?? null;
@ -726,6 +729,7 @@ export function ThreadShell({
slashCommands={slashCommands} slashCommands={slashCommands}
cliApps={cliApps} cliApps={cliApps}
mcpPresets={mcpPresets} mcpPresets={mcpPresets}
skills={skills}
onStop={stop} onStop={stop}
onTranscribeAudio={transcribeAudio} onTranscribeAudio={transcribeAudio}
runStartedAt={runStartedAt} runStartedAt={runStartedAt}
@ -758,6 +762,7 @@ export function ThreadShell({
slashCommands={slashCommands} slashCommands={slashCommands}
cliApps={cliApps} cliApps={cliApps}
mcpPresets={mcpPresets} mcpPresets={mcpPresets}
skills={skills}
runStartedAt={runStartedAt} runStartedAt={runStartedAt}
onTranscribeAudio={transcribeAudio} onTranscribeAudio={transcribeAudio}
goalState={goalState} goalState={goalState}

View File

@ -120,6 +120,7 @@ const MCP_PRESETS: McpPresetInfo[] = [
connection_summary: "", connection_summary: "",
}, },
]; ];
const ORIGINAL_INNER_HEIGHT = window.innerHeight; const ORIGINAL_INNER_HEIGHT = window.innerHeight;
const ORIGINAL_MEDIA_DEVICES = navigator.mediaDevices; const ORIGINAL_MEDIA_DEVICES = navigator.mediaDevices;
@ -1114,6 +1115,36 @@ describe("ThreadComposer", () => {
}); });
}); });
it("opens skills only from a $ reference anywhere", () => {
render(
<ThreadComposer
onSend={vi.fn()}
placeholder="Type your message..."
skills={[{
name: "github",
description: "Work with pull requests and issues",
source: "builtin",
available: true,
}]}
slashCommands={COMMANDS}
/>,
);
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", () => { it("shows right-side source badges so users can distinguish CLI apps from MCP servers", () => {
render( render(
<ThreadComposer <ThreadComposer