diff --git a/openclaw.plugin.json b/openclaw.plugin.json index 2c928a0..f3b901d 100644 --- a/openclaw.plugin.json +++ b/openclaw.plugin.json @@ -53,6 +53,17 @@ }, "defaultServiceTier": { "type": "string" + }, + "verbose": { + "type": "boolean" + }, + "verboseMaxEvents": { + "type": "number", + "minimum": 1 + }, + "verboseFlushMs": { + "type": "number", + "minimum": 250 } } }, @@ -100,6 +111,18 @@ "defaultServiceTier": { "label": "Default Service Tier", "advanced": true + }, + "verbose": { + "label": "Verbose Progress", + "help": "Send short progress updates while Codex is reasoning, using tools, or running commands." + }, + "verboseMaxEvents": { + "label": "Verbose Max Events", + "advanced": true + }, + "verboseFlushMs": { + "label": "Verbose Flush Delay (ms)", + "advanced": true } } } diff --git a/src/client.test.ts b/src/client.test.ts index 1c77492..46d5760 100644 --- a/src/client.test.ts +++ b/src/client.test.ts @@ -200,6 +200,9 @@ describe("CodexAppServerClient.setThreadModel", () => { command: "codex", args: [], requestTimeoutMs: 1_000, + verbose: false, + verboseMaxEvents: 12, + verboseFlushMs: 2_500, }, { debug: vi.fn(), @@ -253,6 +256,9 @@ describe("CodexAppServerClient.setThreadPermissions", () => { command: "codex", args: [], requestTimeoutMs: 1_000, + verbose: false, + verboseMaxEvents: 12, + verboseFlushMs: 2_500, }, { debug: vi.fn(), @@ -320,6 +326,9 @@ describe("CodexAppServerClient.startReview", () => { command: "codex", args: [], requestTimeoutMs: 1_000, + verbose: false, + verboseMaxEvents: 12, + verboseFlushMs: 2_500, }, { debug: vi.fn(), diff --git a/src/client.ts b/src/client.ts index c0bf4ff..886361a 100644 --- a/src/client.ts +++ b/src/client.ts @@ -14,6 +14,7 @@ import { type CompactProgress, type CompactResult, type ContextUsageSnapshot, + type CodexProgressEvent, type CodexTurnInputItem, type ExperimentalFeatureSummary, type McpServerSummary, @@ -2026,6 +2027,84 @@ function extractAssistantNotificationText( return { mode: "ignore", text: "" }; } +function extractProgressEventFromItem(params: unknown): CodexProgressEvent | undefined { + const item = asRecord(asRecord(params)?.item) ?? asRecord(params); + if (!item) { + return undefined; + } + const itemId = pickString(item, ["id", "itemId", "item_id"]); + const itemType = pickString(item, ["type"])?.trim(); + const normalizedType = itemType?.toLowerCase(); + const keyPrefix = itemId || normalizedType || "item"; + switch (normalizedType) { + case "reasoning": + return { label: "Reasoning", key: `reasoning:${keyPrefix}` }; + case "commandexecution": + return { label: "Command", key: `command:${keyPrefix}` }; + case "mcptoolcall": { + return { label: "Tool", key: `mcp:${keyPrefix}` }; + } + case "dynamictoolcall": { + return { label: "Tool", key: `dynamic:${keyPrefix}` }; + } + case "collabagenttoolcall": { + return { label: "Agent", key: `agent:${keyPrefix}` }; + } + case "websearch": { + return { label: "Web search", key: `web:${keyPrefix}` }; + } + case "filechange": + return { label: "File edit", key: `file:${keyPrefix}` }; + case "imageview": + return { label: "Image view", key: `image-view:${keyPrefix}` }; + case "imagegeneration": + return { label: "Image generation", key: `image-generation:${keyPrefix}` }; + case "contextcompaction": + return { label: "Compacting context", key: `compact:${keyPrefix}` }; + default: + return undefined; + } +} + +function extractProgressEventFromNotification( + methodLower: string, + params: unknown, +): CodexProgressEvent | undefined { + if (methodLower === "turn/started") { + return { label: "Working", key: "turn:started" }; + } + if (methodLower === "item/started") { + return extractProgressEventFromItem(params); + } + if ( + methodLower === "item/reasoning/textdelta" || + methodLower === "item/reasoning/summarytextdelta" || + methodLower === "item/reasoning/summarypartadded" + ) { + const ids = extractIds(params); + return { label: "Reasoning", key: `reasoning:${ids.itemId ?? ids.runId ?? "delta"}` }; + } + if (methodLower === "item/mcptoolcall/progress") { + const ids = extractIds(params); + return { + label: "Tool", + key: `mcp-progress:${ids.itemId ?? ids.runId ?? "progress"}`, + }; + } + if (methodLower === "command/exec/outputdelta") { + return { label: "Command output", key: "command-output" }; + } + if (methodLower === "item/commandexecution/outputdelta") { + const ids = extractIds(params); + return { label: "Command output", key: `command-output:${ids.itemId ?? ids.runId ?? "item"}` }; + } + if (methodLower === "turn/plan/updated" || methodLower === "item/plan/delta") { + const ids = extractIds(params); + return { label: "Planning", key: `planning:${ids.itemId ?? ids.runId ?? "turn"}` }; + } + return undefined; +} + function extractPlanDeltaNotification(value: unknown): { itemId?: string; delta: string } { return { itemId: extractAssistantItemId(value), @@ -3263,6 +3342,7 @@ export class CodexAppServerClient { collaborationMode?: CollaborationMode; onPendingInput?: (state: PendingInputState | null) => Promise | void; onFileEdits?: (text: string) => Promise | void; + onProgress?: (event: CodexProgressEvent) => Promise | void; onInterrupted?: () => Promise | void; }): ActiveCodexRun { let threadId = params.existingThreadId?.trim() || ""; @@ -3284,6 +3364,18 @@ export class CodexAppServerClient { let terminalError: TurnTerminalError | undefined; let approvalCancelled = false; let notificationQueue = Promise.resolve(); + let lastProgressKey = ""; + const emitProgress = async (event: CodexProgressEvent | undefined) => { + if (!event || !params.onProgress) { + return; + } + const key = event.key ?? `${event.label}:${event.detail ?? ""}`; + if (key === lastProgressKey) { + return; + } + lastProgressKey = key; + await params.onProgress(event); + }; const pendingInputCoordinator = createPendingInputCoordinator({ onPendingInput: params.onPendingInput, onActivated: () => { @@ -3321,6 +3413,7 @@ export class CodexAppServerClient { } threadId ||= ids.threadId ?? ""; turnId ||= ids.runId ?? ""; + await emitProgress(extractProgressEventFromNotification(methodLower, notificationParams)); const tokenUsage = extractThreadTokenUsageSnapshot(notificationParams); if (tokenUsage) { latestContextUsage = tokenUsage; diff --git a/src/commands.ts b/src/commands.ts index 3b21b06..0f28ede 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -13,6 +13,7 @@ export const COMMANDS = [ ["cas_fast", "Toggle or inspect fast mode for the current Codex binding."], ["cas_model", "List or switch the Codex model for the current binding."], ["cas_permissions", "Show Codex permissions and account status."], + ["cas_verbose", "Toggle or inspect verbose progress updates."], ["cas_init", "Forward /init to Codex."], ["cas_diff", "Forward /diff to Codex."], ["cas_rename", "Rename the Codex thread and optionally sync the conversation name."], diff --git a/src/config.ts b/src/config.ts index 5d1ab5f..ef03023 100644 --- a/src/config.ts +++ b/src/config.ts @@ -56,6 +56,23 @@ function readNumber( return fallback; } +function readBoolean(record: Record, key: string, fallback: boolean): boolean { + const value = record[key]; + if (typeof value === "boolean") { + return value; + } + if (typeof value === "string") { + const normalized = value.trim().toLowerCase(); + if (normalized === "true") { + return true; + } + if (normalized === "false") { + return false; + } + } + return fallback; +} + export function resolvePluginSettings(rawConfig: unknown): PluginSettings { const record = asRecord(rawConfig); const transport = record.transport === "websocket" ? "websocket" : "stdio"; @@ -82,6 +99,9 @@ export function resolvePluginSettings(rawConfig: unknown): PluginSettings { defaultWorkspaceDir: readString(record, "defaultWorkspaceDir"), defaultModel: readString(record, "defaultModel"), defaultServiceTier: readString(record, "defaultServiceTier"), + verbose: readBoolean(record, "verbose", false), + verboseMaxEvents: readNumber(record, "verboseMaxEvents", 12, 1), + verboseFlushMs: readNumber(record, "verboseFlushMs", 2_500, 250), }; } diff --git a/src/controller.test.ts b/src/controller.test.ts index af9973e..d98800a 100644 --- a/src/controller.test.ts +++ b/src/controller.test.ts @@ -23,10 +23,6 @@ const discordSdkState = vi.hoisted(() => ({ resolveDiscordAccount: vi.fn(() => ({ accountId: "default" })), })); -const telegramSdkState = vi.hoisted(() => ({ - resolveTelegramAccount: vi.fn(() => ({ accountId: "default", token: "telegram-token" })), -})); - vi.mock("openclaw/plugin-sdk/discord", () => ({ buildDiscordComponentMessage: discordSdkState.buildDiscordComponentMessage, editDiscordComponentMessage: discordSdkState.editDiscordComponentMessage, @@ -34,10 +30,6 @@ vi.mock("openclaw/plugin-sdk/discord", () => ({ resolveDiscordAccount: discordSdkState.resolveDiscordAccount, })); -vi.mock("openclaw/plugin-sdk/telegram-account", () => ({ - resolveTelegramAccount: telegramSdkState.resolveTelegramAccount, -})); - function makeStateDir(): string { return fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-app-server-test-")); } @@ -552,7 +544,6 @@ beforeEach(() => { discordSdkState.editDiscordComponentMessage.mockClear(); discordSdkState.registerBuiltDiscordComponentMessage.mockClear(); discordSdkState.resolveDiscordAccount.mockClear(); - telegramSdkState.resolveTelegramAccount.mockClear(); vi.spyOn(CodexAppServerClient.prototype, "logStartupProbe").mockResolvedValue(); vi.stubGlobal( "fetch", @@ -756,6 +747,28 @@ describe("Discord controller flows", () => { expect(planUsage).toEqual({ text: "Usage: /cas_plan | /cas_plan off" }); }); + it("toggles verbose progress through cas_verbose", async () => { + const { controller } = await createControllerHarness(); + + const enabled = await controller.handleCommand("cas_verbose", buildTelegramCommandContext({ + args: "on", + commandBody: "/cas_verbose on", + })); + const status = await controller.handleCommand("cas_verbose", buildTelegramCommandContext({ + args: "status", + commandBody: "/cas_verbose status", + })); + const disabled = await controller.handleCommand("cas_verbose", buildTelegramCommandContext({ + args: "off", + commandBody: "/cas_verbose off", + })); + + expect(enabled).toEqual({ text: "Codex verbose progress is on." }); + expect(status).toEqual({ text: "Codex verbose progress is on. Source: runtime override." }); + expect(disabled).toEqual({ text: "Codex verbose progress is off." }); + expect((controller as any).store.getVerboseOverride()).toBe(false); + }); + it("offers a New button on /cas_resume and flips into the new-thread project picker", async () => { const { controller } = await createControllerHarness(); diff --git a/src/controller.ts b/src/controller.ts index 4ba6fb1..031cb67 100644 --- a/src/controller.ts +++ b/src/controller.ts @@ -53,6 +53,7 @@ import { formatCommandUsage, renderCommandHelpText } from "./help.js"; import type { AccountSummary, CollaborationMode, + CodexProgressEvent, CodexTurnInputItem, ConversationPreferences, InteractiveMessageRef, @@ -219,6 +220,28 @@ type TelegramOutboundAdapter = { }) => Promise<{ messageId: string; chatId?: string }>; }; +type TelegramSendRuntimeModule = { + editMessageTelegram?: ( + chatId: string, + messageId: string | number, + text: string, + opts?: { + cfg?: unknown; + accountId?: string; + textMode?: "markdown" | "html"; + linkPreview?: boolean; + }, + ) => Promise<{ messageId?: string; chatId?: string } | unknown>; + deleteMessageTelegram?: ( + chatId: string, + messageId: string | number, + opts?: { + cfg?: unknown; + accountId?: string; + }, + ) => Promise; +}; + type DiscordOutboundAdapter = { sendText?: (ctx: { cfg: unknown; @@ -1949,6 +1972,8 @@ export class CodexPluginController { binding, Boolean(currentBinding || binding), ); + case "cas_verbose": + return await this.handleVerboseCommand(args); case "cas_init": return await this.handlePromptAlias(conversation, binding, args, "/init"); case "cas_diff": @@ -1964,6 +1989,31 @@ export class CodexPluginController { return { text: renderCommandHelpText(commandName) }; } + private async handleVerboseCommand(args: string): Promise { + const normalized = args.trim().toLowerCase(); + const current = this.store.getVerboseOverride() ?? this.settings.verbose; + const formatStatus = (value: boolean) => + `Codex verbose progress is ${value ? "on" : "off"}.`; + if (!normalized || normalized === "toggle") { + const next = !current; + await this.store.setVerboseOverride(next); + return { text: formatStatus(next) }; + } + if (normalized === "status") { + const source = this.store.getVerboseOverride() === undefined ? "config default" : "runtime override"; + return { text: `${formatStatus(current)} Source: ${source}.` }; + } + if (normalized === "on" || normalized === "true" || normalized === "1") { + await this.store.setVerboseOverride(true); + return { text: formatStatus(true) }; + } + if (normalized === "off" || normalized === "false" || normalized === "0") { + await this.store.setVerboseOverride(false); + return { text: formatStatus(false) }; + } + return { text: `${formatCommandUsage("cas_verbose")}\nUse on, off, or status.` }; + } + private async handleStartNewThreadSelection( conversation: ConversationTarget | null, binding: StoredBinding | null, @@ -2623,6 +2673,15 @@ export class CodexPluginController { ): Promise { try { if (message.provider === "telegram") { + const runtime = await this.loadTelegramSendRuntime(); + if (typeof runtime?.editMessageTelegram === "function") { + await runtime.editMessageTelegram(message.chatId, message.messageId, statusCard.text, { + cfg: this.getOpenClawConfig(), + accountId: conversation.accountId, + textMode: "markdown", + }); + return true; + } const token = await this.resolveTelegramBotToken(conversation.accountId); if (!token) { return false; @@ -2665,6 +2724,100 @@ export class CodexPluginController { } } + private async updatePlainMessage( + conversation: ConversationTarget, + message: DeliveredMessageRef, + text: string, + ): Promise { + try { + if (message.provider === "telegram") { + const runtime = await this.loadTelegramSendRuntime(); + if (typeof runtime?.editMessageTelegram === "function") { + await runtime.editMessageTelegram(message.chatId, message.messageId, text, { + cfg: this.getOpenClawConfig(), + accountId: conversation.accountId, + textMode: "markdown", + }); + return true; + } + const token = await this.resolveTelegramBotToken(conversation.accountId); + if (!token) { + return false; + } + await this.callTelegramEditMessageApi(token, { + chat_id: message.chatId, + message_id: Number(message.messageId), + text, + }); + return true; + } + await this.editDiscordComponentMessage( + message.channelId, + message.messageId, + this.buildDiscordPickerSpec({ text, buttons: [] }), + { + accountId: conversation.accountId, + }, + ); + return true; + } catch (error) { + this.api.logger.debug?.( + `codex plain message update failed ${this.formatConversationForLog(conversation)} provider=${message.provider}: ${String(error)}`, + ); + return false; + } + } + + private async deletePlainMessage( + conversation: ConversationTarget, + message: DeliveredMessageRef, + ): Promise { + try { + if (message.provider === "telegram") { + const runtime = await this.loadTelegramSendRuntime(); + if (typeof runtime?.deleteMessageTelegram === "function") { + await runtime.deleteMessageTelegram(message.chatId, message.messageId, { + cfg: this.getOpenClawConfig(), + accountId: conversation.accountId, + }); + return true; + } + const token = await this.resolveTelegramBotToken(conversation.accountId); + if (!token) { + return false; + } + await this.callTelegramDeleteMessageApi(token, { + chat_id: message.chatId, + message_id: Number(message.messageId), + }); + return true; + } + const token = await this.resolveDiscordBotToken(conversation.accountId); + if (!token) { + return false; + } + await this.callDiscordDeleteMessageApi(token, message.channelId, message.messageId); + return true; + } catch (error) { + this.api.logger.debug?.( + `codex plain message delete failed ${this.formatConversationForLog(conversation)} provider=${message.provider}: ${String(error)}`, + ); + return false; + } + } + + private async replacePlainMessage( + conversation: ConversationTarget, + previous: DeliveredMessageRef | null, + text: string, + ): Promise { + const next = await this.sendTextWithDeliveryRef(conversation, text); + if (previous && next) { + await this.deletePlainMessage(conversation, previous).catch(() => false); + } + return next ?? previous; + } + private async buildModelPicker( conversation: ConversationTarget, binding: StoredBinding, @@ -3529,6 +3682,10 @@ export class CodexPluginController { } } const typing = await this.startTypingLease(params.conversation); + const verboseEnabled = this.store.getVerboseOverride() ?? this.settings.verbose; + const progressReporter = verboseEnabled + ? this.createVerboseProgressReporter(params.conversation) + : null; this.api.logger.debug?.( `codex turn starting app-server run ${this.formatConversationForLog(params.conversation)} typing=${typing ? "yes" : "no"} session=${params.binding?.sessionKey ?? ""} existingThread=${params.binding?.threadId ?? ""} profile=${profile} mode=${params.collaborationMode?.mode ?? "default"}`, ); @@ -3560,6 +3717,11 @@ export class CodexPluginController { onFileEdits: async (text) => { await this.sendText(params.conversation, text); }, + onProgress: progressReporter + ? async (event) => { + progressReporter.push(event); + } + : undefined, onInterrupted: async () => { this.api.logger.debug?.( `codex turn interrupted ${this.formatConversationForLog(params.conversation)}`, @@ -3643,6 +3805,7 @@ export class CodexPluginController { ); }) .finally(async () => { + await progressReporter?.stop(); typing?.stop(); this.activeRuns.delete(key); const pending = this.store.getPendingRequestByConversation(params.conversation); @@ -3656,6 +3819,97 @@ export class CodexPluginController { }); } + private createVerboseProgressReporter(conversation: ConversationTarget): { + push: (event: CodexProgressEvent) => void; + stop: () => Promise; + } { + const lines: string[] = []; + const maxEvents = Math.max(1, this.settings.verboseMaxEvents); + const flushMs = Math.max(250, this.settings.verboseFlushMs); + let timer: ReturnType | null = null; + let pending = false; + let stopped = false; + let delivered: DeliveredMessageRef | null = null; + let updateChain = Promise.resolve(); + let lastFlushedAt = 0; + + const formatEvent = (event: CodexProgressEvent): string => { + const label = event.label.trim(); + const detail = event.detail?.trim(); + return detail ? `${label}: ${detail}` : label; + }; + + const flush = async () => { + if (timer) { + clearTimeout(timer); + timer = null; + } + if (!pending || lines.length === 0) { + return; + } + pending = false; + const text = ["Working...", ...lines.slice(-maxEvents).map((line) => `- ${line}`)].join("\n"); + try { + if (delivered) { + const edited = await this.updatePlainMessage(conversation, delivered, text); + if (edited) { + return; + } + delivered = await this.replacePlainMessage(conversation, delivered, text); + return; + } + delivered = await this.sendTextWithDeliveryRef(conversation, text); + } catch (error) { + this.api.logger.debug?.( + `codex verbose progress send failed ${this.formatConversationForLog(conversation)}: ${String(error)}`, + ); + } finally { + lastFlushedAt = Date.now(); + } + }; + + const scheduleFlush = () => { + if (timer || stopped) { + return; + } + const delay = Math.max(0, flushMs - (Date.now() - lastFlushedAt)); + timer = setTimeout(() => { + updateChain = updateChain.then(flush).catch((error: unknown) => { + this.api.logger.debug?.( + `codex verbose progress flush failed ${this.formatConversationForLog(conversation)}: ${String(error)}`, + ); + }); + }, delay); + }; + + return { + push: (event) => { + if (stopped) { + return; + } + const line = formatEvent(event); + if (!line) { + return; + } + lines.push(line); + while (lines.length > maxEvents) { + lines.shift(); + } + pending = true; + scheduleFlush(); + }, + stop: async () => { + stopped = true; + await updateChain; + await flush(); + if (delivered) { + await this.deletePlainMessage(conversation, delivered).catch(() => false); + delivered = null; + } + }, + }; + } + private async describeTurnFailure(params: { sessionKey?: string; profile?: PermissionsMode; @@ -6631,6 +6885,7 @@ export class CodexPluginController { worktreeFolder: displayThreadState?.cwd?.trim() || binding?.workspaceDir || workspaceDir, contextUsage: binding?.contextUsage, planMode: bindingActive ? activeRun?.mode === "plan" : undefined, + verboseEnabled: this.store.getVerboseOverride() ?? this.settings.verbose, threadNote, permissionNote: pendingProfile && activeRun @@ -6964,6 +7219,53 @@ export class CodexPluginController { return (await loadAdapter("telegram")) as TelegramOutboundAdapter | undefined; } + private async loadTelegramSendRuntime(): Promise { + const candidates: string[] = []; + try { + const depsRoot = path.join(this.api.runtime.state.resolveStateDir(), "plugin-runtime-deps"); + const entries = await fs.readdir(depsRoot).catch(() => []); + for (const entry of entries) { + candidates.push( + path.join(depsRoot, entry, "dist/extensions/telegram/runtime-api.js"), + ); + } + } catch (error) { + this.api.logger.debug?.(`codex telegram runtime-deps scan unavailable: ${String(error)}`); + } + try { + const openClawEntrypointPath = resolveOpenClawEntrypointPath(); + candidates.push( + resolveCompatFallbackPath( + openClawEntrypointPath, + "dist/extensions/telegram/runtime-api.js", + ), + ); + } catch (error) { + this.api.logger.debug?.(`codex telegram global runtime path unavailable: ${String(error)}`); + } + let lastError: unknown; + for (const runtimePath of candidates) { + if (!existsSync(runtimePath)) { + continue; + } + try { + const runtime = (await import(pathToFileURL(runtimePath).href)) as TelegramSendRuntimeModule; + if ( + typeof runtime.editMessageTelegram === "function" || + typeof runtime.deleteMessageTelegram === "function" + ) { + return runtime; + } + } catch (error) { + lastError = error; + } + } + if (lastError) { + this.api.logger.debug?.(`codex telegram send runtime unavailable: ${String(lastError)}`); + } + return undefined; + } + private async loadDiscordOutboundAdapter(): Promise { const loadAdapter = this.api.runtime.channel.outbound?.loadAdapter; if (typeof loadAdapter !== "function") { @@ -7536,6 +7838,27 @@ export class CodexPluginController { } } + private async callDiscordDeleteMessageApi( + token: string, + channelId: string, + messageId: string, + ): Promise { + const response = await fetch( + `https://discord.com/api/v10/channels/${encodeURIComponent(channelId)}/messages/${encodeURIComponent(messageId)}`, + { + method: "DELETE", + headers: { + Authorization: `Bot ${token}`, + }, + }, + ); + if (!response.ok && response.status !== 404) { + throw new Error( + `Discord deleteMessage failed status=${response.status} body=${await response.text()}`, + ); + } + } + private async callTelegramBotApi( method: string, token: string, @@ -7590,6 +7913,13 @@ export class CodexPluginController { await this.callTelegramBotApi("editMessageText", token, body); } + private async callTelegramDeleteMessageApi( + token: string, + body: Record, + ): Promise { + await this.callTelegramBotApi("deleteMessage", token, body); + } + private async callTelegramTopicEditApi( token: string, body: Record, diff --git a/src/format.test.ts b/src/format.test.ts index dd10d75..b05e03d 100644 --- a/src/format.test.ts +++ b/src/format.test.ts @@ -171,6 +171,7 @@ describe("formatCodexStatusText", () => { projectFolder: TEST_PROJECT_PATH, worktreeFolder: TEST_WORKTREE_PATH, planMode: false, + verboseEnabled: true, rateLimits: [ { name: "5h limit", @@ -194,6 +195,8 @@ describe("formatCodexStatusText", () => { expect(text).toContain(`Worktree folder: ${shortenHomePathForTest(TEST_WORKTREE_PATH)}`); expect(text).toContain("Fast mode: off"); expect(text).toContain("Plan mode: off"); + expect(text).toContain("Verbose: on"); + expect(text.indexOf("Plan mode: off")).toBeLessThan(text.indexOf("Verbose: on")); expect(text).toContain("Context usage: unavailable until Codex emits a token-usage update"); expect(text).toContain("Permissions: Default"); expect(text).toContain(`Account: ${TEST_MASKED_EMAIL} (pro)`); diff --git a/src/format.ts b/src/format.ts index 41c34f4..d3599ab 100644 --- a/src/format.ts +++ b/src/format.ts @@ -534,6 +534,7 @@ export function formatCodexStatusText(params: { bindingActive?: boolean; contextUsage?: ContextUsageSnapshot; planMode?: boolean; + verboseEnabled?: boolean; permissionNote?: string; threadNote?: string; }): string { @@ -561,6 +562,9 @@ export function formatCodexStatusText(params: { if (params.bindingActive && params.planMode !== undefined) { lines.push(`Plan mode: ${params.planMode ? "on" : "off"}`); } + if (params.verboseEnabled !== undefined) { + lines.push(`Verbose: ${params.verboseEnabled ? "on" : "off"}`); + } const contextUsageText = formatCodexContextUsageSnapshot(params.contextUsage); if (contextUsageText) { lines.push(`Context usage: ${contextUsageText}`); diff --git a/src/help.ts b/src/help.ts index 8a269d4..523c5ac 100644 --- a/src/help.ts +++ b/src/help.ts @@ -153,6 +153,22 @@ export const COMMAND_HELP: Record = { examples: ["/cas_permissions"], notes: "This shows account and permission status. To change permissions, use /cas_status --yolo or the status card toggle.", }, + cas_verbose: { + summary: COMMAND_SUMMARY.cas_verbose, + usage: "/cas_verbose [on|off|status]", + flags: [ + { flag: "on", description: "Enable temporary verbose progress messages." }, + { flag: "off", description: "Disable temporary verbose progress messages." }, + { flag: "status", description: "Show the current verbose state." }, + ], + examples: [ + "/cas_verbose", + "/cas_verbose on", + "/cas_verbose off", + "/cas_verbose status", + ], + notes: "With no argument, this command toggles the runtime override. The plugin config remains the default.", + }, cas_init: { summary: COMMAND_SUMMARY.cas_init, usage: "/cas_init [args]", diff --git a/src/state.ts b/src/state.ts index 8cdc282..922d6fa 100644 --- a/src/state.ts +++ b/src/state.ts @@ -203,6 +203,7 @@ function toConversationKey(target: ConversationTarget): string { function cloneSnapshot(value?: Partial): StoreSnapshot { return { version: STORE_VERSION, + verbose: value?.verbose, bindings: value?.bindings ?? [], pendingBinds: value?.pendingBinds ?? [], pendingRequests: value?.pendingRequests ?? [], @@ -349,6 +350,15 @@ export class PluginStateStore { return [...this.snapshot.bindings]; } + getVerboseOverride(): boolean | undefined { + return typeof this.snapshot.verbose === "boolean" ? this.snapshot.verbose : undefined; + } + + async setVerboseOverride(value: boolean): Promise { + this.snapshot.verbose = value; + await this.save(); + } + getBinding(target: ConversationTarget): StoredBinding | null { const key = toConversationKey(target); return this.snapshot.bindings.find((entry) => toConversationKey(entry.conversation) === key) ?? null; diff --git a/src/types.ts b/src/types.ts index f6e161f..bf60daa 100644 --- a/src/types.ts +++ b/src/types.ts @@ -22,6 +22,9 @@ export type PluginSettings = { defaultWorkspaceDir?: string; defaultModel?: string; defaultServiceTier?: string; + verbose: boolean; + verboseMaxEvents: number; + verboseFlushMs: number; }; export type CodexPlanStep = { @@ -245,6 +248,12 @@ export type TurnTerminalError = { httpStatusCode?: number; }; +export type CodexProgressEvent = { + label: string; + detail?: string; + key?: string; +}; + export type CodexTurnInputItem = | { type: "text"; @@ -562,6 +571,7 @@ export type CallbackAction = export type StoreSnapshot = { version: number; + verbose?: boolean; bindings: StoredBinding[]; pendingBinds: StoredPendingBind[]; pendingRequests: StoredPendingRequest[];