Skip to content

Commit 6fcfeed

Browse files
clawsweeper[bot]se7en-agentTakhoffman
authored
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 2eba76a. - Required merge gates passed before the squash merge. Prepared head SHA: 2eba76a Review: #83941 (comment) 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>
1 parent b2f9f19 commit 6fcfeed

7 files changed

Lines changed: 145 additions & 7 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ Docs: https://docs.openclaw.ai
1010
- 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.
1111
- WhatsApp: clarify inbound group diagnostics so observed but unregistered groups point to `channels.whatsapp.groups` without changing routing or sender authorization. (#83846) Thanks @neeravmakwana.
1212
- 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.
13+
- 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.
1314

1415
## 2026.5.19
1516

src/tui/commands.test.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,25 @@ describe("getSlashCommands", () => {
6767
const completions = await think?.getArgumentCompletions?.("");
6868
expect(completions?.length).toBeGreaterThan(0);
6969
});
70+
71+
it("merges dynamic gateway commands", () => {
72+
const commands = getSlashCommands({
73+
dynamicCommands: [
74+
{
75+
name: "dreaming",
76+
textAliases: ["/dreaming"],
77+
description: "Enable or disable memory dreaming.",
78+
source: "plugin",
79+
scope: "both",
80+
acceptsArgs: true,
81+
},
82+
],
83+
});
84+
85+
expect(commands.find((command) => command.name === "dreaming")?.description).toBe(
86+
"Enable or disable memory dreaming.",
87+
);
88+
});
7089
});
7190

7291
describe("helpText", () => {

src/tui/commands.ts

Lines changed: 28 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import type { SlashCommand } from "@earendil-works/pi-tui";
22
import { listChatCommands, listChatCommandsForConfig } from "../auto-reply/commands-registry.js";
33
import { formatThinkingLevels, listThinkingLevelLabels } from "../auto-reply/thinking.js";
44
import type { OpenClawConfig } from "../config/types.js";
5+
import type { CommandEntry } from "../gateway/protocol/index.js";
56
import { normalizeLowercaseStringOrEmpty } from "../shared/string-coerce.js";
67

78
const VERBOSE_LEVELS = ["on", "off"];
@@ -23,6 +24,7 @@ export type SlashCommandOptions = {
2324
model?: string;
2425
thinkingLevels?: Array<{ id: string; label: string }>;
2526
local?: boolean;
27+
dynamicCommands?: CommandEntry[];
2628
};
2729

2830
const COMMAND_ALIASES: Record<string, string> = {
@@ -42,6 +44,24 @@ function createLevelCompletion(
4244
}));
4345
}
4446

