Skip to content

Commit 18d2dae

Browse files
committed
Refresh slash commands from runtime command list
- Load live slash commands into the chat UI and command palette - Keep builtin fallback behavior when runtime commands are unavailable
1 parent 4c8337f commit 18d2dae

5 files changed

Lines changed: 350 additions & 97 deletions

File tree

ui/src/ui/app-chat.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { scheduleChatScroll, resetChatScroll } from "./app-scroll.ts";
33
import { resetToolStream } from "./app-tool-stream.ts";
44
import type { ChatSideResult } from "./chat/side-result.ts";
55
import { executeSlashCommand } from "./chat/slash-command-executor.ts";
6-
import { parseSlashCommand } from "./chat/slash-commands.ts";
6+
import { parseSlashCommand, refreshSlashCommands } from "./chat/slash-commands.ts";
77
import {
88
abortChatRun,
99
loadChatHistory,
@@ -461,6 +461,7 @@ export async function refreshChat(host: ChatHost, opts?: { scheduleScroll?: bool
461461
}),
462462
refreshChatAvatar(host),
463463
refreshChatModels(host),
464+
refreshChatCommands(host),
464465
]);
465466
if (opts?.scheduleScroll !== false) {
466467
scheduleChatScroll(host as unknown as Parameters<typeof scheduleChatScroll>[0]);
@@ -481,6 +482,13 @@ async function refreshChatModels(host: ChatHost) {
481482
}
482483
}
483484

485+
async function refreshChatCommands(host: ChatHost) {
486+
await refreshSlashCommands({
487+
client: host.client,
488+
agentId: resolveAgentIdForSession(host),
489+
});
490+
}
491+
484492
export const flushChatQueueForEvent = flushChatQueue;
485493
const chatAvatarRequestVersions = new WeakMap<object, number>();
486494

ui/src/ui/chat/slash-commands.node.test.ts

Lines changed: 69 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,14 @@
1-
import { describe, expect, it } from "vitest";
2-
import { parseSlashCommand, SLASH_COMMANDS } from "./slash-commands.ts";
1+
import { afterEach, describe, expect, it } from "vitest";
2+
import {
3+
parseSlashCommand,
4+
refreshSlashCommands,
5+
resetSlashCommandsForTest,
6+
SLASH_COMMANDS,
7+
} from "./slash-commands.ts";
8+
9+
afterEach(() => {
10+
resetSlashCommandsForTest();
11+
});
312

413
describe("parseSlashCommand", () => {
514
it("parses commands with an optional colon separator", () => {
@@ -100,4 +109,62 @@ describe("parseSlashCommand", () => {
100109
args: "",
101110
});
102111
});
112+
113+
it("refreshes runtime commands from commands.list so docks, plugins, and direct skills appear", async () => {
114+
const request = async (method: string) => {
115+
expect(method).toBe("commands.list");
116+
return {
117+
commands: [
118+
{
119+
name: "dock-discord",
120+
textAliases: ["/dock-discord", "/dock_discord"],
121+
description: "Switch to discord for replies.",
122+
source: "native",
123+
scope: "both",
124+
acceptsArgs: false,
125+
category: "docks",
126+
},
127+
{
128+
name: "dreaming",
129+
textAliases: ["/dreaming"],
130+
description: "Enable or disable memory dreaming.",
131+
source: "plugin",
132+
scope: "both",
133+
acceptsArgs: true,
134+
},
135+
{
136+
name: "prose",
137+
textAliases: ["/prose"],
138+
description: "Draft polished prose.",
139+
source: "skill",
140+
scope: "both",
141+
acceptsArgs: true,
142+
},
143+
],
144+
};
145+
};
146+
147+
await refreshSlashCommands({
148+
client: { request } as never,
149+
agentId: "main",
150+
});
151+
152+
expect(SLASH_COMMANDS.find((entry) => entry.name === "dock-discord")).toMatchObject({
153+
aliases: ["dock_discord"],
154+
category: "tools",
155+
executeLocal: false,
156+
});
157+
expect(SLASH_COMMANDS.find((entry) => entry.name === "dreaming")).toMatchObject({
158+
key: "dreaming",
159+
executeLocal: false,
160+
});
161+
expect(SLASH_COMMANDS.find((entry) => entry.name === "prose")).toMatchObject({
162+
key: "prose",
163+
executeLocal: false,
164+
});
165+
expect(parseSlashCommand("/dock_discord")).toMatchObject({
166+
command: { name: "dock-discord" },
167+
args: "",
168+
});
169+
});
103170
});

ui/src/ui/chat/slash-commands.ts

Lines changed: 138 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,6 @@
11
import { buildBuiltinChatCommands } from "../../../../src/auto-reply/commands-registry.shared.js";
2-
import type {
3-
ChatCommandDefinition,
4-
CommandArgChoice,
5-
} from "../../../../src/auto-reply/commands-registry.types.js";
2+
import type { CommandEntry, CommandsListResult } from "../../../../src/gateway/protocol/index.js";
3+
import type { GatewayBrowserClient } from "../gateway.ts";
64
import type { IconName } from "../icons.ts";
75
import { normalizeLowercaseStringOrEmpty } from "../string-coerce.ts";
86

