From 6fcfeed5dc6709ecff6bed5ac2e0085465772767 Mon Sep 17 00:00:00 2001 From: "clawsweeper[bot]" <274271284+clawsweeper[bot]@users.noreply.github.com> Date: Tue, 19 May 2026 03:55:26 +0000 Subject: [PATCH] fix: include gateway plugin commands in TUI autocomplete (#83941) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Summary: - The PR adds TUI-side Gateway `commands.list` fetching, dynamic slash-command merging, backend typing/tests, and a changelog entry so Gateway-connected TUI sessions suggest plugin-owned slash commands. - Reproducibility: yes. Source inspection shows current main builds TUI autocomplete without any `commands.lis ... y exposes text-scope plugin commands, and the source PR supplies after-fix command output plus screenshots. Automerge notes: - PR branch already contained follow-up commit before automerge: fix: include gateway plugin commands in TUI autocomplete - PR branch already contained follow-up commit before automerge: fix(clawsweeper): address review for automerge-openclaw-openclaw-8364… Validation: - ClawSweeper review passed for head 2eba76a42dbe146d0f2595f3d56d7dc205b17836. - Required merge gates passed before the squash merge. Prepared head SHA: 2eba76a42dbe146d0f2595f3d56d7dc205b17836 Review: https://github.com/openclaw/openclaw/pull/83941#issuecomment-4484023526 Co-authored-by: Se7en Co-authored-by: clawsweeper <274271284+clawsweeper[bot]@users.noreply.github.com> Co-authored-by: clawsweeper[bot] <274271284+clawsweeper[bot]@users.noreply.github.com> Approved-by: takhoffman Co-authored-by: takhoffman <781889+takhoffman@users.noreply.github.com> --- CHANGELOG.md | 1 + src/tui/commands.test.ts | 19 ++++++++++++ src/tui/commands.ts | 34 ++++++++++++++++---- src/tui/gateway-chat.test.ts | 27 ++++++++++++++++ src/tui/gateway-chat.ts | 8 +++++ src/tui/tui-backend.ts | 3 ++ src/tui/tui.ts | 60 +++++++++++++++++++++++++++++++++++- 7 files changed, 145 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 19728516574..17f5b287910 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ Docs: https://docs.openclaw.ai - CLI: retry config snapshot reads after a transient failure so one rejected read no longer poisons later commands in the same process. (#83931) Thanks @honor2030. - WhatsApp: clarify inbound group diagnostics so observed but unregistered groups point to `channels.whatsapp.groups` without changing routing or sender authorization. (#83846) Thanks @neeravmakwana. - WhatsApp: drain pending outbound deliveries on a 30s periodic timer in addition to the reconnect handler, so messages enqueued while the provider is already connected no longer wait for the next reconnect to send. (#79083) Thanks @Oviemudiaga. +- CLI/TUI: include gateway plugin slash commands in TUI autocomplete, so connected sessions can suggest plugin-owned commands exposed by the running Gateway. (#83640) Thanks @se7en-agent. ## 2026.5.19 diff --git a/src/tui/commands.test.ts b/src/tui/commands.test.ts index d7b5bb29035..4e4d4f1261d 100644 --- a/src/tui/commands.test.ts +++ b/src/tui/commands.test.ts @@ -67,6 +67,25 @@ describe("getSlashCommands", () => { const completions = await think?.getArgumentCompletions?.(""); expect(completions?.length).toBeGreaterThan(0); }); + + it("merges dynamic gateway commands", () => { + const commands = getSlashCommands({ + dynamicCommands: [ + { + name: "dreaming", + textAliases: ["/dreaming"], + description: "Enable or disable memory dreaming.", + source: "plugin", + scope: "both", + acceptsArgs: true, + }, + ], + }); + + expect(commands.find((command) => command.name === "dreaming")?.description).toBe( + "Enable or disable memory dreaming.", + ); + }); }); describe("helpText", () => { diff --git a/src/tui/commands.ts b/src/tui/commands.ts index 22e6a279e9d..76964d6e437 100644 --- a/src/tui/commands.ts +++ b/src/tui/commands.ts @@ -2,6 +2,7 @@ import type { SlashCommand } from "@earendil-works/pi-tui"; import { listChatCommands, listChatCommandsForConfig } from "../auto-reply/commands-registry.js"; import { formatThinkingLevels, listThinkingLevelLabels } from "../auto-reply/thinking.js"; import type { OpenClawConfig } from "../config/types.js"; +import type { CommandEntry } from "../gateway/protocol/index.js"; import { normalizeLowercaseStringOrEmpty } from "../shared/string-coerce.js"; const VERBOSE_LEVELS = ["on", "off"]; @@ -23,6 +24,7 @@ export type SlashCommandOptions = { model?: string; thinkingLevels?: Array<{ id: string; label: string }>; local?: boolean; + dynamicCommands?: CommandEntry[]; }; const COMMAND_ALIASES: Record = { @@ -42,6 +44,24 @@ function createLevelCompletion( })); } +function normalizeSlashCommandName(value: string): string { + return value.replace(/^\//, "").trim(); +} + +function appendSlashCommand( + commands: SlashCommand[], + seen: Set, + name: string, + description: string, +) { + const normalizedName = normalizeSlashCommandName(name); + if (!normalizedName || seen.has(normalizedName)) { + return; + } + seen.add(normalizedName); + commands.push({ name: normalizedName, description }); +} + export function parseCommand(input: string): ParsedCommand { const trimmed = input.replace(/^\//, "").trim(); if (!trimmed) { @@ -142,12 +162,14 @@ export function getSlashCommands(options: SlashCommandOptions = {}): SlashComman for (const command of gatewayCommands) { const aliases = command.textAliases.length > 0 ? command.textAliases : [`/${command.key}`]; for (const alias of aliases) { - const name = alias.replace(/^\//, "").trim(); - if (!name || seen.has(name)) { - continue; - } - seen.add(name); - commands.push({ name, description: command.description }); + appendSlashCommand(commands, seen, alias, command.description); + } + } + + for (const command of options.dynamicCommands ?? []) { + const aliases = command.textAliases?.length ? command.textAliases : [command.name]; + for (const alias of aliases) { + appendSlashCommand(commands, seen, alias, command.description); } } diff --git a/src/tui/gateway-chat.test.ts b/src/tui/gateway-chat.test.ts index f96b32cbf15..f21cd739b5f 100644 --- a/src/tui/gateway-chat.test.ts +++ b/src/tui/gateway-chat.test.ts @@ -598,4 +598,31 @@ describe("GatewayChatClient", () => { await expect(historyPromise).resolves.toEqual({ messages: [] }); expect(request).toHaveBeenCalledTimes(2); }); + + it("lists gateway commands through commands.list", async () => { + const client = new GatewayChatClient({ + url: "ws://127.0.0.1:18789", + token: "test-token", + allowInsecureLocalOperatorUi: true, + }); + const command = { + name: "tts", + textAliases: ["/tts"], + description: "Text to speech", + source: "plugin", + scope: "both", + acceptsArgs: false, + }; + const request = vi.fn().mockResolvedValue({ commands: [command] }); + (client as unknown as { client: { request: typeof request } }).client.request = request; + + await expect( + client.listCommands({ agentId: "main", provider: "discord", scope: "text" }), + ).resolves.toEqual([command]); + expect(request).toHaveBeenCalledWith("commands.list", { + agentId: "main", + provider: "discord", + scope: "text", + }); + }); }); diff --git a/src/tui/gateway-chat.ts b/src/tui/gateway-chat.ts index 3ac372d0b74..8421cb0bdd4 100644 --- a/src/tui/gateway-chat.ts +++ b/src/tui/gateway-chat.ts @@ -19,6 +19,9 @@ import { type HelloOk, MIN_CLIENT_PROTOCOL_VERSION, PROTOCOL_VERSION, + type CommandEntry, + type CommandsListParams, + type CommandsListResult, type SessionsListParams, type SessionsPatchResult, type SessionsPatchParams, @@ -251,6 +254,11 @@ export class GatewayChatClient implements TuiBackend { const res = await this.client.request("models.list"); return Array.isArray(res?.models) ? res.models : []; } + + async listCommands(opts?: CommandsListParams): Promise { + const res = await this.client.request("commands.list", opts ?? {}); + return Array.isArray(res?.commands) ? res.commands : []; + } } export async function resolveGatewayConnection( diff --git a/src/tui/tui-backend.ts b/src/tui/tui-backend.ts index c73f9b37419..cbb24f38059 100644 --- a/src/tui/tui-backend.ts +++ b/src/tui/tui-backend.ts @@ -1,4 +1,6 @@ import type { + CommandEntry, + CommandsListParams, SessionsListParams, SessionsPatchParams, SessionsPatchResult, @@ -119,4 +121,5 @@ export type TuiBackend = { resetSession: (key: string, reason?: "new" | "reset") => Promise; getGatewayStatus: () => Promise; listModels: () => Promise; + listCommands?: (opts?: CommandsListParams) => Promise; }; diff --git a/src/tui/tui.ts b/src/tui/tui.ts index 2fb522501a0..a842294dec2 100644 --- a/src/tui/tui.ts +++ b/src/tui/tui.ts @@ -14,6 +14,7 @@ import { } from "@earendil-works/pi-tui"; import { resolveAgentIdByWorkspacePath, resolveDefaultAgentId } from "../agents/agent-scope.js"; import { getRuntimeConfig, type OpenClawConfig } from "../config/config.js"; +import type { CommandEntry } from "../gateway/protocol/index.js"; import { registerUncaughtExceptionHandler } from "../infra/unhandled-rejections.js"; import { setConsoleSubsystemFilter } from "../logging/console.js"; import { loggingState } from "../logging/state.js"; @@ -475,6 +476,10 @@ export async function runTui(opts: RunTuiOptions): Promise { const autoMessage = opts.message?.trim(); let autoMessageSent = false; let sessionInfo: SessionInfo = {}; + let dynamicSlashCommands: CommandEntry[] = []; + let dynamicSlashCommandsKey: string | null = null; + let dynamicSlashCommandsInFlightKey: string | null = null; + let dynamicSlashCommandsRequestId = 0; let lastCtrlCAt = 0; let exitRequested = false; let exitResult: TuiResult = { exitReason: "exit" }; @@ -699,7 +704,10 @@ export async function runTui(opts: RunTuiOptions): Promise { root.addChild(footer); root.addChild(editor); - const updateAutocompleteProvider = () => { + const resolveDynamicSlashCommandsKey = () => currentAgentId; + + const applyAutocompleteProvider = () => { + const dynamicKey = resolveDynamicSlashCommandsKey(); editor.setAutocompleteProvider( new CombinedAutocompleteProvider( getSlashCommands({ @@ -708,12 +716,56 @@ export async function runTui(opts: RunTuiOptions): Promise { provider: sessionInfo.modelProvider, model: sessionInfo.model, thinkingLevels: sessionInfo.thinkingLevels, + dynamicCommands: dynamicSlashCommandsKey === dynamicKey ? dynamicSlashCommands : [], }), process.cwd(), ), ); }; + const refreshDynamicSlashCommands = () => { + const key = resolveDynamicSlashCommandsKey(); + if ( + !isConnected || + !client.listCommands || + dynamicSlashCommandsKey === key || + dynamicSlashCommandsInFlightKey === key + ) { + return; + } + dynamicSlashCommandsInFlightKey = key; + const requestId = ++dynamicSlashCommandsRequestId; + const agentId = currentAgentId; + void client + .listCommands({ + agentId, + scope: "text", + includeArgs: false, + }) + .then((commands) => { + if ( + requestId !== dynamicSlashCommandsRequestId || + key !== resolveDynamicSlashCommandsKey() + ) { + return; + } + dynamicSlashCommands = commands; + dynamicSlashCommandsKey = key; + applyAutocompleteProvider(); + }) + .catch(() => undefined) + .finally(() => { + if (dynamicSlashCommandsInFlightKey === key) { + dynamicSlashCommandsInFlightKey = null; + } + }); + }; + + const updateAutocompleteProvider = () => { + applyAutocompleteProvider(); + refreshDynamicSlashCommands(); + }; + tui.addChild(root); tui.setFocus(editor); @@ -1339,6 +1391,7 @@ export async function runTui(opts: RunTuiOptions): Promise { await refreshAgents(); await restoreRememberedSession(); updateHeader(); + updateAutocompleteProvider(); await loadHistory(); setConnectionStatus( isLocalMode ? "local ready" : reconnected ? "gateway reconnected" : "gateway connected", @@ -1362,6 +1415,11 @@ export async function runTui(opts: RunTuiOptions): Promise { isConnected = false; wasDisconnected = true; historyLoaded = false; + dynamicSlashCommands = []; + dynamicSlashCommandsKey = null; + dynamicSlashCommandsInFlightKey = null; + dynamicSlashCommandsRequestId += 1; + updateAutocompleteProvider(); pauseStreamingWatchdog(); const disconnectState = isLocalMode ? {