fix: include gateway plugin commands in TUI autocomplete (#83941)

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 2eba76a42d.
- Required merge gates passed before the squash merge.

Prepared head SHA: 2eba76a42d
Review: https://github.com/openclaw/openclaw/pull/83941#issuecomment-4484023526

Co-authored-by: Se7en <se7en-agent@users.noreply.github.com>
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>
This commit is contained in:
clawsweeper[bot]
2026-05-19 03:55:26 +00:00
committed by GitHub
parent b2f9f197a5
commit 6fcfeed5dc
7 changed files with 145 additions and 7 deletions

View File

@@ -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

View File

@@ -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", () => {

View File

@@ -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<string, string> = {
@@ -42,6 +44,24 @@ function createLevelCompletion(
}));
}
function normalizeSlashCommandName(value: string): string {
return value.replace(/^\//, "").trim();
}
function appendSlashCommand(
commands: SlashCommand[],
seen: Set<string>,
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);
}
}

View File

@@ -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",
});
});
});

View File

@@ -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<CommandEntry[]> {
const res = await this.client.request<CommandsListResult>("commands.list", opts ?? {});
return Array.isArray(res?.commands) ? res.commands : [];
}
}
export async function resolveGatewayConnection(

View File

@@ -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<unknown>;
getGatewayStatus: () => Promise<unknown>;
listModels: () => Promise<TuiModelChoice[]>;
listCommands?: (opts?: CommandsListParams) => Promise<CommandEntry[]>;
};

View File

@@ -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<TuiResult> {
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<TuiResult> {
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<TuiResult> {
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<TuiResult> {
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<TuiResult> {
isConnected = false;
wasDisconnected = true;
historyLoaded = false;
dynamicSlashCommands = [];
dynamicSlashCommandsKey = null;
dynamicSlashCommandsInFlightKey = null;
dynamicSlashCommandsRequestId += 1;
updateAutocompleteProvider();
pauseStreamingWatchdog();
const disconnectState = isLocalMode
? {