Control UI: refresh slash commands from runtime command list (#65620)

* 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

* Apply suggestion from @greptile-apps[bot]

Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>

* Control UI: harden runtime slash command discovery

* Control UI: bound runtime slash command payloads

* Control UI: use default agent for plain session keys

* Control UI: guard malformed slash command payloads

---------

Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
This commit is contained in:
Val Alexander
2026-04-12 20:38:37 -05:00
committed by GitHub
parent c4764095f8
commit d0c83777fb
11 changed files with 964 additions and 132 deletions

View File

@@ -3,7 +3,7 @@ import { scheduleChatScroll, resetChatScroll } from "./app-scroll.ts";
import { resetToolStream } from "./app-tool-stream.ts";
import type { ChatSideResult } from "./chat/side-result.ts";
import { executeSlashCommand } from "./chat/slash-command-executor.ts";
import { parseSlashCommand } from "./chat/slash-commands.ts";
import { parseSlashCommand, refreshSlashCommands } from "./chat/slash-commands.ts";
import {
abortChatRun,
loadChatHistory,
@@ -461,6 +461,7 @@ export async function refreshChat(host: ChatHost, opts?: { scheduleScroll?: bool
}),
refreshChatAvatar(host),
refreshChatModels(host),
refreshChatCommands(host),
]);
if (opts?.scheduleScroll !== false) {
scheduleChatScroll(host as unknown as Parameters<typeof scheduleChatScroll>[0]);
@@ -481,6 +482,13 @@ async function refreshChatModels(host: ChatHost) {
}
}
async function refreshChatCommands(host: ChatHost) {
await refreshSlashCommands({
client: host.client,
agentId: resolveAgentIdForSession(host),
});
}
export const flushChatQueueForEvent = flushChatQueue;
const chatAvatarRequestVersions = new WeakMap<object, number>();

View File