47+
function normalizeSlashCommandName(value: string): string {
48+
return value.replace(/^\//, "").trim();
49+
}
50+
51+
function appendSlashCommand(
52+
commands: SlashCommand[],
53+
seen: Set<string>,
54+
name: string,
55+
description: string,
56+
) {
57+
const normalizedName = normalizeSlashCommandName(name);
58+
if (!normalizedName || seen.has(normalizedName)) {
59+
return;
60+
}
61+
seen.add(normalizedName);
62+
commands.push({ name: normalizedName, description });
63+
}
64+
4565
export function parseCommand(input: string): ParsedCommand {
4666
const trimmed = input.replace(/^\//, "").trim();
4767
if (!trimmed) {
@@ -142,12 +162,14 @@ export function getSlashCommands(options: SlashCommandOptions = {}): SlashComman
142162
for (const command of gatewayCommands) {
143163
const aliases = command.textAliases.length > 0 ? command.textAliases : [`/${command.key}`];
144164
for (const alias of aliases) {
145-
const name = alias.replace(/^\//, "").trim();
146-
if (!name || seen.has(name)) {
147-
continue;
148-
}
149-
seen.add(name);
150-
commands.push({ name, description: command.description });
165+
appendSlashCommand(commands, seen, alias, command.description);
166+
}
167+
}
168+
169+
for (const command of options.dynamicCommands ?? []) {
170+
const aliases = command.textAliases?.length ? command.textAliases : [command.name];
171+
for (const alias of aliases) {
172+
appendSlashCommand(commands, seen, alias, command.description);
151173
}
152174
}
153175

src/tui/gateway-chat.test.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -598,4 +598,31 @@ describe("GatewayChatClient", () => {
598598
await expect(historyPromise).resolves.toEqual({ messages: [] });
599599
expect(request).toHaveBeenCalledTimes(2);
600600
});
601+
602+
it("lists gateway commands through commands.list", async () => {
603+
const client = new GatewayChatClient({
604+
url: "ws://127.0.0.1:18789",
605+
token: "test-token",
606+
allowInsecureLocalOperatorUi: true,
607+
});
608+
const command = {
609+
name: "tts",
610+
textAliases: ["/tts"],
611+
description: "Text to speech",
612+
source: "plugin",
613+
scope: "both",
614+
acceptsArgs: false,
615+
};
616+
const request = vi.fn().mockResolvedValue({ commands: [command] });
617+
(client as unknown as { client: { request: typeof request } }).client.request = request;
618+
619+
await expect(
620+
client.listCommands({ agentId: "main", provider: "discord", scope: "text" }),
621+
).resolves.toEqual([command]);
622+
expect(request).toHaveBeenCalledWith("commands.list", {
623+
agentId: "main",
624+
provider: "discord",
625+
scope: "text",
626+
});
627+
});
601628
});

src/tui/gateway-chat.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,9 @@ import {
1919
type HelloOk,
2020
MIN_CLIENT_PROTOCOL_VERSION,
2121
PROTOCOL_VERSION,
22+
type CommandEntry,
23+
type CommandsListParams,
24+
type CommandsListResult,
2225
type SessionsListParams,
2326
type SessionsPatchResult,
2427
type SessionsPatchParams,
@@ -251,6 +254,11 @@ export class GatewayChatClient implements TuiBackend {
251254
const res = await this.client.request("models.list");
252255
return Array.isArray(res?.models) ? res.models : [];
253256
}
257+
258+
async listCommands(opts?: CommandsListParams): Promise<CommandEntry[]> {
259+
const res = await this.client.request<CommandsListResult>("commands.list", opts ?? {});
260+
return Array.isArray(res?.commands) ? res.commands : [];
261+
}
254262
}
255263

256264
export async function resolveGatewayConnection(

src/tui/tui-backend.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
import type {
2+
CommandEntry,
3+
CommandsListParams,
24
SessionsListParams,
35
SessionsPatchParams,
46
SessionsPatchResult,
@@ -119,4 +121,5 @@ export type TuiBackend = {
119121
resetSession: (key: string, reason?: "new" | "reset") => Promise<unknown>;
120122
getGatewayStatus: () => Promise<unknown>;
121123
listModels: () => Promise<TuiModelChoice[]>;
124+
listCommands?: (opts?: CommandsListParams) => Promise<CommandEntry[]>;
122125
};

src/tui/tui.ts

Lines changed: 59 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import {
1414
} from "@earendil-works/pi-tui";
1515
import { resolveAgentIdByWorkspacePath, resolveDefaultAgentId } from "../agents/agent-scope.js";
1616
import { getRuntimeConfig, type OpenClawConfig } from "../config/config.js";
17+
import type { CommandEntry } from "../gateway/protocol/index.js";
1718
import { registerUncaughtExceptionHandler } from "../infra/unhandled-rejections.js";
1819
import { setConsoleSubsystemFilter } from "../logging/console.js";
1920
import { loggingState } from "../logging/state.js";
@@ -475,6 +476,10 @@ export async function runTui(opts: RunTuiOptions): Promise<TuiResult> {
475476
const autoMessage = opts.message?.trim();
476477
let autoMessageSent = false;
477478
let sessionInfo: SessionInfo = {};
479+
let dynamicSlashCommands: CommandEntry[] = [];
480+
let dynamicSlashCommandsKey: string | null = null;
481+
let dynamicSlashCommandsInFlightKey: string | null = null;
482+
let dynamicSlashCommandsRequestId = 0;
478483
let lastCtrlCAt = 0;
479484
let exitRequested = false;
480485
let exitResult: TuiResult = { exitReason: "exit" };
@@ -699,7 +704,10 @@ export async function runTui(opts: RunTuiOptions): Promise<TuiResult> {
699704
root.addChild(footer);
700705
root.addChild(editor);
701706

702-
const updateAutocompleteProvider = () => {
707+
const resolveDynamicSlashCommandsKey = () => currentAgentId;
708+
709+
const applyAutocompleteProvider = () => {
710+
const dynamicKey = resolveDynamicSlashCommandsKey();
703711
editor.setAutocompleteProvider(
704712
new CombinedAutocompleteProvider(
705713
getSlashCommands({
@@ -708,12 +716,56 @@ export async function runTui(opts: RunTuiOptions): Promise<TuiResult> {
708716
provider: sessionInfo.modelProvider,
709717
model: sessionInfo.model,
710718
thinkingLevels: sessionInfo.thinkingLevels,
719+
dynamicCommands: dynamicSlashCommandsKey === dynamicKey ? dynamicSlashCommands : [],
711720
}),
712721
process.cwd(),
713722
),
714723
);
715724
};
716725

726+
const refreshDynamicSlashCommands = () => {
727+
const key = resolveDynamicSlashCommandsKey();
728+
if (
729+
!isConnected ||
730+
!client.listCommands ||
731+
dynamicSlashCommandsKey === key ||
732+
dynamicSlashCommandsInFlightKey === key
733+
) {
734+
return;
735+
}
736+
dynamicSlashCommandsInFlightKey = key;
737+
const requestId = ++dynamicSlashCommandsRequestId;
738+
const agentId = currentAgentId;
739+
void client
740+
.listCommands({
741+
agentId,
742+
scope: "text",
743+
includeArgs: false,
744+
})
745+
.then((commands) => {
746+
if (
747+
requestId !== dynamicSlashCommandsRequestId ||
748+
key !== resolveDynamicSlashCommandsKey()
749+
) {
750+
return;
751+
}
752+
dynamicSlashCommands = commands;
753+
dynamicSlashCommandsKey = key;
754+
applyAutocompleteProvider();
755+
})
756+
.catch(() => undefined)
757+
.finally(() => {
758+
if (dynamicSlashCommandsInFlightKey === key) {
759+
dynamicSlashCommandsInFlightKey = null;
760+
}
761+
});
762+
};
763+
764+
const updateAutocompleteProvider = () => {
765+
applyAutocompleteProvider();
766+
refreshDynamicSlashCommands();
767+
};
768+
717769
tui.addChild(root);
718770
tui.setFocus(editor);
719771

@@ -1339,6 +1391,7 @@ export async function runTui(opts: RunTuiOptions): Promise<TuiResult> {
13391391
await refreshAgents();
13401392
await restoreRememberedSession();
13411393
updateHeader();
1394+
updateAutocompleteProvider();
13421395
await loadHistory();
13431396
setConnectionStatus(
13441397
isLocalMode ? "local ready" : reconnected ? "gateway reconnected" : "gateway connected",
@@ -1362,6 +1415,11 @@ export async function runTui(opts: RunTuiOptions): Promise<TuiResult> {
13621415
isConnected = false;
13631416
wasDisconnected = true;
13641417
historyLoaded = false;
1418+
dynamicSlashCommands = [];
1419+
dynamicSlashCommandsKey = null;
1420+
dynamicSlashCommandsInFlightKey = null;
1421+
dynamicSlashCommandsRequestId += 1;
1422+
updateAutocompleteProvider();
13651423
pauseStreamingWatchdog();
13661424
const disconnectState = isLocalMode
13671425
? {

0 commit comments

Comments
 (0)