mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-24 14:01:04 +00:00
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 head2eba76a42d. - Required merge gates passed before the squash merge. Prepared head SHA:2eba76a42dReview: 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:
@@ -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
|
||||
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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[]>;
|
||||
};
|
||||
|
||||
@@ -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
|
||||
? {
|
||||
|
||||
Reference in New Issue
Block a user