@@ -1,17 +1,27 @@
import { describe, expect, it, vi } from "vitest";
const { refreshChatMock, refreshChatAvatarMock, loadChatHistoryMock, loadSessionsMock } =
vi.hoisted(() => ({
refreshChatMock: vi.fn(),
refreshChatAvatarMock: vi.fn(),
loadChatHistoryMock: vi.fn(),
loadSessionsMock: vi.fn(),
}));
const {
refreshChatMock,
refreshChatAvatarMock,
refreshSlashCommandsMock,
loadChatHistoryMock,
loadSessionsMock,
} = vi.hoisted(() => ({
refreshChatMock: vi.fn(),
refreshChatAvatarMock: vi.fn(),
refreshSlashCommandsMock: vi.fn(),
loadChatHistoryMock: vi.fn(),
loadSessionsMock: vi.fn(),
}));
vi.mock("./app-chat.ts", () => ({
refreshChat: refreshChatMock,
refreshChatAvatar: refreshChatAvatarMock,
}));
vi.mock("./chat/slash-commands.ts", () => ({
refreshSlashCommands: (...args: unknown[]) => refreshSlashCommandsMock(...args),
}));
vi.mock("./controllers/chat.ts", () => ({
loadChatHistory: loadChatHistoryMock,
}));
@@ -393,6 +403,7 @@ describe("switchChatSession", () => {
} as unknown as AppViewState;
refreshChatAvatarMock.mockResolvedValue(undefined);
refreshSlashCommandsMock.mockResolvedValue(undefined);
loadChatHistoryMock.mockResolvedValue(undefined);
loadSessionsMock.mockResolvedValue(undefined);
@@ -402,6 +413,10 @@ describe("switchChatSession", () => {
expect(state.chatSideResult).toBeNull();
expect(state.chatSideResultTerminalRuns.size).toBe(0);
expect(refreshChatAvatarMock).toHaveBeenCalledWith(state);
expect(refreshSlashCommandsMock).toHaveBeenCalledWith({
client: undefined,
agentId: "main",
});
expect(loadChatHistoryMock).toHaveBeenCalledWith(state);
expect(loadSessionsMock).toHaveBeenCalledWith(state, {
activeMinutes: 0,
@@ -410,4 +425,64 @@ describe("switchChatSession", () => {
includeUnknown: true,
});
});
it("does not force agentId=main for plain session keys", async () => {
const settings: AppViewState["settings"] = {
gatewayUrl: "",
token: "",
locale: "en",
sessionKey: "main",
lastActiveSessionKey: "main",
theme: "claw",
themeMode: "dark",
splitRatio: 0.6,
navWidth: 280,
navCollapsed: false,
navGroupsCollapsed: {},
borderRadius: 50,
chatFocusMode: false,
chatShowThinking: false,
chatShowToolCalls: true,
};
const state = {
sessionKey: "main",
chatMessage: "",
chatAttachments: [],
chatMessages: [],
chatToolMessages: [],
chatStreamSegments: [],
chatThinkingLevel: null,
chatStream: null,
chatSideResult: null,
lastError: null,
compactionStatus: null,
fallbackStatus: null,
chatAvatarUrl: null,
chatQueue: [],
chatRunId: null,
chatSideResultTerminalRuns: new Set<string>(),
chatStreamStartedAt: null,
settings,
applySettings(next: typeof settings) {
state.settings = next;
},
loadAssistantIdentity: vi.fn(),
resetToolStream: vi.fn(),
resetChatScroll: vi.fn(),
client: { request: vi.fn() },
} as unknown as AppViewState;
refreshChatAvatarMock.mockResolvedValue(undefined);
refreshSlashCommandsMock.mockResolvedValue(undefined);
loadChatHistoryMock.mockResolvedValue(undefined);
loadSessionsMock.mockResolvedValue(undefined);
switchChatSession(state, "main");
await Promise.resolve();
expect(refreshSlashCommandsMock).toHaveBeenCalledWith({
client: state.client,
agentId: undefined,
});
});
});

View File

@@ -9,6 +9,7 @@ import {
resolveChatModelOverrideValue,
resolveChatModelSelectState,
} from "./chat-model-select-state.ts";
import { refreshSlashCommands } from "./chat/slash-commands.ts";
import { refreshVisibleToolsEffectiveForCurrentSession } from "./controllers/agents.ts";
import { ChatState, loadChatHistory } from "./controllers/chat.ts";
import { loadSessions } from "./controllers/sessions.ts";
@@ -542,6 +543,10 @@ export function switchChatSession(state: AppViewState, nextSessionKey: string) {
resetChatStateForSessionSwitch(state, nextSessionKey);
void state.loadAssistantIdentity();
void refreshChatAvatar(state);
void refreshSlashCommands({
client: state.client,
agentId: parseAgentSessionKey(nextSessionKey)?.agentId,
});
syncUrlWithSessionKey(
state as unknown as Parameters<typeof syncUrlWithSessionKey>[0],
nextSessionKey,

View File

@@ -1,5 +1,14 @@
import { describe, expect, it } from "vitest";
import { parseSlashCommand, SLASH_COMMANDS } from "./slash-commands.ts";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
parseSlashCommand,
refreshSlashCommands,
resetSlashCommandsForTest,
SLASH_COMMANDS,
} from "./slash-commands.ts";
afterEach(() => {
resetSlashCommandsForTest();
});
describe("parseSlashCommand", () => {
it("parses commands with an optional colon separator", () => {
@@ -100,4 +109,303 @@ describe("parseSlashCommand", () => {
args: "",
});
});
it("refreshes runtime commands from commands.list so docks, plugins, and direct skills appear", async () => {
const request = async (method: string) => {
expect(method).toBe("commands.list");
return {
commands: [
{
name: "dock-discord",
textAliases: ["/dock-discord", "/dock_discord"],
description: "Switch to discord for replies.",
source: "native",
scope: "both",
acceptsArgs: false,
category: "docks",
},
{
name: "dreaming",
textAliases: ["/dreaming"],
description: "Enable or disable memory dreaming.",
source: "plugin",
scope: "both",
acceptsArgs: true,
},
{
name: "prose",
textAliases: ["/prose"],
description: "Draft polished prose.",
source: "skill",
scope: "both",
acceptsArgs: true,
},
],
};
};
await refreshSlashCommands({
client: { request } as never,
agentId: "main",
});
expect(SLASH_COMMANDS.find((entry) => entry.name === "dock-discord")).toMatchObject({
aliases: ["dock_discord"],
category: "tools",
executeLocal: false,
});
expect(SLASH_COMMANDS.find((entry) => entry.name === "dreaming")).toMatchObject({
key: "dreaming",
executeLocal: false,
});
expect(SLASH_COMMANDS.find((entry) => entry.name === "prose")).toMatchObject({
key: "prose",
executeLocal: false,
});
expect(parseSlashCommand("/dock_discord")).toMatchObject({
command: { name: "dock-discord" },
args: "",
});
});
it("does not let remote commands collide with reserved local commands", async () => {
const request = async () => ({
commands: [
{
name: "redirect",
textAliases: ["/redirect"],
description: "Remote redirect impostor.",
source: "plugin",
scope: "both",
acceptsArgs: true,
},
{
name: "kill",
textAliases: ["/kill"],
description: "Remote kill impostor.",
source: "plugin",
scope: "both",
acceptsArgs: true,
},
],
});
await refreshSlashCommands({
client: { request } as never,
agentId: "main",
});
expect(SLASH_COMMANDS.find((entry) => entry.name === "redirect")).toMatchObject({
key: "redirect",
executeLocal: true,
description: "Abort and restart with a new message",
});
expect(SLASH_COMMANDS.find((entry) => entry.name === "kill")).toMatchObject({
key: "kill",
executeLocal: true,
description: "Kill a running subagent (or all).",
});
});
it("drops remote commands with unsafe identifiers before they reach the palette/parser", async () => {
const request = async () => ({
commands: [
{
name: "prose now",
textAliases: ["/prose now", "/safe-name"],
description: "Unsafe injected command.",
source: "skill",
scope: "both",
acceptsArgs: true,
},
{
name: "bad:alias",
textAliases: ["/bad:alias"],
description: "Unsafe alias command.",
source: "plugin",
scope: "both",
acceptsArgs: false,
},
],
});
await refreshSlashCommands({
client: { request } as never,
agentId: "main",
});
expect(SLASH_COMMANDS.find((entry) => entry.name === "safe-name")).toMatchObject({
name: "safe-name",
});
expect(SLASH_COMMANDS.find((entry) => entry.name === "prose now")).toBeUndefined();
expect(SLASH_COMMANDS.find((entry) => entry.name === "bad:alias")).toBeUndefined();
expect(parseSlashCommand("/safe-name")).toMatchObject({
command: { name: "safe-name" },
});
});
it("caps remote command payload size and long metadata before it reaches UI state", async () => {
const longName = "x".repeat(260);
const longDescription = "d".repeat(2_500);
const request = async () => ({
commands: Array.from({ length: 520 }, (_, index) => ({
name: `plugin-${index}`,
textAliases: Array.from(
{ length: 25 },
(_, aliasIndex) => `/plugin-${index}-${aliasIndex}`,
),
description: longDescription,
source: "plugin" as const,
scope: "both" as const,
acceptsArgs: true,
args: Array.from({ length: 25 }, (_, argIndex) => ({
name: `${longName}-${argIndex}`,
description: longDescription,
type: "string" as const,
choices: Array.from({ length: 55 }, (_, choiceIndex) => ({
value: `${longName}-${choiceIndex}`,
label: `${longName}-${choiceIndex}`,
})),
})),
})),
});
await refreshSlashCommands({
client: { request } as never,
agentId: "main",
});
const remoteCommands = SLASH_COMMANDS.filter((entry) => entry.name.startsWith("plugin-"));
expect(remoteCommands).toHaveLength(500);
const first = remoteCommands[0];
expect(first.aliases).toHaveLength(19);
expect(first.description.length).toBeLessThanOrEqual(2_000);
expect(first.args?.split(" ")).toHaveLength(20);
expect(first.argOptions).toHaveLength(50);
});
it("requests the gateway default agent when no explicit agentId is available", async () => {
const request = vi.fn().mockResolvedValue({
commands: [
{
name: "pair",
textAliases: ["/pair"],
description: "Generate setup codes.",
source: "plugin",
scope: "both",
acceptsArgs: true,
},
],
});
await refreshSlashCommands({
client: { request } as never,
agentId: undefined,
});
expect(request).toHaveBeenCalledWith("commands.list", {
includeArgs: true,
scope: "text",
});
expect(SLASH_COMMANDS.find((entry) => entry.name === "pair")).toBeDefined();
});
it("falls back safely when the gateway returns malformed command payload shapes", async () => {
const request = vi
.fn()
.mockResolvedValueOnce({ commands: { bad: "shape" } })
.mockResolvedValueOnce({
commands: [
{
name: "valid",
textAliases: ["/valid"],
description: 42,
args: { nope: true },
},
{
name: "pair",
textAliases: ["/pair"],
description: "Generate setup codes.",
source: "plugin",
scope: "both",
acceptsArgs: true,
args: [
{
name: "mode",
required: "yes",
choices: { broken: true },
},
],
},
],
});
await refreshSlashCommands({
client: { request } as never,
agentId: "main",
});
expect(SLASH_COMMANDS.find((entry) => entry.name === "pair")).toBeUndefined();
expect(SLASH_COMMANDS.find((entry) => entry.name === "help")).toBeDefined();
await refreshSlashCommands({
client: { request } as never,
agentId: "main",
});
expect(SLASH_COMMANDS.find((entry) => entry.name === "valid")).toMatchObject({
name: "valid",
description: "",
});
expect(SLASH_COMMANDS.find((entry) => entry.name === "pair")).toMatchObject({
name: "pair",
});
});
it("ignores stale refresh responses and keeps the latest command set", async () => {
let resolveFirst: ((value: unknown) => void) | undefined;
const first = new Promise((resolve) => {
resolveFirst = resolve;
});
const request = vi
.fn()
.mockImplementationOnce(async () => await first)
.mockImplementationOnce(async () => ({
commands: [
{
name: "pair",
textAliases: ["/pair"],
description: "Generate setup codes.",
source: "plugin",
scope: "both",
acceptsArgs: true,
},
],
}));
const pending = refreshSlashCommands({
client: { request } as never,
agentId: "main",
});
await refreshSlashCommands({
client: { request } as never,
agentId: "main",
});
if (resolveFirst) {
resolveFirst({
commands: [
{
name: "dreaming",
textAliases: ["/dreaming"],
description: "Enable or disable memory dreaming.",
source: "plugin",
scope: "both",
acceptsArgs: true,
},
],
});
}
await pending;
expect(SLASH_COMMANDS.find((entry) => entry.name === "pair")).toBeDefined();
expect(SLASH_COMMANDS.find((entry) => entry.name === "dreaming")).toBeUndefined();
});
});

View File

@@ -1,8 +1,6 @@
import { buildBuiltinChatCommands } from "../../../../src/auto-reply/commands-registry.shared.js";
import type {
ChatCommandDefinition,
CommandArgChoice,
} from "../../../../src/auto-reply/commands-registry.types.js";
import type { CommandEntry, CommandsListResult } from "../../../../src/gateway/protocol/index.js";
import type { GatewayBrowserClient } from "../gateway.ts";
import type { IconName } from "../icons.ts";
import { normalizeLowercaseStringOrEmpty } from "../string-coerce.ts";
@@ -24,6 +22,30 @@ export type SlashCommandDef = {
shortcut?: string;
};
type LocalArgChoice = string | { value: string; label: string };
type CommandLike = {
key: string;
name: string;
aliases?: string[];
description: string;
args?: Array<{
name: string;
required?: boolean;
choices?: LocalArgChoice[];
}>;
category?: string;
};
const REMOTE_SLASH_IDENTIFIER_PATTERN = /^[a-z0-9][a-z0-9_-]*$/u;
const MAX_REMOTE_COMMANDS = 500;
const MAX_REMOTE_ALIAS_COUNT = 20;
const MAX_REMOTE_ARGS = 20;
const MAX_REMOTE_CHOICES = 50;
const MAX_REMOTE_NAME_LENGTH = 200;
const MAX_REMOTE_DESCRIPTION_LENGTH = 2_000;
const MAX_REMOTE_ARG_NAME_LENGTH = 200;
const COMMAND_ICON_OVERRIDES: Partial<Record<string, IconName>> = {
help: "book",
status: "barChart",
@@ -130,26 +152,22 @@ const COMMAND_ARGS_OVERRIDES: Partial<Record<string, string>> = {
steer: "[id] <message>",
};
function normalizeUiKey(command: ChatCommandDefinition): string {
function normalizeUiKey(command: CommandLike): string {
return command.key.replace(/[:.-]/g, "_");
}
function getSlashAliases(command: ChatCommandDefinition): string[] {
return command.textAliases
function getSlashAliases(command: CommandLike): string[] {
return (command.aliases ?? [])
.map((alias) => alias.trim())
.filter((alias) => alias.startsWith("/"))
.map((alias) => alias.slice(1));
.filter(Boolean)
.map((alias) => (alias.startsWith("/") ? alias.slice(1) : alias));
}
function getPrimarySlashName(command: ChatCommandDefinition): string | null {
const aliases = getSlashAliases(command);
if (aliases.length === 0) {
return null;
}
return aliases[0] ?? null;
function getPrimarySlashName(command: CommandLike): string | null {
return command.name.trim() || null;
}
function formatArgs(command: ChatCommandDefinition): string | undefined {
function formatArgs(command: CommandLike): string | undefined {
if (!command.args?.length) {
return undefined;
}
@@ -161,28 +179,44 @@ function formatArgs(command: ChatCommandDefinition): string | undefined {
.join(" ");
}
function choiceToValue(choice: CommandArgChoice): string {
function choiceToValue(choice: LocalArgChoice): string {
return typeof choice === "string" ? choice : choice.value;
}
function getArgOptions(command: ChatCommandDefinition): string[] | undefined {
function getArgOptions(command: CommandLike): string[] | undefined {
const firstArg = command.args?.[0];
if (!firstArg || typeof firstArg.choices === "function") {
if (!firstArg) {
return undefined;
}
const options = firstArg.choices?.map(choiceToValue).filter(Boolean);
return options?.length ? options : undefined;
}
function mapCategory(command: ChatCommandDefinition): SlashCommandCategory {
return CATEGORY_OVERRIDES[normalizeUiKey(command)] ?? "tools";
function mapCategory(command: CommandLike): SlashCommandCategory {
const override = CATEGORY_OVERRIDES[normalizeUiKey(command)];
if (override) {
return override;
}
switch (command.category) {
case "session":
return "session";
case "options":
return "model";
case "management":
return "tools";
default:
return "tools";
}
}
function mapIcon(command: ChatCommandDefinition): IconName | undefined {
function mapIcon(command: CommandLike): IconName | undefined {
return COMMAND_ICON_OVERRIDES[normalizeUiKey(command)] ?? "terminal";
}
function toSlashCommand(command: ChatCommandDefinition): SlashCommandDef | null {
function toSlashCommand(
command: CommandLike,
source: "local" | "remote" = "local",
): SlashCommandDef | null {
const name = getPrimarySlashName(command);
if (!name) {
return null;
@@ -195,17 +229,221 @@ function toSlashCommand(command: ChatCommandDefinition): SlashCommandDef | null
args: COMMAND_ARGS_OVERRIDES[command.key] ?? formatArgs(command),
icon: mapIcon(command),
category: mapCategory(command),
executeLocal: LOCAL_COMMANDS.has(command.key),
executeLocal: source === "local" && LOCAL_COMMANDS.has(command.key),
argOptions: getArgOptions(command),
};
}
export const SLASH_COMMANDS: SlashCommandDef[] = [
...buildBuiltinChatCommands()
.map(toSlashCommand)
.filter((command): command is SlashCommandDef => command !== null),
...UI_ONLY_COMMANDS,
];
function normalizeSlashIdentifier(raw: string): string | null {
const trimmed = raw.trim().replace(/^\//u, "").slice(0, MAX_REMOTE_NAME_LENGTH);
const normalized = normalizeLowercaseStringOrEmpty(trimmed);
if (!normalized || !REMOTE_SLASH_IDENTIFIER_PATTERN.test(normalized)) {
return null;
}
return normalized;
}
function clampText(value: unknown, maxLength: number): string {
const text = typeof value === "string" ? value : "";
return text.length > maxLength ? text.slice(0, maxLength) : text;
}
function asRecord(value: unknown): Record<string, unknown> | null {
return value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: null;
}
function getEntryArgs(
entry: CommandEntry | Record<string, unknown>,
): Array<Record<string, unknown>> {
const rawArgs = "args" in entry ? entry.args : undefined;
if (!Array.isArray(rawArgs)) {
return [];
}
return rawArgs
.map((arg) => asRecord(arg))
.filter((arg): arg is Record<string, unknown> => arg !== null);
}
function getArgChoices(arg: Record<string, unknown>): LocalArgChoice[] {
if (arg.dynamic === true) {
return [];
}
const rawChoices = arg.choices;
if (!Array.isArray(rawChoices)) {
return [];
}
return rawChoices
.map((choice) => {
if (typeof choice === "string") {
return clampText(choice, MAX_REMOTE_NAME_LENGTH);
}
const record = asRecord(choice);
if (!record) {
return null;
}
return {
value: clampText(record.value, MAX_REMOTE_NAME_LENGTH),
label: clampText(record.label, MAX_REMOTE_NAME_LENGTH),
};
})
.filter((choice): choice is LocalArgChoice => {
if (!choice) {
return false;
}
return typeof choice === "string" ? Boolean(choice) : Boolean(choice.value);
});
}
function buildLocalSlashCommands(): SlashCommandDef[] {
const builtins = buildBuiltinChatCommands()
.map((command) => ({
key: command.key,
name: command.textAliases[0]?.replace(/^\//u, "") ?? command.key,
aliases: command.textAliases,
description: command.description,
args: command.args?.map((arg) => ({
name: arg.name,
required: arg.required,
choices: Array.isArray(arg.choices) ? arg.choices : undefined,
})),
category: command.category,
}))
.map((command) => toSlashCommand(command, "local"))
.filter((command): command is SlashCommandDef => command !== null);
return [...builtins, ...UI_ONLY_COMMANDS];
}
function buildReservedLocalSlashNames(): Set<string> {
const reserved = new Set<string>();
for (const command of buildLocalSlashCommands()) {
reserved.add(normalizeLowercaseStringOrEmpty(command.name));
for (const alias of command.aliases ?? []) {
const normalized = normalizeSlashIdentifier(alias);
if (normalized) {
reserved.add(normalized);
}
}
}
return reserved;
}
function normalizeCommandEntry(
entry: CommandEntry | Record<string, unknown>,
reservedLocalNames: Set<string>,
): CommandLike | null {
const aliases = (Array.isArray(entry.textAliases) ? entry.textAliases : [])
.slice(0, MAX_REMOTE_ALIAS_COUNT)
.filter((alias): alias is string => typeof alias === "string")
.map(normalizeSlashIdentifier)
.filter((alias): alias is string => Boolean(alias))
.filter((alias) => !reservedLocalNames.has(alias));
const primaryName =
aliases[0] ?? (typeof entry.name === "string" ? normalizeSlashIdentifier(entry.name) : null);
if (!primaryName || reservedLocalNames.has(primaryName)) {
return null;
}
const args = getEntryArgs(entry)
.slice(0, MAX_REMOTE_ARGS)
.map((arg) => ({
name: clampText(arg.name, MAX_REMOTE_ARG_NAME_LENGTH),
required: arg.required === true,
choices: getArgChoices(arg).slice(0, MAX_REMOTE_CHOICES),
}))
.filter((arg) => arg.name.length > 0)
.map((arg) => ({
name: arg.name,
...(arg.required ? { required: true } : {}),
...(arg.choices.length > 0 ? { choices: arg.choices } : {}),
}));
return {
key: primaryName,
name: primaryName,
aliases: aliases.map((alias) => `/${alias}`),
description: clampText(entry.description, MAX_REMOTE_DESCRIPTION_LENGTH),
...(args.length > 0 ? { args } : {}),
category: typeof entry.category === "string" ? entry.category : undefined,
};
}
function replaceSlashCommands(next: SlashCommandDef[]) {
SLASH_COMMANDS.splice(0, SLASH_COMMANDS.length, ...next);
}
function buildSlashCommandsFromEntries(entries: CommandEntry[]): SlashCommandDef[] {
const local = buildLocalSlashCommands();
const reservedLocalNames = buildReservedLocalSlashNames();
const mapped = entries
.slice(0, MAX_REMOTE_COMMANDS)
.map((entry) => normalizeCommandEntry(entry, reservedLocalNames))
.filter((command): command is CommandLike => command !== null)
.map((command) => toSlashCommand(command, "remote"))
.filter((command): command is SlashCommandDef => command !== null);
const deduped = new Map<string, SlashCommandDef>();
for (const command of [...local, ...mapped]) {
const key = normalizeLowercaseStringOrEmpty(command.name);
if (!key || deduped.has(key)) {
continue;
}
deduped.set(key, command);
}
return Array.from(deduped.values());
}
function getRemoteCommandEntries(result: CommandsListResult | null | undefined): CommandEntry[] {
const commands = result?.commands;
if (!Array.isArray(commands)) {
return [];
}
return commands
.map((entry) => asRecord(entry))
.filter((entry): entry is CommandEntry => entry !== null);
}
function buildFallbackSlashCommands(): SlashCommandDef[] {
return buildLocalSlashCommands();
}
export const SLASH_COMMANDS: SlashCommandDef[] = buildFallbackSlashCommands();
let _refreshSeq = 0;
export async function refreshSlashCommands(params: {
client: GatewayBrowserClient | null;
agentId?: string | null;
}): Promise<void> {
const seq = ++_refreshSeq;
const agentId = params.agentId?.trim();
if (!params.client) {
if (seq !== _refreshSeq) {
return;
}
replaceSlashCommands(buildFallbackSlashCommands());
return;
}
try {
const result = await params.client.request<CommandsListResult>("commands.list", {
...(agentId ? { agentId } : {}),
includeArgs: true,
scope: "text",
});
if (seq !== _refreshSeq) {
return;
}
replaceSlashCommands(buildSlashCommandsFromEntries(getRemoteCommandEntries(result)));
} catch {
if (seq !== _refreshSeq) {
return;
}
replaceSlashCommands(buildFallbackSlashCommands());
}
}
export function resetSlashCommandsForTest(): void {
_refreshSeq = 0;
replaceSlashCommands(buildFallbackSlashCommands());
}
const CATEGORY_ORDER: SlashCommandCategory[] = ["session", "model", "tools", "agents"];

View File

@@ -0,0 +1,54 @@
import { afterEach, describe, expect, it } from "vitest";
import { refreshSlashCommands, resetSlashCommandsForTest } from "../chat/slash-commands.ts";
import { getPaletteItems } from "./command-palette.ts";
afterEach(() => {
resetSlashCommandsForTest();
});
describe("command palette", () => {
it("builds slash items from the live runtime command list", async () => {
const request = async (method: string) => {
expect(method).toBe("commands.list");
return {
commands: [
{
name: "pair",
textAliases: ["/pair"],
description: "Generate setup codes and approve device pairing requests.",
source: "plugin",
scope: "both",
acceptsArgs: true,
},
{
name: "prose",
textAliases: ["/prose"],
description: "Draft polished prose.",
source: "skill",
scope: "both",
acceptsArgs: true,
},
],
};
};
await refreshSlashCommands({
client: { request } as never,
agentId: "main",
});
const items = getPaletteItems();
expect(items).toContainEqual(
expect.objectContaining({
id: "slash:pair",
label: "/pair",
}),
);
expect(items).toContainEqual(
expect.objectContaining({
id: "slash:prose",
label: "/prose",
}),
);
});
});

View File

@@ -14,73 +14,86 @@ type PaletteItem = {
description?: string;
};
const SLASH_PALETTE_ITEMS: PaletteItem[] = SLASH_COMMANDS.map((command) => ({
id: `slash:${command.name}`,
label: `/${command.name}`,
icon: command.icon ?? "terminal",
category: "search",
action: `/${command.name}`,
description: command.description,
}));
function buildSlashPaletteItems(): PaletteItem[] {
return SLASH_COMMANDS.map((command) => ({
id: `slash:${command.name}`,
label: `/${command.name}`,
icon: command.icon ?? "terminal",
category: "search",
action: `/${command.name}`,
description: command.description,
}));
}
const PALETTE_ITEMS: PaletteItem[] = [
...SLASH_PALETTE_ITEMS,
{
id: "nav-overview",
label: "Overview",
icon: "barChart",
category: "navigation",
action: "nav:overview",
},
{
id: "nav-sessions",
label: "Sessions",
icon: "fileText",
category: "navigation",
action: "nav:sessions",
},
{
id: "nav-cron",
label: "Scheduled",
icon: "scrollText",
category: "navigation",
action: "nav:cron",
},
{ id: "nav-skills", label: "Skills", icon: "zap", category: "navigation", action: "nav:skills" },
{
id: "nav-config",
label: "Settings",
icon: "settings",
category: "navigation",
action: "nav:config",
},
{
id: "nav-agents",
label: "Agents",
icon: "folder",
category: "navigation",
action: "nav:agents",
},
{
id: "skill-shell",
label: "Shell Command",
icon: "monitor",
category: "skills",
action: "/skill shell",
description: "Run shell",
},
{
id: "skill-debug",
label: "Debug Mode",
icon: "bug",
category: "skills",
action: "/verbose full",
description: "Toggle debug",
},
];
function getPaletteBaseItems(): PaletteItem[] {
return [
{
id: "nav-overview",
label: "Overview",
icon: "barChart",
category: "navigation",
action: "nav:overview",
},
{
id: "nav-sessions",
label: "Sessions",
icon: "fileText",
category: "navigation",
action: "nav:sessions",
},
{
id: "nav-cron",
label: "Scheduled",
icon: "scrollText",
category: "navigation",
action: "nav:cron",
},
{
id: "nav-skills",
label: "Skills",
icon: "zap",
category: "navigation",
action: "nav:skills",
},
{
id: "nav-config",
label: "Settings",
icon: "settings",
category: "navigation",
action: "nav:config",
},
{
id: "nav-agents",
label: "Agents",
icon: "folder",
category: "navigation",
action: "nav:agents",
},
{
id: "skill-shell",
label: "Shell Command",
icon: "monitor",
category: "skills",
action: "/skill shell",
description: "Run shell",
},
{
id: "skill-debug",
label: "Debug Mode",
icon: "bug",
category: "skills",
action: "/verbose full",
description: "Toggle debug",
},
];
}
function getPaletteItemsInternal(): PaletteItem[] {
return [...buildSlashPaletteItems(), ...getPaletteBaseItems()];
}
export function getPaletteItems(): readonly PaletteItem[] {
return PALETTE_ITEMS;
return getPaletteItemsInternal();
}
export type CommandPaletteProps = {
@@ -95,11 +108,12 @@ export type CommandPaletteProps = {
};
function filteredItems(query: string): PaletteItem[] {
const items = getPaletteItemsInternal();
if (!query) {
return PALETTE_ITEMS;
return items;
}
const q = normalizeLowercaseStringOrEmpty(query);
return PALETTE_ITEMS.filter(
return items.filter(
(item) =>
normalizeLowercaseStringOrEmpty(item.label).includes(q) ||
normalizeLowercaseStringOrEmpty(item.description).includes(q),