mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 09:10:45 +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:
@@ -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