@@ -24,6 +22,21 @@ export type SlashCommandDef = {
2422
shortcut?: string;
2523
};
2624

25+
type LocalArgChoice = string | { value: string; label: string };
26+
27+
type CommandLike = {
28+
key: string;
29+
name: string;
30+
aliases?: string[];
31+
description: string;
32+
args?: Array<{
33+
name: string;
34+
required?: boolean;
35+
choices?: LocalArgChoice[];
36+
}>;
37+
category?: string;
38+
};
39+
2740
const COMMAND_ICON_OVERRIDES: Partial<Record<string, IconName>> = {
2841
help: "book",
2942
status: "barChart",
@@ -130,26 +143,22 @@ const COMMAND_ARGS_OVERRIDES: Partial<Record<string, string>> = {
130143
steer: "[id] <message>",
131144
};
132145

133-
function normalizeUiKey(command: ChatCommandDefinition): string {
146+
function normalizeUiKey(command: CommandLike): string {
134147
return command.key.replace(/[:.-]/g, "_");
135148
}
136149

137-
function getSlashAliases(command: ChatCommandDefinition): string[] {
138-
return command.textAliases
150+
function getSlashAliases(command: CommandLike): string[] {
151+
return (command.aliases ?? [])
139152
.map((alias) => alias.trim())
140-
.filter((alias) => alias.startsWith("/"))
141-
.map((alias) => alias.slice(1));
153+
.filter(Boolean)
154+
.map((alias) => (alias.startsWith("/") ? alias.slice(1) : alias));
142155
}
143156

144-
function getPrimarySlashName(command: ChatCommandDefinition): string | null {
145-
const aliases = getSlashAliases(command);
146-
if (aliases.length === 0) {
147-
return null;
148-
}
149-
return aliases[0] ?? null;
157+
function getPrimarySlashName(command: CommandLike): string | null {
158+
return command.name.trim() || null;
150159
}
151160

152-
function formatArgs(command: ChatCommandDefinition): string | undefined {
161+
function formatArgs(command: CommandLike): string | undefined {
153162
if (!command.args?.length) {
154163
return undefined;
155164
}
@@ -161,28 +170,41 @@ function formatArgs(command: ChatCommandDefinition): string | undefined {
161170
.join(" ");
162171
}
163172

164-
function choiceToValue(choice: CommandArgChoice): string {
173+
function choiceToValue(choice: LocalArgChoice): string {
165174
return typeof choice === "string" ? choice : choice.value;
166175
}
167176

168-
function getArgOptions(command: ChatCommandDefinition): string[] | undefined {
177+
function getArgOptions(command: CommandLike): string[] | undefined {
169178
const firstArg = command.args?.[0];
170-
if (!firstArg || typeof firstArg.choices === "function") {
179+
if (!firstArg) {
171180
return undefined;
172181
}
173182
const options = firstArg.choices?.map(choiceToValue).filter(Boolean);
174183
return options?.length ? options : undefined;
175184
}
176185

177-
function mapCategory(command: ChatCommandDefinition): SlashCommandCategory {
178-
return CATEGORY_OVERRIDES[normalizeUiKey(command)] ?? "tools";
186+
function mapCategory(command: CommandLike): SlashCommandCategory {
187+
const override = CATEGORY_OVERRIDES[normalizeUiKey(command)];
188+
if (override) {
189+
return override;
190+
}
191+
switch (command.category) {
192+
case "session":
193+
return "session";
194+
case "options":
195+
return "model";
196+
case "management":
197+
return "tools";
198+
default:
199+
return "tools";
200+
}
179201
}
180202

181-
function mapIcon(command: ChatCommandDefinition): IconName | undefined {
203+
function mapIcon(command: CommandLike): IconName | undefined {
182204
return COMMAND_ICON_OVERRIDES[normalizeUiKey(command)] ?? "terminal";
183205
}
184206

185-
function toSlashCommand(command: ChatCommandDefinition): SlashCommandDef | null {
207+
function toSlashCommand(command: CommandLike): SlashCommandDef | null {
186208
const name = getPrimarySlashName(command);
187209
if (!name) {
188210
return null;
@@ -200,12 +222,100 @@ function toSlashCommand(command: ChatCommandDefinition): SlashCommandDef | null
200222
};
201223
}
202224

203-
export const SLASH_COMMANDS: SlashCommandDef[] = [
204-
...buildBuiltinChatCommands()
225+
function normalizeCommandEntry(entry: CommandEntry): CommandLike {
226+
const aliases = Array.isArray(entry.textAliases)
227+
? entry.textAliases.filter((alias) => typeof alias === "string")
228+
: [];
229+
const primaryAlias = aliases.find((alias) => alias.startsWith("/"));
230+
const primaryName = primaryAlias ? primaryAlias.slice(1) : entry.name;
231+
return {
232+
key: primaryName,
233+
name: primaryName,
234+
aliases,
235+
description: entry.description,
236+
args: entry.args?.map((arg) => ({
237+
name: arg.name,
238+
required: arg.required,
239+
choices: arg.dynamic ? undefined : arg.choices,
240+
})),
241+
category: entry.category,
242+
};
243+
}
244+
245+
function replaceSlashCommands(next: SlashCommandDef[]) {
246+
SLASH_COMMANDS.splice(0, SLASH_COMMANDS.length, ...next);
247+
}
248+
249+
function buildSlashCommandsFromEntries(entries: CommandEntry[]): SlashCommandDef[] {
250+
const mapped = entries
251+
.map(normalizeCommandEntry)
205252
.map(toSlashCommand)
206-
.filter((command): command is SlashCommandDef => command !== null),
207-
...UI_ONLY_COMMANDS,
208-
];
253+
.filter((command): command is SlashCommandDef => command !== null);
254+
const deduped = new Map<string, SlashCommandDef>();
255+
for (const command of [...mapped, ...UI_ONLY_COMMANDS]) {
256+
const key = normalizeLowercaseStringOrEmpty(command.name);
257+
if (!key || deduped.has(key)) {
258+
continue;
259+
}
260+
deduped.set(key, command);
261+
}
262+
return Array.from(deduped.values());
263+
}
264+
265+
function buildFallbackSlashCommands(): SlashCommandDef[] {
266+
const builtins = buildBuiltinChatCommands()
267+
.map((command) => ({
268+
key: command.key,
269+
name: command.textAliases[0]?.replace(/^\//, "") ?? command.key,
270+
aliases: command.textAliases,
271+
description: command.description,
272+
args: command.args?.map((arg) => ({
273+
name: arg.name,
274+
required: arg.required,
275+
choices: Array.isArray(arg.choices) ? arg.choices : undefined,
276+
})),
277+
category: command.category,
278+
}))
279+
.map(toSlashCommand)
280+
.filter((command): command is SlashCommandDef => command !== null);
281+
return buildSlashCommandsFromEntries([]).concat(
282+
builtins.filter(
283+
(command) =>
284+
!UI_ONLY_COMMANDS.some(
285+
(uiCommand) =>
286+
normalizeLowercaseStringOrEmpty(uiCommand.name) ===
287+
normalizeLowercaseStringOrEmpty(command.name),
288+
),
289+
),
290+
);
291+
}
292+
293+
export const SLASH_COMMANDS: SlashCommandDef[] = buildFallbackSlashCommands();
294+
295+
export async function refreshSlashCommands(params: {
296+
client: GatewayBrowserClient | null;
297+
agentId?: string | null;
298+
}): Promise<void> {
299+
const agentId = params.agentId?.trim();
300+
if (!params.client || !agentId) {
301+
replaceSlashCommands(buildFallbackSlashCommands());
302+
return;
303+
}
304+
try {
305+
const result = await params.client.request<CommandsListResult>("commands.list", {
306+
agentId,
307+
includeArgs: true,
308+
scope: "text",
309+
});
310+
replaceSlashCommands(buildSlashCommandsFromEntries(result?.commands ?? []));
311+
} catch {
312+
replaceSlashCommands(buildFallbackSlashCommands());
313+
}
314+
}
315+
316+
export function resetSlashCommandsForTest(): void {
317+
replaceSlashCommands(buildFallbackSlashCommands());
318+
}
209319

210320
const CATEGORY_ORDER: SlashCommandCategory[] = ["session", "model", "tools", "agents"];
211321

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import { afterEach, describe, expect, it } from "vitest";
2+
import { refreshSlashCommands, resetSlashCommandsForTest } from "../chat/slash-commands.ts";
3+
import { getPaletteItems } from "./command-palette.ts";
4+
5+
afterEach(() => {
6+
resetSlashCommandsForTest();
7+
});
8+
9+
describe("command palette", () => {
10+
it("builds slash items from the live runtime command list", async () => {
11+
const request = async (method: string) => {
12+
expect(method).toBe("commands.list");
13+
return {
14+
commands: [
15+
{
16+
name: "pair",
17+
textAliases: ["/pair"],
18+
description: "Generate setup codes and approve device pairing requests.",
19+
source: "plugin",
20+
scope: "both",
21+
acceptsArgs: true,
22+
},
23+
{
24+
name: "prose",
25+
textAliases: ["/prose"],
26+
description: "Draft polished prose.",
27+
source: "skill",
28+
scope: "both",
29+
acceptsArgs: true,
30+
},
31+
],
32+
};
33+
};
34+
35+
await refreshSlashCommands({
36+
client: { request } as never,
37+
agentId: "main",
38+
});
39+
40+
const items = getPaletteItems();
41+
expect(items).toContainEqual(
42+
expect.objectContaining({
43+
id: "slash:pair",
44+
label: "/pair",
45+
}),
46+
);
47+
expect(items).toContainEqual(
48+
expect.objectContaining({
49+
id: "slash:prose",
50+
label: "/prose",
51+
}),
52+
);
53+
});
54+
});

0 commit comments

Comments
 (0)