From 2805bbd3d7a95dbf31dd9e69a1d3aed660c1bb1f Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 3 May 2026 18:22:13 +0100 Subject: [PATCH] feat(commands): add side alias for btw --- CHANGELOG.md | 1 + docs/channels/slack.md | 5 ++++ docs/tools/btw.md | 3 +- docs/tools/slash-commands.md | 5 ++-- src/auto-reply/commands-registry.shared.ts | 18 ++++++++---- src/auto-reply/commands-registry.test.ts | 14 +++++++++ src/auto-reply/commands-registry.ts | 30 ++++++++++++++++++-- src/auto-reply/commands-registry.types.ts | 1 + src/auto-reply/reply/commands-btw.test.ts | 22 +++++++++++++++ src/tui/embedded-backend.test.ts | 33 ++++++++++++++++++++++ src/tui/embedded-backend.ts | 2 +- src/tui/tui-command-handlers.test.ts | 19 +++++++++++++ src/tui/tui-command-handlers.ts | 2 +- ui/src/ui/app-chat.test.ts | 27 ++++++++++++++++++ ui/src/ui/app-chat.ts | 2 +- ui/src/ui/chat/slash-commands.node.test.ts | 4 +++ 16 files changed, 174 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 09ec2d9ff4e..0446e9df7e3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ Docs: https://docs.openclaw.ai ### Changes +- Tools/BTW: add `/side` as a text and native slash-command alias for `/btw` side questions. - Agents/tools: skip optional media and PDF tool factories when the effective tool denylist already blocks them, avoiding unnecessary hot-path setup for tools that will be filtered out before model use. (#76773) Thanks @dorukardahan. - Discord/status: let explicit reaction tool calls opt into tracking subsequent tool progress on the reacted message with `trackToolCalls: true`, and use the shared tool display emoji table for status reactions. - Gateway/config: stop Gateway startup and hot reload from auto-restoring invalid config; invalid config now fails closed and `openclaw doctor --fix` owns last-known-good repair. diff --git a/docs/channels/slack.md b/docs/channels/slack.md index 07ce37ae44a..6ab601a6d88 100644 --- a/docs/channels/slack.md +++ b/docs/channels/slack.md @@ -387,6 +387,11 @@ The default manifest enables the Slack App Home **Home** tab and subscribes to ` "description": "Ask a side question without changing session context", "usage_hint": "" }, + { + "command": "/side", + "description": "Ask a side question without changing session context", + "usage_hint": "" + }, { "command": "/usage", "description": "Control the usage footer or show cost summary", diff --git a/docs/tools/btw.md b/docs/tools/btw.md index e6d2f97ef02..ccf73c996f9 100644 --- a/docs/tools/btw.md +++ b/docs/tools/btw.md @@ -7,7 +7,7 @@ title: "BTW side questions" --- `/btw` lets you ask a quick side question about the **current session** without -turning that question into normal conversation history. +turning that question into normal conversation history. `/side` is an alias. It is modeled after Claude Code's `/btw` behavior, but adapted to OpenClaw's Gateway and multi-channel architecture. @@ -121,6 +121,7 @@ Examples: ```text /btw what file are we editing? +/side what changed while the main run continued? /btw what does this error mean? /btw summarize the current task in one sentence /btw what is 17 * 19? diff --git a/docs/tools/slash-commands.md b/docs/tools/slash-commands.md index bd0b316ba0a..5503e4a80e0 100644 --- a/docs/tools/slash-commands.md +++ b/docs/tools/slash-commands.md @@ -164,7 +164,7 @@ Current source-of-truth: - `/skill [input]` runs a skill by name. - `/allowlist [list|add|remove] ...` manages allowlist entries. Text-only. - `/approve ` resolves exec approval prompts. - - `/btw ` asks a side question without changing future session context. See [BTW](/tools/btw). + - `/btw ` asks a side question without changing future session context. Alias: `/side`. See [BTW](/tools/btw). @@ -457,7 +457,7 @@ Examples: ## BTW side questions -`/btw` is a quick **side question** about the current session. +`/btw` is a quick **side question** about the current session. `/side` is an alias. Unlike normal chat: @@ -473,6 +473,7 @@ Example: ```text /btw what are we doing right now? +/side what changed while the main run continued? ``` See [BTW Side Questions](/tools/btw) for the full behavior and client UX details. diff --git a/src/auto-reply/commands-registry.shared.ts b/src/auto-reply/commands-registry.shared.ts index 866d8d33d3d..ce562c1bd50 100644 --- a/src/auto-reply/commands-registry.shared.ts +++ b/src/auto-reply/commands-registry.shared.ts @@ -25,6 +25,7 @@ const BROWSER_SAFE_THINKING_LEVELS: ThinkLevel[] = [ type DefineChatCommandInput = { key: string; nativeName?: string; + nativeAliases?: string[]; description: string; args?: ChatCommandDefinition["args"]; argsParsing?: ChatCommandDefinition["argsParsing"]; @@ -50,6 +51,7 @@ export function defineChatCommand(command: DefineChatCommandInput): ChatCommandD return { key: command.key, nativeName: command.nativeName, + nativeAliases: command.nativeAliases?.map((alias) => alias.trim()).filter(Boolean), description: command.description, acceptsArgs, args: command.args, @@ -105,17 +107,22 @@ export function assertCommandRegistry(commands: ChatCommandDefinition[]): void { if (nativeName) { throw new Error(`Text-only command has native name: ${command.key}`); } + if (command.nativeAliases?.length) { + throw new Error(`Text-only command has native aliases: ${command.key}`); + } if (command.textAliases.length === 0) { throw new Error(`Text-only command missing text alias: ${command.key}`); } } else if (!nativeName) { throw new Error(`Native command missing native name: ${command.key}`); } else { - const nativeKey = normalizeOptionalLowercaseString(nativeName) ?? ""; - if (nativeNames.has(nativeKey)) { - throw new Error(`Duplicate native command: ${nativeName}`); + for (const alias of [nativeName, ...(command.nativeAliases ?? [])]) { + const nativeKey = normalizeOptionalLowercaseString(alias) ?? ""; + if (nativeNames.has(nativeKey)) { + throw new Error(`Duplicate native command: ${alias}`); + } + nativeNames.add(nativeKey); } - nativeNames.add(nativeKey); } if (command.scope === "native" && command.textAliases.length > 0) { @@ -268,8 +275,9 @@ export function buildBuiltinChatCommands( defineChatCommand({ key: "btw", nativeName: "btw", + nativeAliases: ["side"], description: "Ask a side question without changing future session context.", - textAlias: "/btw", + textAliases: ["/btw", "/side"], acceptsArgs: true, category: "tools", tier: "standard", diff --git a/src/auto-reply/commands-registry.test.ts b/src/auto-reply/commands-registry.test.ts index a9802b93910..757e15ec6ee 100644 --- a/src/auto-reply/commands-registry.test.ts +++ b/src/auto-reply/commands-registry.test.ts @@ -109,6 +109,20 @@ describe("commands registry", () => { expect(specs.find((spec) => spec.name === "compact")).toBeTruthy(); }); + it("exposes /side as a BTW text and native alias", () => { + const btw = listChatCommands().find((command) => command.key === "btw"); + expect(btw).toMatchObject({ + nativeName: "btw", + nativeAliases: ["side"], + textAliases: ["/btw", "/side"], + }); + expect(normalizeCommandBody("/side what changed?")).toBe("/btw what changed?"); + expect(findCommandByNativeName("side")?.key).toBe("btw"); + expect(listNativeCommandSpecs().find((spec) => spec.name === "side")).toMatchObject({ + acceptsArgs: true, + }); + }); + it("filters commands based on config flags", () => { const disabled = listChatCommandsForConfig({ commands: { config: false, plugins: false, debug: false }, diff --git a/src/auto-reply/commands-registry.ts b/src/auto-reply/commands-registry.ts index 1dd6609de32..eea5cce631e 100644 --- a/src/auto-reply/commands-registry.ts +++ b/src/auto-reply/commands-registry.ts @@ -96,13 +96,36 @@ function toNativeCommandSpec(command: ChatCommandDefinition, provider?: string): return spec; } +function resolveNativeNames(command: ChatCommandDefinition, provider?: string): string[] { + const primary = resolveNativeName(command, provider); + return [primary, ...(command.nativeAliases ?? [])].filter((name): name is string => + Boolean(name), + ); +} + function listNativeSpecsFromCommands( commands: ChatCommandDefinition[], provider?: string, ): NativeCommandSpec[] { return commands .filter((command) => command.scope !== "text" && command.nativeName) - .map((command) => toNativeCommandSpec(command, provider)); + .flatMap((command) => { + const spec = toNativeCommandSpec(command, provider); + return resolveNativeNames(command, provider).map((name) => { + const nativeSpec: NativeCommandSpec = { + name, + description: spec.description, + acceptsArgs: spec.acceptsArgs, + }; + if (spec.args) { + nativeSpec.args = spec.args; + } + if (spec.descriptionLocalizations) { + nativeSpec.descriptionLocalizations = spec.descriptionLocalizations; + } + return nativeSpec; + }); + }); } export function listNativeCommandSpecs(params?: { @@ -134,8 +157,9 @@ export function findCommandByNativeName( return getChatCommands().find( (command) => command.scope !== "text" && - normalizeOptionalLowercaseString(resolveNativeName(command, provider, options)) === - normalized, + [resolveNativeName(command, provider, options), ...(command.nativeAliases ?? [])].some( + (name) => normalizeOptionalLowercaseString(name) === normalized, + ), ); } diff --git a/src/auto-reply/commands-registry.types.ts b/src/auto-reply/commands-registry.types.ts index 560cac303f2..4538331d65e 100644 --- a/src/auto-reply/commands-registry.types.ts +++ b/src/auto-reply/commands-registry.types.ts @@ -58,6 +58,7 @@ export type CommandArgsParsing = "none" | "positional"; export type ChatCommandDefinition = { key: string; nativeName?: string; + nativeAliases?: string[]; description: string; /** Localized descriptions for native command surfaces that support them. */ descriptionLocalizations?: Record; diff --git a/src/auto-reply/reply/commands-btw.test.ts b/src/auto-reply/reply/commands-btw.test.ts index d126ac75020..51703eb2dfe 100644 --- a/src/auto-reply/reply/commands-btw.test.ts +++ b/src/auto-reply/reply/commands-btw.test.ts @@ -139,6 +139,28 @@ describe("handleBtwCommand", () => { }); }); + it("accepts /side as a /btw alias", async () => { + const params = buildParams("/side what changed?"); + params.agentDir = "/tmp/agent"; + params.sessionEntry = { + sessionId: "session-1", + updatedAt: Date.now(), + }; + runBtwSideQuestionMock.mockResolvedValue({ text: "alias answer" }); + + const result = await handleBtwCommand(params, true); + + expect(runBtwSideQuestionMock).toHaveBeenCalledWith( + expect.objectContaining({ + question: "what changed?", + }), + ); + expect(result).toEqual({ + shouldContinue: false, + reply: { text: "alias answer", btw: { question: "what changed?" } }, + }); + }); + it("falls back to the resolved agent dir when the caller omits it", async () => { const params = buildParams("/btw what changed?"); params.agentId = "worker-1"; diff --git a/src/tui/embedded-backend.test.ts b/src/tui/embedded-backend.test.ts index f13811dcb60..ae4963cacdf 100644 --- a/src/tui/embedded-backend.test.ts +++ b/src/tui/embedded-backend.test.ts @@ -332,6 +332,39 @@ describe("EmbeddedTuiBackend", () => { ]); }); + it("emits side-result events for local /side alias runs", async () => { + const { EmbeddedTuiBackend } = await import("./embedded-backend.js"); + agentCommandFromIngressMock.mockResolvedValueOnce({ + payloads: [{ text: "alias answer" }], + meta: {}, + }); + + const backend = new EmbeddedTuiBackend(); + const events: Array<{ event: string; payload: unknown }> = []; + backend.onEvent = (evt) => { + events.push({ event: evt.event, payload: evt.payload }); + }; + + backend.start(); + await backend.sendChat({ + sessionKey: "agent:main:main", + message: "/side what changed?", + runId: "run-side-1", + }); + await flushMicrotasks(); + + expect(events).toContainEqual({ + event: "chat.side_result", + payload: { + kind: "btw", + runId: "run-side-1", + sessionKey: "agent:main:main", + question: "what changed?", + text: "alias answer", + }, + }); + }); + it("registers tool-first local runs before forwarding agent events", async () => { const { EmbeddedTuiBackend } = await import("./embedded-backend.js"); const pending = deferred<{ diff --git a/src/tui/embedded-backend.ts b/src/tui/embedded-backend.ts index 265c0b41ff8..fdcfd5fbfe3 100644 --- a/src/tui/embedded-backend.ts +++ b/src/tui/embedded-backend.ts @@ -75,7 +75,7 @@ const silentRuntime = { }; function resolveBtwQuestion(message: string): string | undefined { - const match = /^\/btw(?::|\s)+(.*)$/i.exec(message.trim()); + const match = /^\/(?:btw|side)(?::|\s)+(.*)$/i.exec(message.trim()); const question = match?.[1]?.trim(); return question ? question : undefined; } diff --git a/src/tui/tui-command-handlers.test.ts b/src/tui/tui-command-handlers.test.ts index 6c0c992609b..b73c1393033 100644 --- a/src/tui/tui-command-handlers.test.ts +++ b/src/tui/tui-command-handlers.test.ts @@ -315,6 +315,25 @@ describe("tui command handlers", () => { ); }); + it("sends /side without hijacking the active main run", async () => { + const { handleCommand, sendChat, addUser, noteLocalRunId, noteLocalBtwRunId, state } = + createHarness({ + activeChatRunId: "run-main", + }); + + await handleCommand("/side what changed?"); + + expect(addUser).not.toHaveBeenCalled(); + expect(noteLocalRunId).not.toHaveBeenCalled(); + expect(noteLocalBtwRunId).toHaveBeenCalledTimes(1); + expect(state.activeChatRunId).toBe("run-main"); + expect(sendChat).toHaveBeenCalledWith( + expect.objectContaining({ + message: "/side what changed?", + }), + ); + }); + it("creates unique session for /new and resets shared session for /reset", async () => { const loadHistory = vi.fn().mockResolvedValue(undefined); const setSessionMock = vi.fn().mockResolvedValue(undefined) as SetSessionMock; diff --git a/src/tui/tui-command-handlers.ts b/src/tui/tui-command-handlers.ts index afc8f52a1db..21e3d2a61a4 100644 --- a/src/tui/tui-command-handlers.ts +++ b/src/tui/tui-command-handlers.ts @@ -55,7 +55,7 @@ type CommandHandlerContext = { }; function isBtwCommand(text: string): boolean { - return /^\/btw(?::|\s|$)/i.test(text.trim()); + return /^\/(?:btw|side)(?::|\s|$)/i.test(text.trim()); } export function createCommandHandlers(context: CommandHandlerContext) { diff --git a/ui/src/ui/app-chat.test.ts b/ui/src/ui/app-chat.test.ts index 12893fbd365..5639d6b6feb 100644 --- a/ui/src/ui/app-chat.test.ts +++ b/ui/src/ui/app-chat.test.ts @@ -715,6 +715,33 @@ describe("handleSendChat", () => { expect(host.chatMessage).toBe("/btw what changed?"); }); + it("sends /side through the detached BTW path", async () => { + const request = vi.fn(async (method: string) => { + if (method === "chat.send") { + return {}; + } + throw new Error(`Unexpected request: ${method}`); + }); + const host = makeHost({ + client: { request } as unknown as ChatHost["client"], + chatRunId: "run-main", + chatStream: "Working...", + chatMessage: "/side what changed?", + }); + + await handleSendChat(host); + + expect(request).toHaveBeenCalledWith( + "chat.send", + expect.objectContaining({ + message: "/side what changed?", + deliver: false, + }), + ); + expect(host.chatQueue).toEqual([]); + expect(host.chatRunId).toBe("run-main"); + }); + it("sends /btw without adopting a main chat run when idle", async () => { const request = vi.fn(async (method: string) => { if (method === "chat.send") { diff --git a/ui/src/ui/app-chat.ts b/ui/src/ui/app-chat.ts index 1c458005687..dc77c4ba914 100644 --- a/ui/src/ui/app-chat.ts +++ b/ui/src/ui/app-chat.ts @@ -146,7 +146,7 @@ function confirmChatResetCommand(text: string) { } function isBtwCommand(text: string) { - return /^\/btw(?::|\s|$)/i.test(text.trim()); + return /^\/(?:btw|side)(?::|\s|$)/i.test(text.trim()); } export async function handleAbortChat(host: ChatHost) { diff --git a/ui/src/ui/chat/slash-commands.node.test.ts b/ui/src/ui/chat/slash-commands.node.test.ts index fdc504fd414..48b62dd9d55 100644 --- a/ui/src/ui/chat/slash-commands.node.test.ts +++ b/ui/src/ui/chat/slash-commands.node.test.ts @@ -79,6 +79,10 @@ describe("parseSlashCommand", () => { command: { key: "export-session" }, args: "", }); + expect(parseSlashCommand("/side what changed?")).toMatchObject({ + command: { key: "btw", name: "btw", aliases: expect.arrayContaining(["side"]) }, + args: "what changed?", + }); }); it("keeps canonical long-form slash names as the primary menu command", () => {