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}
settingsSnapshot={settingsSnapshot}
onOpenModelSettings={onOpenModelSettings}
skills={skills}
/>
</div>
{view !== "chat" && (

View File

@ -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<string>;
/** 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<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 [];
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<CliAppMentionQuery | null>(() => {
@ -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(

View File

@ -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}

View File

@ -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(
<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", () => {
render(
<ThreadComposer