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:
committed by
Xubin Ren
co-authored by
Alan Chen
parent
a6d5e4f3b5
commit
2ec4044217
@@ -1586,6 +1586,7 @@ function Shell({
|
||||
onWorkspaceScopeChange={applyWorkspaceScope}
|
||||
settingsSnapshot={settingsSnapshot}
|
||||
onOpenModelSettings={onOpenModelSettings}
|
||||
skills={skills}
|
||||
/>
|
||||
</div>
|
||||
{view !== "chat" && (
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user