mirror of
https://github.com/moltbot/moltbot.git
synced 2026-04-15 19:01:24 +00:00
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:
@@ -9,6 +9,7 @@ Docs: https://docs.openclaw.ai
|
||||
### Fixes
|
||||
|
||||
- Gateway/startup: defer scheduled services until sidecars finish, gate chat history and model listing during sidecar resume, and let Control UI retry startup-gated history loads so Sandbox wake resumes channels first. (#65365) Thanks @lml2468.
|
||||
- Control UI/chat: load the live gateway slash-command catalog into the composer and command palette so dock commands, plugin commands, and direct skill aliases appear in chat, while keeping trusted local commands authoritative and bounding remote command metadata. (#65620) Thanks @BunsDev.
|
||||
|
||||
## 2026.4.12-beta.1
|
||||
|
||||
|
||||
@@ -1,6 +1,19 @@
|
||||
import { Type } from "@sinclair/typebox";
|
||||
import { NonEmptyString } from "./primitives.js";
|
||||
|
||||
export const COMMAND_NAME_MAX_LENGTH = 200;
|
||||
export const COMMAND_DESCRIPTION_MAX_LENGTH = 2_000;
|
||||
export const COMMAND_ALIAS_MAX_ITEMS = 20;
|
||||
export const COMMAND_ARGS_MAX_ITEMS = 20;
|
||||
export const COMMAND_ARG_NAME_MAX_LENGTH = 200;
|
||||
export const COMMAND_ARG_DESCRIPTION_MAX_LENGTH = 500;
|
||||
export const COMMAND_ARG_CHOICES_MAX_ITEMS = 50;
|
||||
export const COMMAND_CHOICE_VALUE_MAX_LENGTH = 200;
|
||||
export const COMMAND_CHOICE_LABEL_MAX_LENGTH = 200;
|
||||
export const COMMAND_LIST_MAX_ITEMS = 500;
|
||||
|
||||
const BoundedNonEmptyString = (maxLength: number) => Type.String({ minLength: 1, maxLength });
|
||||
|
||||
export const CommandSourceSchema = Type.Union([
|
||||
Type.Literal("native"),
|
||||
Type.Literal("skill"),
|
||||
@@ -25,19 +38,21 @@ export const CommandCategorySchema = Type.Union([
|
||||
|
||||
export const CommandArgChoiceSchema = Type.Object(
|
||||
{
|
||||
value: Type.String(),
|
||||
label: Type.String(),
|
||||
value: Type.String({ maxLength: COMMAND_CHOICE_VALUE_MAX_LENGTH }),
|
||||
label: Type.String({ maxLength: COMMAND_CHOICE_LABEL_MAX_LENGTH }),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
);
|
||||
|
||||
export const CommandArgSchema = Type.Object(
|
||||
{
|
||||
name: NonEmptyString,
|
||||
description: Type.String(),
|
||||
name: BoundedNonEmptyString(COMMAND_ARG_NAME_MAX_LENGTH),
|
||||
description: Type.String({ maxLength: COMMAND_ARG_DESCRIPTION_MAX_LENGTH }),
|
||||
type: Type.Union([Type.Literal("string"), Type.Literal("number"), Type.Literal("boolean")]),
|
||||
required: Type.Optional(Type.Boolean()),
|
||||
choices: Type.Optional(Type.Array(CommandArgChoiceSchema)),
|
||||
choices: Type.Optional(
|
||||
Type.Array(CommandArgChoiceSchema, { maxItems: COMMAND_ARG_CHOICES_MAX_ITEMS }),
|
||||
),
|
||||
dynamic: Type.Optional(Type.Boolean()),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
@@ -45,15 +60,19 @@ export const CommandArgSchema = Type.Object(
|
||||
|
||||
export const CommandEntrySchema = Type.Object(
|
||||
{
|
||||
name: NonEmptyString,
|
||||
nativeName: Type.Optional(NonEmptyString),
|
||||
textAliases: Type.Optional(Type.Array(NonEmptyString)),
|
||||
description: Type.String(),
|
||||
name: BoundedNonEmptyString(COMMAND_NAME_MAX_LENGTH),
|
||||
nativeName: Type.Optional(BoundedNonEmptyString(COMMAND_NAME_MAX_LENGTH)),
|
||||
textAliases: Type.Optional(
|
||||
Type.Array(BoundedNonEmptyString(COMMAND_NAME_MAX_LENGTH), {
|
||||
maxItems: COMMAND_ALIAS_MAX_ITEMS,
|
||||
}),
|
||||
),
|
||||
description: Type.String({ maxLength: COMMAND_DESCRIPTION_MAX_LENGTH }),
|
||||
category: Type.Optional(CommandCategorySchema),
|
||||
source: CommandSourceSchema,
|
||||
scope: CommandScopeSchema,
|
||||
acceptsArgs: Type.Boolean(),
|
||||
args: Type.Optional(Type.Array(CommandArgSchema)),
|
||||
args: Type.Optional(Type.Array(CommandArgSchema, { maxItems: COMMAND_ARGS_MAX_ITEMS })),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
);
|
||||
@@ -70,7 +89,7 @@ export const CommandsListParamsSchema = Type.Object(
|
||||
|
||||
export const CommandsListResultSchema = Type.Object(
|
||||
{
|
||||
commands: Type.Array(CommandEntrySchema),
|
||||
commands: Type.Array(CommandEntrySchema, { maxItems: COMMAND_LIST_MAX_ITEMS }),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
);
|
||||
|
||||
@@ -132,6 +132,14 @@ vi.mock("../../channels/plugins/index.js", () => ({
|
||||
}));
|
||||
|
||||
import { ErrorCodes, errorShape } from "../protocol/index.js";
|
||||
import {
|
||||
COMMAND_ALIAS_MAX_ITEMS,
|
||||
COMMAND_ARG_CHOICES_MAX_ITEMS,
|
||||
COMMAND_ARGS_MAX_ITEMS,
|
||||
COMMAND_DESCRIPTION_MAX_LENGTH,
|
||||
COMMAND_LIST_MAX_ITEMS,
|
||||
COMMAND_NAME_MAX_LENGTH,
|
||||
} from "../protocol/schema/commands.js";
|
||||
import { commandsHandlers, buildCommandsListResult } from "./commands.js";
|
||||
|
||||
function callHandler(params: Record<string, unknown> = {}) {
|
||||
@@ -344,6 +352,57 @@ describe("commands.list handler", () => {
|
||||
expect(model!.args).toBeUndefined();
|
||||
});
|
||||
|
||||
it("caps serialized command payload size and field lengths", () => {
|
||||
const originalCommands = [...mockChatCommands];
|
||||
const longToken = "x".repeat(COMMAND_NAME_MAX_LENGTH + 50);
|
||||
const aliasBase = "alias".repeat(20);
|
||||
const longDescription = "d".repeat(COMMAND_DESCRIPTION_MAX_LENGTH + 50);
|
||||
try {
|
||||
mockChatCommands.length = 0;
|
||||
for (let index = 0; index < COMMAND_LIST_MAX_ITEMS + 25; index += 1) {
|
||||
mockChatCommands.push({
|
||||
key: `cmd-${index}`,
|
||||
description: longDescription,
|
||||
textAliases: Array.from(
|
||||
{ length: COMMAND_ALIAS_MAX_ITEMS + 5 },
|
||||
(_, aliasIndex) => `/${aliasBase}-${index}-${aliasIndex}`,
|
||||
),
|
||||
acceptsArgs: true,
|
||||
args: Array.from({ length: COMMAND_ARGS_MAX_ITEMS + 5 }, (_, argIndex) => ({
|
||||
name: `${longToken}-${argIndex}`,
|
||||
description: longDescription,
|
||||
type: "string",
|
||||
choices: Array.from(
|
||||
{ length: COMMAND_ARG_CHOICES_MAX_ITEMS + 5 },
|
||||
(_, choiceIndex) => ({
|
||||
value: `${longToken}-${choiceIndex}`,
|
||||
label: `${longToken}-${choiceIndex}`,
|
||||
}),
|
||||
),
|
||||
})),
|
||||
scope: "both",
|
||||
category: "tools",
|
||||
});
|
||||
}
|
||||
|
||||
const { payload } = callHandler();
|
||||
const { commands } = payload as { commands: Array<Record<string, unknown>> };
|
||||
expect(commands).toHaveLength(COMMAND_LIST_MAX_ITEMS);
|
||||
const first = commands[0];
|
||||
expect((first.name as string).length).toBeLessThanOrEqual(COMMAND_NAME_MAX_LENGTH);
|
||||
expect((first.description as string).length).toBeLessThanOrEqual(
|
||||
COMMAND_DESCRIPTION_MAX_LENGTH,
|
||||
);
|
||||
expect((first.textAliases as unknown[]).length).toBeLessThanOrEqual(COMMAND_ALIAS_MAX_ITEMS);
|
||||
expect(first.args as unknown[]).toHaveLength(COMMAND_ARGS_MAX_ITEMS);
|
||||
const firstArg = (first.args as Array<Record<string, unknown>>)[0];
|
||||
expect(firstArg.choices as unknown[]).toHaveLength(COMMAND_ARG_CHOICES_MAX_ITEMS);
|
||||
} finally {
|
||||
mockChatCommands.length = 0;
|
||||
mockChatCommands.push(...originalCommands);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects unknown agentId", () => {
|
||||
const { ok, error } = callHandler({ agentId: "nonexistent" });
|
||||
expect(ok).toBe(false);
|
||||
|
||||
@@ -19,11 +19,39 @@ import {
|
||||
formatValidationErrors,
|
||||
validateCommandsListParams,
|
||||
} from "../protocol/index.js";
|
||||
import {
|
||||
COMMAND_ALIAS_MAX_ITEMS,
|
||||
COMMAND_ARG_CHOICES_MAX_ITEMS,
|
||||
COMMAND_ARG_DESCRIPTION_MAX_LENGTH,
|
||||
COMMAND_ARG_NAME_MAX_LENGTH,
|
||||
COMMAND_ARGS_MAX_ITEMS,
|
||||
COMMAND_CHOICE_LABEL_MAX_LENGTH,
|
||||
COMMAND_CHOICE_VALUE_MAX_LENGTH,
|
||||
COMMAND_DESCRIPTION_MAX_LENGTH,
|
||||
COMMAND_LIST_MAX_ITEMS,
|
||||
COMMAND_NAME_MAX_LENGTH,
|
||||
} from "../protocol/schema/commands.js";
|
||||
import type { GatewayRequestHandlers, RespondFn } from "./types.js";
|
||||
|
||||
type SerializedArg = NonNullable<CommandEntry["args"]>[number];
|
||||
type CommandNameSurface = "text" | "native";
|
||||
|
||||
function clampString(value: string, maxLength: number): string {
|
||||
return value.length > maxLength ? value.slice(0, maxLength) : value;
|
||||
}
|
||||
|
||||
function trimClampNonEmpty(value: string, maxLength: number): string | null {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) {
|
||||
return null;
|
||||
}
|
||||
return clampString(trimmed, maxLength);
|
||||
}
|
||||
|
||||
function clampDescription(value: string | undefined): string {
|
||||
return clampString(value ?? "", COMMAND_DESCRIPTION_MAX_LENGTH);
|
||||
}
|
||||
|
||||
function resolveAgentIdOrRespondError(rawAgentId: unknown, respond: RespondFn) {
|
||||
const cfg = loadConfig();
|
||||
const knownAgents = listAgentIds(cfg);
|
||||
@@ -61,7 +89,7 @@ function resolveTextAliases(cmd: ChatCommandDefinition): string[] {
|
||||
const seen = new Set<string>();
|
||||
const aliases: string[] = [];
|
||||
for (const alias of cmd.textAliases) {
|
||||
const trimmed = alias.trim();
|
||||
const trimmed = trimClampNonEmpty(alias, COMMAND_NAME_MAX_LENGTH);
|
||||
if (!trimmed) {
|
||||
continue;
|
||||
}
|
||||
@@ -71,11 +99,14 @@ function resolveTextAliases(cmd: ChatCommandDefinition): string[] {
|
||||
}
|
||||
seen.add(exactAlias);
|
||||
aliases.push(exactAlias);
|
||||
if (aliases.length >= COMMAND_ALIAS_MAX_ITEMS) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (aliases.length > 0) {
|
||||
return aliases;
|
||||
}
|
||||
return [`/${cmd.key}`];
|
||||
return [`/${clampString(cmd.key, COMMAND_NAME_MAX_LENGTH)}`];
|
||||
}
|
||||
|
||||
function resolvePrimaryTextName(cmd: ChatCommandDefinition): string {
|
||||
@@ -84,10 +115,12 @@ function resolvePrimaryTextName(cmd: ChatCommandDefinition): string {
|
||||
|
||||
function serializeArg(arg: CommandArgDefinition): SerializedArg {
|
||||
const isDynamic = typeof arg.choices === "function";
|
||||
const staticChoices = Array.isArray(arg.choices) ? arg.choices.map(normalizeChoice) : undefined;
|
||||
const staticChoices = Array.isArray(arg.choices)
|
||||
? arg.choices.slice(0, COMMAND_ARG_CHOICES_MAX_ITEMS).map(normalizeChoice)
|
||||
: undefined;
|
||||
return {
|
||||
name: arg.name,
|
||||
description: arg.description,
|
||||
name: clampString(arg.name, COMMAND_ARG_NAME_MAX_LENGTH),
|
||||
description: clampString(arg.description, COMMAND_ARG_DESCRIPTION_MAX_LENGTH),
|
||||
type: arg.type,
|
||||
...(arg.required ? { required: true } : {}),
|
||||
...(staticChoices ? { choices: staticChoices } : {}),
|
||||
@@ -96,7 +129,17 @@ function serializeArg(arg: CommandArgDefinition): SerializedArg {
|
||||
}
|
||||
|
||||
function normalizeChoice(choice: CommandArgChoice): { value: string; label: string } {
|
||||
return typeof choice === "string" ? { value: choice, label: choice } : choice;
|
||||
if (typeof choice === "string") {
|
||||
const value = clampString(choice, COMMAND_CHOICE_VALUE_MAX_LENGTH);
|
||||
return {
|
||||
value,
|
||||
label: clampString(choice, COMMAND_CHOICE_LABEL_MAX_LENGTH),
|
||||
};
|
||||
}
|
||||
return {
|
||||
value: clampString(choice.value, COMMAND_CHOICE_VALUE_MAX_LENGTH),
|
||||
label: clampString(choice.label, COMMAND_CHOICE_LABEL_MAX_LENGTH),
|
||||
};
|
||||
}
|
||||
|
||||
function mapCommand(
|
||||
@@ -109,15 +152,20 @@ function mapCommand(
|
||||
const shouldIncludeArgs = includeArgs && cmd.acceptsArgs && cmd.args?.length;
|
||||
const nativeName = cmd.scope === "text" ? undefined : resolveNativeName(cmd, provider);
|
||||
return {
|
||||
name: nameSurface === "text" ? resolvePrimaryTextName(cmd) : (nativeName ?? cmd.key),
|
||||
...(nativeName ? { nativeName } : {}),
|
||||
name: clampString(
|
||||
nameSurface === "text" ? resolvePrimaryTextName(cmd) : (nativeName ?? cmd.key),
|
||||
COMMAND_NAME_MAX_LENGTH,
|
||||
),
|
||||
...(nativeName ? { nativeName: clampString(nativeName, COMMAND_NAME_MAX_LENGTH) } : {}),
|
||||
...(cmd.scope !== "native" ? { textAliases: resolveTextAliases(cmd) } : {}),
|
||||
description: cmd.description,
|
||||
description: clampDescription(cmd.description),
|
||||
...(cmd.category ? { category: cmd.category } : {}),
|
||||
source,
|
||||
scope: cmd.scope,
|
||||
acceptsArgs: Boolean(cmd.acceptsArgs),
|
||||
...(shouldIncludeArgs ? { args: cmd.args!.map(serializeArg) } : {}),
|
||||
...(shouldIncludeArgs
|
||||
? { args: cmd.args!.slice(0, COMMAND_ARGS_MAX_ITEMS).map(serializeArg) }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -133,10 +181,13 @@ function buildPluginCommandEntries(params: {
|
||||
const nativeSpec = pluginNativeSpecs[index];
|
||||
const nativeName = nativeSpec?.name;
|
||||
entries.push({
|
||||
name: params.nameSurface === "text" ? textSpec.name : (nativeName ?? textSpec.name),
|
||||
...(nativeName ? { nativeName } : {}),
|
||||
textAliases: [`/${textSpec.name}`],
|
||||
description: textSpec.description,
|
||||
name: clampString(
|
||||
params.nameSurface === "text" ? textSpec.name : (nativeName ?? textSpec.name),
|
||||
COMMAND_NAME_MAX_LENGTH,
|
||||
),
|
||||
...(nativeName ? { nativeName: clampString(nativeName, COMMAND_NAME_MAX_LENGTH) } : {}),
|
||||
textAliases: [`/${clampString(textSpec.name, COMMAND_NAME_MAX_LENGTH)}`],
|
||||
description: clampDescription(textSpec.description),
|
||||
source: "plugin",
|
||||
scope: "both",
|
||||
acceptsArgs: textSpec.acceptsArgs,
|
||||
@@ -184,7 +235,7 @@ export function buildCommandsListResult(params: {
|
||||
|
||||
commands.push(...buildPluginCommandEntries({ provider, nameSurface }));
|
||||
|
||||
return { commands };
|
||||
return { commands: commands.slice(0, COMMAND_LIST_MAX_ITEMS) };
|
||||
}
|
||||
|
||||
export const commandsHandlers: GatewayRequestHandlers = {
|
||||
|
||||
@@ -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>();
|
||||
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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"];
|
||||
|
||||
|
||||
54
ui/src/ui/views/command-palette.test.ts
Normal file
54
ui/src/ui/views/command-palette.test.ts
Normal 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",
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user