Skip to content

Commit 400be62

Browse files
authored
feat(codex): add portable Codex command pickers (#82224)
Refactor Codex slash-command pickers so the Codex plugin owns the native command tree and returns portable presentation buttons for channels to render. Telegram now maps portable slash-command buttons to `tgcmd:` native callbacks while preserving approval callback shortening/bypass behavior, and the old Telegram-specific Codex callback menu path is gone. Verification: - `node scripts/run-vitest.mjs extensions/codex/src/command-plugins-management.test.ts extensions/codex/src/commands.test.ts extensions/telegram/src/button-types.test.ts` - `node scripts/run-vitest.mjs extensions/telegram/src/bot.test.ts extensions/telegram/src/button-types.test.ts extensions/telegram/src/bot-native-commands.test.ts extensions/telegram/src/shared.test.ts` - `node scripts/run-vitest.mjs run --config test/vitest/vitest.media-understanding.config.ts --reporter=verbose` - `pnpm check:test-types` - `pnpm tsgo:prod` - `pnpm lint --threads=8` - `git diff --check` - `.agents/skills/autoreview/scripts/autoreview --mode branch --base origin/main` - CI `26714121462` Co-authored-by: Soham Patankar <102520430+yaanfpv@users.noreply.github.com>
1 parent 5a0e677 commit 400be62

16 files changed

Lines changed: 484 additions & 54 deletions

extensions/codex/src/command-handlers.ts

Lines changed: 143 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import crypto from "node:crypto";
22
import { resolveAgentDir, resolveSessionAgentIds } from "openclaw/plugin-sdk/agent-runtime";
3+
import type { MessagePresentation } from "openclaw/plugin-sdk/interactive-runtime";
34
import { parseStrictPositiveInteger } from "openclaw/plugin-sdk/number-runtime";
45
import type { PluginCommandContext, PluginCommandResult } from "openclaw/plugin-sdk/plugin-entry";
56
import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
@@ -240,12 +241,144 @@ export function resetCodexDiagnosticsFeedbackStateForTests(): void {
240241
pendingCodexDiagnosticsConfirmationTokensByScope.clear();
241242
}
242243

244+
type CodexPickerButton = { label: string; command: string };
245+
246+
function buildPickerPresentation(title: string, prompt: string, buttons: CodexPickerButton[]) {
247+
return {
248+
title,
249+
blocks: [
250+
{ type: "text", text: prompt },
251+
{
252+
type: "buttons",
253+
buttons: buttons.map((button) => ({
254+
label: button.label,
255+
value: button.command,
256+
})),
257+
},
258+
],
259+
} satisfies MessagePresentation;
260+
}
261+
262+
/**
263+
* No-arg `/codex` picker. Core owns the native command tree; channels render
264+
* the portable buttons as inline controls when their transport can.
265+
*/
266+
function buildCodexSubcommandPickerReply(): PluginCommandResult {
267+
const verbs: CodexPickerButton[] = [
268+
{ label: "plugins", command: "/codex plugins menu" },
269+
{ label: "permissions", command: "/codex permissions menu" },
270+
{ label: "fast", command: "/codex fast menu" },
271+
{ label: "computer-use", command: "/codex computer-use menu" },
272+
{ label: "account", command: "/codex account" },
273+
{ label: "help", command: "/codex help" },
274+
];
275+
276+
const fallbackTextLines = [
277+
"Codex commands. Pick a category or type:",
278+
"",
279+
...verbs.map((v, i) => ` ${i + 1}. ${v.command}`),
280+
"",
281+
"Tap 'help' (or type /codex help) for the full list of typeable verbs",
282+
"including threads, mcp, binding, detach, skills, resume, bind, steer,",
283+
"model, diagnostics, compact, review, computer-use.",
284+
"",
285+
"Top-level shortcuts cover everyday operations: /status, /fast, /help, /stop, /models.",
286+
];
287+
288+
return {
289+
text: fallbackTextLines.join("\n"),
290+
presentation: buildPickerPresentation("Codex commands", "Pick a Codex subcommand:", verbs),
291+
};
292+
}
293+
294+
/** Sub-picker for `/codex fast menu` (on / off / status). */
295+
function buildCodexFastMenuReply(): PluginCommandResult {
296+
const modes = ["on", "off", "status"] as const;
297+
const buttons: CodexPickerButton[] = [
298+
...modes.map((mode) => ({ label: mode, command: `/codex fast ${mode}` })),
299+
{ label: "back", command: "/codex" },
300+
];
301+
const fallbackTextLines = [
302+
"Codex fast mode. Pick one or type /codex fast <mode>:",
303+
"",
304+
...modes.map((m, i) => ` ${i + 1}. /codex fast ${m}`),
305+
"",
306+
"Type '/codex' to go back to the main menu.",
307+
];
308+
return {
309+
text: fallbackTextLines.join("\n"),
310+
presentation: buildPickerPresentation("Codex fast mode", "Pick a Codex fast mode:", buttons),
311+
};
312+
}
313+
314+
/** Sub-picker for `/codex permissions menu` (default / yolo / status). */
315+
function buildCodexPermissionsMenuReply(): PluginCommandResult {
316+
const modes = ["default", "yolo", "status"] as const;
317+
const buttons: CodexPickerButton[] = [
318+
...modes.map((mode) => ({ label: mode, command: `/codex permissions ${mode}` })),
319+
{ label: "back", command: "/codex" },
320+
];
321+
const fallbackTextLines = [
322+
"Codex permissions. Pick one or type /codex permissions <mode>:",
323+
"",
324+
...modes.map((m, i) => ` ${i + 1}. /codex permissions ${m}`),
325+
"",
326+
"Type '/codex' to go back to the main menu.",
327+
];
328+
return {
329+
text: fallbackTextLines.join("\n"),
330+
presentation: buildPickerPresentation(
331+
"Codex permissions",
332+
"Pick a Codex permissions mode:",
333+
buttons,
334+
),
335+
};
336+
}
337+
338+
/** Sub-picker for `/codex computer-use menu` (status / install). */
339+
function buildCodexComputerUseMenuReply(): PluginCommandResult {
340+
const actions = ["status", "install"] as const;
341+
const buttons: CodexPickerButton[] = [
342+
...actions.map((action) => ({
343+
label: action,
344+
command: `/codex computer-use ${action}`,
345+
})),
346+
{ label: "back", command: "/codex" },
347+
];
348+
const fallbackTextLines = [
349+
"Codex computer-use. Pick one or type /codex computer-use <action>:",
350+
"",
351+
...actions.map((a, i) => ` ${i + 1}. /codex computer-use ${a}`),
352+
"",
353+
"Flag-driven invocations (--source, --marketplace-path, --marketplace) are not in the picker. Type '/codex computer-use' or read '/codex help' for the full surface.",
354+
"",
355+
"Type '/codex' to go back to the main menu.",
356+
];
357+
return {
358+
text: fallbackTextLines.join("\n"),
359+
presentation: buildPickerPresentation(
360+
"Codex computer-use",
361+
"Pick a Codex computer-use action:",
362+
buttons,
363+
),
364+
};
365+
}
366+
367+
/** Returns true when the rest-args are exactly `["menu"]` (case-insensitive). */
368+
function isMenuVerb(rest: readonly string[]): boolean {
369+
return rest.length === 1 && (rest[0] ?? "").trim().toLowerCase() === "menu";
370+
}
371+
243372
export async function handleCodexSubcommand(
244373
ctx: PluginCommandContext,
245374
options: { pluginConfig?: unknown; deps?: Partial<CodexCommandDeps> },
246375
): Promise<PluginCommandResult> {
247376
const deps: CodexCommandDeps = { ...defaultCodexCommandDeps, ...options.deps };
248-
const [subcommand = "status", ...rest] = splitArgs(ctx.args);
377+
const args = splitArgs(ctx.args);
378+
if (args.length === 0) {
379+
return buildCodexSubcommandPickerReply();
380+
}
381+
const [subcommand = "status", ...rest] = args;
249382
const normalized = subcommand.toLowerCase();
250383
if (normalized === "help") {
251384
return { text: buildHelp() };
@@ -321,9 +454,15 @@ export async function handleCodexSubcommand(
321454
return { text: await setConversationModel(deps, ctx, options.pluginConfig, rest) };
322455
}
323456
if (normalized === "fast") {
457+
if (isMenuVerb(rest)) {
458+
return buildCodexFastMenuReply();
459+
}
324460
return { text: await setConversationFastMode(deps, ctx, options.pluginConfig, rest) };
325461
}
326462
if (normalized === "permissions") {
463+
if (isMenuVerb(rest)) {
464+
return buildCodexPermissionsMenuReply();
465+
}
327466
return { text: await setConversationPermissions(deps, ctx, options.pluginConfig, rest) };
328467
}
329468
if (normalized === "compact") {
@@ -360,6 +499,9 @@ export async function handleCodexSubcommand(
360499
);
361500
}
362501
if (normalized === "computer-use" || normalized === "computeruse") {
502+
if (isMenuVerb(rest)) {
503+
return buildCodexComputerUseMenuReply();
504+
}
363505
return {
364506
text: await handleComputerUseCommand(deps, options.pluginConfig, rest),
365507
};

extensions/codex/src/command-plugins-management.test.ts

Lines changed: 56 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import type { PluginCommandContext } from "openclaw/plugin-sdk/plugin-entry";
1+
import type { PluginCommandContext, PluginCommandResult } from "openclaw/plugin-sdk/plugin-entry";
22
import { describe, expect, it } from "vitest";
33
import {
44
handleCodexPluginsSubcommand,
@@ -40,6 +40,14 @@ const fakeCtx: PluginCommandContext = {
4040
getCurrentConversationBinding: async () => null,
4141
};
4242

43+
function buttonValues(result: PluginCommandResult): string[] {
44+
const block = result.presentation?.blocks.find((candidate) => candidate.type === "buttons");
45+
if (!block || block.type !== "buttons") {
46+
throw new Error("expected button presentation");
47+
}
48+
return block.buttons.map((button) => button.value ?? "");
49+
}
50+
4351
describe("Codex /codex plugins subcommand", () => {
4452
it("lists a configured plugin with its enabled marker and explains the underlying file", async () => {
4553
const io = inMemoryIO({
@@ -72,6 +80,50 @@ describe("Codex /codex plugins subcommand", () => {
7280
expect(result.text).toContain("Global codexPlugins.enabled is off");
7381
});
7482

83+
it("renders the plugins menu as portable slash-command buttons", async () => {
84+
const io = inMemoryIO();
85+
86+
const result = await handleCodexPluginsSubcommand(fakeCtx, ["menu"], io);
87+
88+
expect(result.text).toContain("/codex plugins list");
89+
expect(buttonValues(result)).toEqual([
90+
"/codex plugins list",
91+
"/codex plugins enable",
92+
"/codex plugins disable",
93+
"/codex plugins help",
94+
"/codex",
95+
]);
96+
});
97+
98+
it("renders enable and disable target pickers from effective plugin state", async () => {
99+
const io = inMemoryIO({
100+
"google-calendar": {
101+
enabled: false,
102+
marketplaceName: "openai-curated",
103+
pluginName: "google-calendar",
104+
},
105+
notion: {
106+
enabled: true,
107+
marketplaceName: "openai-curated",
108+
pluginName: "notion",
109+
},
110+
});
111+
112+
const enableResult = await handleCodexPluginsSubcommand(fakeCtx, ["enable"], io);
113+
expect(enableResult.text).toContain("/codex plugins enable google-calendar");
114+
expect(buttonValues(enableResult)).toEqual([
115+
"/codex plugins enable google-calendar",
116+
"/codex plugins menu",
117+
]);
118+
119+
const disableResult = await handleCodexPluginsSubcommand(fakeCtx, ["disable"], io);
120+
expect(disableResult.text).toContain("/codex plugins disable notion");
121+
expect(buttonValues(disableResult)).toEqual([
122+
"/codex plugins disable notion",
123+
"/codex plugins menu",
124+
]);
125+
});
126+
75127
it("enables and disables a configured plugin and reflects the change in subsequent reads", async () => {
76128
const io = inMemoryIO({
77129
"google-calendar": {
@@ -149,18 +201,13 @@ describe("Codex /codex plugins subcommand", () => {
149201
expect(result.text).not.toContain("@ops");
150202
});
151203

152-
it("returns usage when list, enable, or disable receives the wrong arity", async () => {
204+
it("returns usage when list, menu, enable, or disable receives the wrong arity", async () => {
153205
const io = inMemoryIO();
154206
const listResult = await handleCodexPluginsSubcommand(fakeCtx, ["list", "chrome"], io);
155207
expect(listResult.text).toContain("Usage: /codex plugins list");
156208

157-
const result = await handleCodexPluginsSubcommand(fakeCtx, ["disable"], io);
158-
expect(result.text).toContain("Usage: /codex plugins disable <name>");
159-
expect(result.presentation).toBeUndefined();
160-
161-
const enableResult = await handleCodexPluginsSubcommand(fakeCtx, ["enable"], io);
162-
expect(enableResult.text).toContain("Usage: /codex plugins enable <name>");
163-
expect(enableResult.presentation).toBeUndefined();
209+
const menuResult = await handleCodexPluginsSubcommand(fakeCtx, ["menu", "extra"], io);
210+
expect(menuResult.text).toContain("Usage: /codex plugins menu");
164211

165212
const extraResult = await handleCodexPluginsSubcommand(
166213
fakeCtx,

0 commit comments

Comments
 (0)