From 4bf86b1839cb3a96010d535dadab35f48f820fcb Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Tue, 12 May 2026 05:13:23 -0400 Subject: [PATCH 01/18] ship: prepare lane for review --- README.md | 6 + apps/ade-cli/README.md | 5 + apps/ade-cli/package-lock.json | 172 +++ apps/ade-cli/package.json | 1 + apps/ade-cli/src/cli.ts | 37 +- .../src/tuiClient/__tests__/adeApi.test.ts | 47 +- .../src/tuiClient/__tests__/commands.test.ts | 30 + .../__tests__/drawerSelection.test.ts | 47 + .../src/tuiClient/__tests__/format.test.ts | 362 +++++ apps/ade-cli/src/tuiClient/adeApi.ts | 50 +- apps/ade-cli/src/tuiClient/app.tsx | 151 ++- apps/ade-cli/src/tuiClient/commands.ts | 3 +- .../src/tuiClient/components/ModelStatus.tsx | 6 +- apps/ade-cli/src/tuiClient/drawerSelection.ts | 34 + apps/ade-cli/src/tuiClient/format.ts | 201 ++- apps/ade-cli/src/tuiClient/theme.ts | 15 + apps/desktop/package-lock.json | 123 ++ apps/desktop/package.json | 8 + apps/desktop/pnpm-workspace.yaml | 11 + .../scripts/prepare-universal-mac-inputs.mjs | 25 + .../scripts/runtimeBinaryPermissions.cjs | 12 +- apps/desktop/src/main/main.ts | 12 +- apps/desktop/src/main/packagedRuntimeSmoke.ts | 4 +- .../main/services/ai/codexExecutable.test.ts | 52 +- .../src/main/services/ai/codexExecutable.ts | 123 +- .../services/chat/agentChatService.test.ts | 619 ++++++++- .../main/services/chat/agentChatService.ts | 743 +++++++++- .../services/chat/chatTextBatching.test.ts | 37 +- .../main/services/chat/chatTextBatching.ts | 3 +- .../services/chat/codexCliLauncher.test.ts | 157 +++ .../main/services/chat/codexCliLauncher.ts | 146 ++ .../src/main/services/ipc/registerIpc.ts | 52 + .../localRuntimeConnectionPool.test.ts | 6 +- apps/desktop/src/preload/global.d.ts | 7 + apps/desktop/src/preload/preload.ts | 8 + apps/desktop/src/renderer/browserMock.ts | 9 + .../src/renderer/components/app/App.tsx | 2 +- .../components/app/App.workKeepAlive.test.tsx | 20 +- .../src/renderer/components/app/AppShell.tsx | 10 +- .../components/app/CommandPalette.test.tsx | 14 +- .../components/chat/AgentChatComposer.tsx | 102 +- .../components/chat/AgentChatMessageList.tsx | 166 ++- .../components/chat/AgentChatPane.tsx | 81 +- .../chat/ChatAttachmentTray.test.tsx | 18 + .../components/chat/ChatAttachmentTray.tsx | 74 +- .../components/chat/chatTranscriptRows.ts | 53 +- .../chat/codex/CodexContextCompactionChip.tsx | 46 + .../chat/codex/CodexGoalBanner.test.tsx | 82 ++ .../components/chat/codex/CodexGoalBanner.tsx | 199 +++ .../codex/CodexImageGenerationCard.test.tsx | 83 ++ .../chat/codex/CodexImageGenerationCard.tsx | 146 ++ .../chat/codex/CodexImageViewLine.tsx | 79 ++ .../chat/codex/CodexOpenInCliButton.tsx | 164 +++ .../components/chat/codex/CodexPlanCard.tsx | 124 ++ .../chat/codex/CodexTokenInline.tsx | 90 ++ .../renderer/components/lanes/LanesPage.tsx | 62 +- .../components/terminals/WorkStartSurface.tsx | 13 +- .../src/renderer/state/appStore.test.ts | 20 + apps/desktop/src/renderer/state/appStore.ts | 25 +- apps/desktop/src/shared/ipc.ts | 1 + apps/desktop/src/shared/types/chat.test.ts | 13 + apps/desktop/src/shared/types/chat.ts | 127 +- docs/codex migration plan.md | 1197 +++++++++++++++++ docs/codex-cli-passthrough-audit.md | 102 ++ plans/ade-32-codex-followup.md | 542 ++++++++ plans/ade-32-codex-v130-chat-parity.md | 228 ++++ scripts/dev-code.mjs | 18 +- scripts/dev-desktop.mjs | 2 +- scripts/dev-shared.mjs | 42 +- 69 files changed, 6935 insertions(+), 334 deletions(-) create mode 100644 apps/ade-cli/src/tuiClient/__tests__/drawerSelection.test.ts create mode 100644 apps/ade-cli/src/tuiClient/drawerSelection.ts create mode 100644 apps/desktop/pnpm-workspace.yaml create mode 100644 apps/desktop/src/main/services/chat/codexCliLauncher.test.ts create mode 100644 apps/desktop/src/main/services/chat/codexCliLauncher.ts create mode 100644 apps/desktop/src/renderer/components/chat/codex/CodexContextCompactionChip.tsx create mode 100644 apps/desktop/src/renderer/components/chat/codex/CodexGoalBanner.test.tsx create mode 100644 apps/desktop/src/renderer/components/chat/codex/CodexGoalBanner.tsx create mode 100644 apps/desktop/src/renderer/components/chat/codex/CodexImageGenerationCard.test.tsx create mode 100644 apps/desktop/src/renderer/components/chat/codex/CodexImageGenerationCard.tsx create mode 100644 apps/desktop/src/renderer/components/chat/codex/CodexImageViewLine.tsx create mode 100644 apps/desktop/src/renderer/components/chat/codex/CodexOpenInCliButton.tsx create mode 100644 apps/desktop/src/renderer/components/chat/codex/CodexPlanCard.tsx create mode 100644 apps/desktop/src/renderer/components/chat/codex/CodexTokenInline.tsx create mode 100644 docs/codex migration plan.md create mode 100644 docs/codex-cli-passthrough-audit.md create mode 100644 plans/ade-32-codex-followup.md create mode 100644 plans/ade-32-codex-v130-chat-parity.md diff --git a/README.md b/README.md index 17bb366a7..76ffca655 100644 --- a/README.md +++ b/README.md @@ -156,6 +156,12 @@ npm run dev That aliases to `npm run dev:desktop`: it rebuilds `apps/ade-cli`, launches the Electron desktop app, and points it at the dev runtime socket `/tmp/ade-runtime-dev.sock`. If no dev runtime is listening, desktop is allowed to create it. This is the normal desktop-dev flow. +When these commands are run from an ADE lane worktree under `.ade/worktrees/`, +they still run code from that lane checkout, but they open the primary checkout's +project data by default. For example, running from +`/path/to/ADE/.ade/worktrees/my-lane` opens `/path/to/ADE` as the ADE project +and uses the lane path as the workspace root for `dev:code`. + Dev command matrix: ```bash diff --git a/apps/ade-cli/README.md b/apps/ade-cli/README.md index 8a962894b..299264d07 100644 --- a/apps/ade-cli/README.md +++ b/apps/ade-cli/README.md @@ -325,6 +325,11 @@ The dev scripts are the same runtime daemon, just running from source against a /tmp/ade-runtime-dev.sock ``` +From an ADE lane checkout under `.ade/worktrees/`, the dev scripts keep using +that lane's source code, but default the ADE project root back to the primary +checkout. `npm run dev:code` also passes the lane checkout as the workspace root +so initial lane selection matches `ade code` launched directly from the lane. + Full matrix: ```bash diff --git a/apps/ade-cli/package-lock.json b/apps/ade-cli/package-lock.json index 08626f2c8..3eaaf95b7 100644 --- a/apps/ade-cli/package-lock.json +++ b/apps/ade-cli/package-lock.json @@ -12,6 +12,7 @@ "@anthropic-ai/claude-agent-sdk": "^0.2.119", "@cursor/sdk": "^1.0.9", "@linear/sdk": "^84.0.0", + "@openai/codex": "0.130.0", "@opencode-ai/sdk": "^1.4.2", "@wize-logic/nodejs-rfb": "^4.2.0", "bonjour-service": "^1.3.0", @@ -962,6 +963,128 @@ "node": ">=10" } }, + "node_modules/@openai/codex": { + "version": "0.130.0", + "resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.130.0.tgz", + "integrity": "sha512-WGDj+RZ3TXWC/7MlwprgLWOqzpwatPIINPhP3IRzHA0ni+o3QZ4i4xrS2uWwGmHUJ395J5JHwoZAAZYyfJyz6w==", + "license": "Apache-2.0", + "bin": { + "codex": "bin/codex.js" + }, + "engines": { + "node": ">=16" + }, + "optionalDependencies": { + "@openai/codex-darwin-arm64": "npm:@openai/codex@0.130.0-darwin-arm64", + "@openai/codex-darwin-x64": "npm:@openai/codex@0.130.0-darwin-x64", + "@openai/codex-linux-arm64": "npm:@openai/codex@0.130.0-linux-arm64", + "@openai/codex-linux-x64": "npm:@openai/codex@0.130.0-linux-x64", + "@openai/codex-win32-arm64": "npm:@openai/codex@0.130.0-win32-arm64", + "@openai/codex-win32-x64": "npm:@openai/codex@0.130.0-win32-x64" + } + }, + "node_modules/@openai/codex-darwin-arm64": { + "name": "@openai/codex", + "version": "0.130.0-darwin-arm64", + "resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.130.0-darwin-arm64.tgz", + "integrity": "sha512-R9pkGC7kwC8yQ8el5hvBlmugQlcsG/pHMEFgZluu03X9fD2TezGxdq3KqRDRCZuMYl07ILamVEoqknuJ0cq7MA==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@openai/codex-darwin-x64": { + "name": "@openai/codex", + "version": "0.130.0-darwin-x64", + "resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.130.0-darwin-x64.tgz", + "integrity": "sha512-gJ+7J8djevgtdra+NgDAiQQPW+O3KTsgGfE3E5dpDfww3zS5OCeV0V2dhxqnJdlOjOSDw99o0P2LqBv19mhpRw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@openai/codex-linux-arm64": { + "name": "@openai/codex", + "version": "0.130.0-linux-arm64", + "resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.130.0-linux-arm64.tgz", + "integrity": "sha512-tFtH0V9/hEI3d9y7zP92BXI9FM4Z3+STNQaOR52Czv18TRtCFUp7CbIUYaToopuq6UBfnE1VKr8RLhwT5FcbmA==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@openai/codex-linux-x64": { + "name": "@openai/codex", + "version": "0.130.0-linux-x64", + "resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.130.0-linux-x64.tgz", + "integrity": "sha512-3VcNlez99xdnEf+kB1IOpWv9fICYV9PiGj4sLCO4TCcShLnyxe+YBGa3poknkvXLnMG0qiN9SMnYS2FGrMxQcA==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@openai/codex-win32-arm64": { + "name": "@openai/codex", + "version": "0.130.0-win32-arm64", + "resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.130.0-win32-arm64.tgz", + "integrity": "sha512-vdpmiNp57L/arZabltLXn8TyEtNa7W1meOEkr+3R6W/8ZyBt++wuqz1Orv134OT2grrcFJsIVCAIPiqUxCvBkA==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@openai/codex-win32-x64": { + "name": "@openai/codex", + "version": "0.130.0-win32-x64", + "resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.130.0-win32-x64.tgz", + "integrity": "sha512-FzMznm7fr5/nbjZgOujZ9Y9AbdGm7ji1FOoWiY3U+srqauvZaTgn6o6aCheSL7kuymu7nTLOO/cAyWV6NuesqQ==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16" + } + }, "node_modules/@opencode-ai/sdk": { "version": "1.14.48", "resolved": "https://registry.npmjs.org/@opencode-ai/sdk/-/sdk-1.14.48.tgz", @@ -6755,6 +6878,55 @@ "rimraf": "^3.0.2" } }, + "@openai/codex": { + "version": "0.130.0", + "resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.130.0.tgz", + "integrity": "sha512-WGDj+RZ3TXWC/7MlwprgLWOqzpwatPIINPhP3IRzHA0ni+o3QZ4i4xrS2uWwGmHUJ395J5JHwoZAAZYyfJyz6w==", + "requires": { + "@openai/codex-darwin-arm64": "npm:@openai/codex@0.130.0-darwin-arm64", + "@openai/codex-darwin-x64": "npm:@openai/codex@0.130.0-darwin-x64", + "@openai/codex-linux-arm64": "npm:@openai/codex@0.130.0-linux-arm64", + "@openai/codex-linux-x64": "npm:@openai/codex@0.130.0-linux-x64", + "@openai/codex-win32-arm64": "npm:@openai/codex@0.130.0-win32-arm64", + "@openai/codex-win32-x64": "npm:@openai/codex@0.130.0-win32-x64" + } + }, + "@openai/codex-darwin-arm64": { + "version": "npm:@openai/codex@0.130.0-darwin-arm64", + "resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.130.0-darwin-arm64.tgz", + "integrity": "sha512-R9pkGC7kwC8yQ8el5hvBlmugQlcsG/pHMEFgZluu03X9fD2TezGxdq3KqRDRCZuMYl07ILamVEoqknuJ0cq7MA==", + "optional": true + }, + "@openai/codex-darwin-x64": { + "version": "npm:@openai/codex@0.130.0-darwin-x64", + "resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.130.0-darwin-x64.tgz", + "integrity": "sha512-gJ+7J8djevgtdra+NgDAiQQPW+O3KTsgGfE3E5dpDfww3zS5OCeV0V2dhxqnJdlOjOSDw99o0P2LqBv19mhpRw==", + "optional": true + }, + "@openai/codex-linux-arm64": { + "version": "npm:@openai/codex@0.130.0-linux-arm64", + "resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.130.0-linux-arm64.tgz", + "integrity": "sha512-tFtH0V9/hEI3d9y7zP92BXI9FM4Z3+STNQaOR52Czv18TRtCFUp7CbIUYaToopuq6UBfnE1VKr8RLhwT5FcbmA==", + "optional": true + }, + "@openai/codex-linux-x64": { + "version": "npm:@openai/codex@0.130.0-linux-x64", + "resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.130.0-linux-x64.tgz", + "integrity": "sha512-3VcNlez99xdnEf+kB1IOpWv9fICYV9PiGj4sLCO4TCcShLnyxe+YBGa3poknkvXLnMG0qiN9SMnYS2FGrMxQcA==", + "optional": true + }, + "@openai/codex-win32-arm64": { + "version": "npm:@openai/codex@0.130.0-win32-arm64", + "resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.130.0-win32-arm64.tgz", + "integrity": "sha512-vdpmiNp57L/arZabltLXn8TyEtNa7W1meOEkr+3R6W/8ZyBt++wuqz1Orv134OT2grrcFJsIVCAIPiqUxCvBkA==", + "optional": true + }, + "@openai/codex-win32-x64": { + "version": "npm:@openai/codex@0.130.0-win32-x64", + "resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.130.0-win32-x64.tgz", + "integrity": "sha512-FzMznm7fr5/nbjZgOujZ9Y9AbdGm7ji1FOoWiY3U+srqauvZaTgn6o6aCheSL7kuymu7nTLOO/cAyWV6NuesqQ==", + "optional": true + }, "@opencode-ai/sdk": { "version": "1.14.48", "resolved": "https://registry.npmjs.org/@opencode-ai/sdk/-/sdk-1.14.48.tgz", diff --git a/apps/ade-cli/package.json b/apps/ade-cli/package.json index 410e3b1f1..8aa68a217 100644 --- a/apps/ade-cli/package.json +++ b/apps/ade-cli/package.json @@ -28,6 +28,7 @@ "@anthropic-ai/claude-agent-sdk": "^0.2.119", "@cursor/sdk": "^1.0.9", "@linear/sdk": "^84.0.0", + "@openai/codex": "0.130.0", "@opencode-ai/sdk": "^1.4.2", "@wize-logic/nodejs-rfb": "^4.2.0", "bonjour-service": "^1.3.0", diff --git a/apps/ade-cli/src/cli.ts b/apps/ade-cli/src/cli.ts index bca89e4c9..15d32165b 100644 --- a/apps/ade-cli/src/cli.ts +++ b/apps/ade-cli/src/cli.ts @@ -173,10 +173,13 @@ type ReadinessCheck = { declare const __ADE_VERSION__: string | undefined; +const BUNDLED_VERSION = + typeof __ADE_VERSION__ === "string" ? __ADE_VERSION__.trim() : ""; +const ENV_VERSION = process.env.ADE_CLI_VERSION?.trim() ?? ""; const VERSION = - typeof __ADE_VERSION__ === "string" && __ADE_VERSION__.trim() - ? __ADE_VERSION__ - : process.env.ADE_CLI_VERSION?.trim() || "0.0.0"; + BUNDLED_VERSION && BUNDLED_VERSION !== "0.0.0" + ? BUNDLED_VERSION + : ENV_VERSION || BUNDLED_VERSION || "0.0.0"; const PROTOCOL_VERSION = "2025-06-18"; const SOURCE_FALLBACK_ENV = "ADE_CLI_SOURCE_FALLBACK_ACTIVE"; const CLI_ENTRY_PATH = @@ -5099,6 +5102,10 @@ function buildChatPlan(args: string[]): CliPlan { : standardRequested ? false : undefined; + // `--print` opts the session's app-server initialize handshake into + // print-mode (suppresses delta notification streams). Must be set at create + // time because the handshake runs once when the runtime starts. + const createRuntimeMode = readFlag(args, ["--print"]) ? "print" : undefined; return { kind: "execute", label: "chat create", @@ -5124,12 +5131,27 @@ function buildChatPlan(args: string[]): CliPlan { title: readValue(args, ["--title"]), surface: readValue(args, ["--surface"]) ?? "work", ...(codexFastMode !== undefined ? { codexFastMode } : {}), + ...(createRuntimeMode ? { runtimeMode: createRuntimeMode } : {}), }), ), ], }; } - if (sub === "send") + if (sub === "send") { + const imageUrl = readValue(args, ["--image-url"]); + // `--print` is honored at session create time only — the app-server + // initialize handshake runs once per session, so setting it per-message + // would be a silent no-op. Reject explicitly so users move it to + // `ade chat create --print`. + if (readFlag(args, ["--print"])) { + throw new CliUsageError( + "--print must be set at session creation time. Use `ade chat create --print ...`.", + ); + } + const sendText = requireValue( + readValue(args, ["--text", "--message"]) ?? args.join(" "), + "message text", + ); return { kind: "execute", label: "chat send", @@ -5140,14 +5162,13 @@ function buildChatPlan(args: string[]): CliPlan { "sendMessage", withSession({ sessionId: requireValue(sessionId, "sessionId"), - text: requireValue( - readValue(args, ["--text", "--message"]) ?? args.join(" "), - "message text", - ), + text: sendText, + ...(imageUrl ? { attachments: [{ type: "image-url", url: imageUrl, path: imageUrl }] } : {}), }), ), ], }; + } if (sub === "interrupt") return { kind: "execute", diff --git a/apps/ade-cli/src/tuiClient/__tests__/adeApi.test.ts b/apps/ade-cli/src/tuiClient/__tests__/adeApi.test.ts index 7d191c4c7..359ff864e 100644 --- a/apps/ade-cli/src/tuiClient/__tests__/adeApi.test.ts +++ b/apps/ade-cli/src/tuiClient/__tests__/adeApi.test.ts @@ -3,7 +3,7 @@ import os from "node:os"; import path from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; import type { AgentChatEventEnvelope } from "../../../../desktop/src/shared/types/chat"; -import { createChatSession, DEFAULT_CODEX_REASONING_EFFORT, discoverProjectSlashCommands, latestTokenStats, sendChatMessage } from "../adeApi"; +import { createChatSession, DEFAULT_CODEX_REASONING_EFFORT, discoverProjectSlashCommands, latestGoal, latestTokenStats, sendChatMessage } from "../adeApi"; import type { AdeCodeConnection } from "../types"; const tmpPaths: string[] = []; @@ -58,6 +58,7 @@ describe("latestTokenStats", () => { streaming: false, inputTokens: 2_100, outputTokens: 700, + cachedInputTokens: null, costUsd: 0.42, }); }); @@ -79,6 +80,7 @@ describe("latestTokenStats", () => { streaming: false, inputTokens: 40_000, outputTokens: 10_000, + cachedInputTokens: null, costUsd: 0.12, }); }); @@ -94,6 +96,49 @@ describe("latestTokenStats", () => { ]; expect(latestTokenStats(events).percent).toBeNull(); }); + + it("reads cachedInputTokens / cacheReadTokens from both tokens and codex_token_usage events", () => { + const events = [ + envelope(1, { + type: "tokens", + turnId: "turn-1", + inputTokens: 1_000, + outputTokens: 500, + cacheReadTokens: 450, + contextWindow: 10_000, + } as AgentChatEventEnvelope["event"]), + envelope(2, { + type: "codex_token_usage", + usage: { + last: { inputTokens: 2_300, outputTokens: 1_100, cacheReadTokens: 600 }, + modelContextWindow: 10_000, + }, + } as AgentChatEventEnvelope["event"]), + ]; + const stats = latestTokenStats(events); + expect(stats.cachedInputTokens).toBe(600); + expect(stats.inputTokens).toBe(2_300); + expect(stats.outputTokens).toBe(1_100); + }); +}); + +describe("latestGoal", () => { + it("tracks the most recent updated goal and respects clears", () => { + expect(latestGoal([ + envelope(1, { + type: "codex_goal_updated", + goal: { objective: "Refactor middleware", status: "active", tokensUsed: 100, tokenBudget: 5_000 }, + } as AgentChatEventEnvelope["event"]), + ])?.objective).toBe("Refactor middleware"); + + expect(latestGoal([ + envelope(1, { + type: "codex_goal_updated", + goal: { objective: "Old goal", status: "active" }, + } as AgentChatEventEnvelope["event"]), + envelope(2, { type: "codex_goal_cleared" } as AgentChatEventEnvelope["event"]), + ])).toBeNull(); + }); }); describe("discoverProjectSlashCommands", () => { diff --git a/apps/ade-cli/src/tuiClient/__tests__/commands.test.ts b/apps/ade-cli/src/tuiClient/__tests__/commands.test.ts index b90421961..ae2c8400f 100644 --- a/apps/ade-cli/src/tuiClient/__tests__/commands.test.ts +++ b/apps/ade-cli/src/tuiClient/__tests__/commands.test.ts @@ -24,6 +24,16 @@ describe("commands", () => { expect(parsed ? commandPlacement(parsed) : null).toBe("chat"); }); + it("routes Codex /fast arguments to chat", () => { + const parsed = parseCommand("/fast on", [ + { name: "/fast", description: "Toggle Fast mode for supported models", source: "sdk", argumentHint: "[on|off|status]" }, + ]); + expect(parsed?.name).toBe("/fast"); + expect(parsed?.args).toBe("on"); + expect(parsed?.userCommand?.name).toBe("/fast"); + expect(parsed ? commandPlacement(parsed) : null).toBe("chat"); + }); + it("lets runtime commands override single-word ADE built-ins on exact name", () => { const parsed = parseCommand("/status please", [ { name: "/status", description: "Runtime status", source: "sdk" }, @@ -127,4 +137,24 @@ describe("commands", () => { ]); expect(rows[0]?.name).toBe("/compact"); }); + + it("registers /compact and /goal as chat-placement builtins", () => { + const compact = parseCommand("/compact"); + expect(compact?.spec?.name).toBe("/compact"); + expect(compact ? commandPlacement(compact) : null).toBe("chat"); + + const goal = parseCommand("/goal Ship the migration"); + expect(goal?.spec?.name).toBe("/goal"); + expect(goal?.args).toBe("Ship the migration"); + expect(goal ? commandPlacement(goal) : null).toBe("chat"); + + const goalBudget = parseCommand("/goal budget 50000"); + expect(goalBudget?.spec?.name).toBe("/goal"); + expect(goalBudget?.args).toBe("budget 50000"); + }); + + it("drops the legacy /resume builtin", () => { + const rows = paletteCommands("/resume", []); + expect(rows.find((row) => row.name === "/resume" && row.source === "ade")).toBeUndefined(); + }); }); diff --git a/apps/ade-cli/src/tuiClient/__tests__/drawerSelection.test.ts b/apps/ade-cli/src/tuiClient/__tests__/drawerSelection.test.ts new file mode 100644 index 000000000..91eb5126a --- /dev/null +++ b/apps/ade-cli/src/tuiClient/__tests__/drawerSelection.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from "vitest"; +import type { AgentChatSessionSummary } from "../../../../desktop/src/shared/types/chat"; +import { resolveDrawerChatSelection } from "../drawerSelection"; + +function session(sessionId: string, laneId = "lane-1"): AgentChatSessionSummary { + return { + sessionId, + laneId, + provider: "codex", + model: "gpt-5.5", + status: "idle", + startedAt: "2026-01-01T00:00:00.000Z", + endedAt: null, + lastActivityAt: "2026-01-01T00:00:00.000Z", + lastOutputPreview: null, + summary: null, + }; +} + +describe("resolveDrawerChatSelection", () => { + it("does not snap a manually selected chat back to the draft new-chat row", () => { + expect(resolveDrawerChatSelection({ + activeLaneId: "lane-1", + activeSessionId: null, + draftChatActive: true, + drawerLaneId: "lane-1", + drawerVisibleLaneSessions: [session("chat-1"), session("chat-2")], + selectedDrawerChatAction: null, + selectedDrawerChatId: "chat-2", + })).toBeNull(); + }); + + it("selects the new-chat row for a draft only when no valid chat row is selected", () => { + expect(resolveDrawerChatSelection({ + activeLaneId: "lane-1", + activeSessionId: null, + draftChatActive: true, + drawerLaneId: "lane-1", + drawerVisibleLaneSessions: [session("chat-1")], + selectedDrawerChatAction: null, + selectedDrawerChatId: null, + })).toEqual({ + selectedDrawerChatId: null, + selectedDrawerChatAction: "new-chat", + }); + }); +}); diff --git a/apps/ade-cli/src/tuiClient/__tests__/format.test.ts b/apps/ade-cli/src/tuiClient/__tests__/format.test.ts index 18438c099..cd1c1f982 100644 --- a/apps/ade-cli/src/tuiClient/__tests__/format.test.ts +++ b/apps/ade-cli/src/tuiClient/__tests__/format.test.ts @@ -153,6 +153,368 @@ describe("renderChatLines", () => { ]); }); + it("renders Codex plan, web, and image events and suppresses goal/token chat rows", () => { + const lines = renderChatLines({ + activeSession: null, + notices: [], + events: [ + { + sessionId: "s1", + timestamp: "2026-01-01T12:00:00.000Z", + sequence: 1, + event: { + type: "plan", + steps: [{ text: "Inspect protocol", status: "completed" }], + state: "updated", + }, + }, + { + sessionId: "s1", + timestamp: "2026-01-01T12:00:01.000Z", + sequence: 2, + event: { + type: "web_search", + query: "Codex app server", + itemId: "web-1", + status: "completed", + actions: [ + { type: "search", query: "Codex app server" }, + { type: "openPage", url: "https://example.com/codex" }, + ], + }, + }, + { + sessionId: "s1", + timestamp: "2026-01-01T12:00:02.000Z", + sequence: 3, + event: { type: "codex_goal_updated", goal: { objective: "Ship the migration", status: "active" } }, + }, + { + sessionId: "s1", + timestamp: "2026-01-01T12:00:03.000Z", + sequence: 4, + event: { + type: "codex_image_generation", + itemId: "img-1", + prompt: "diagram", + status: "completed", + }, + }, + { + sessionId: "s1", + timestamp: "2026-01-01T12:00:04.000Z", + sequence: 5, + event: { + type: "codex_token_usage", + usage: { total: { totalTokens: 1200 }, last: { totalTokens: 120 }, modelContextWindow: 200000 }, + }, + }, + ], + }); + + const body = lines.map((line) => line.body).join("\n"); + expect(body).toContain("plan"); + // Plan glyphs are unicode now (◐/○/●), not ASCII. + expect(body).toContain("● Inspect protocol"); + // Web search renders actions per-line, not joined with ` · `. + expect(body).toContain("web Codex app server"); + expect(body).toMatch(/search\s+Codex app server/); + expect(body).toMatch(/openPage\s+https:\/\/example\.com\/codex/); + expect(body).toContain("image generated"); + // Goal/token-usage events are suppressed in the chat transcript. + expect(body).not.toContain("goal active"); + expect(body).not.toContain("tokens · last"); + }); + + it("renders the new event variants (status, error, done, todo, subagent, completion_report, turn_diff_summary, codex_context_compaction)", () => { + const lines = renderChatLines({ + activeSession: null, + notices: [], + events: [ + { + sessionId: "s1", + timestamp: "2026-01-01T12:00:00.000Z", + sequence: 1, + event: { type: "status", turnStatus: "completed" } as never, + }, + { + sessionId: "s1", + timestamp: "2026-01-01T12:00:01.000Z", + sequence: 2, + event: { type: "error", message: "rate limited" } as never, + }, + { + sessionId: "s1", + timestamp: "2026-01-01T12:00:02.000Z", + sequence: 3, + event: { + type: "done", + turnId: "t1", + status: "completed", + usage: { inputTokens: 1200, outputTokens: 500 }, + costUsd: 0.13, + } as never, + }, + { + sessionId: "s1", + timestamp: "2026-01-01T12:00:03.000Z", + sequence: 4, + event: { + type: "todo_update", + items: [ + { id: "1", description: "Read", status: "completed" }, + { id: "2", description: "Write", status: "in_progress" }, + ], + } as never, + }, + { + sessionId: "s1", + timestamp: "2026-01-01T12:00:04.000Z", + sequence: 5, + event: { type: "subagent_started", taskId: "ag", description: "do thing" } as never, + }, + { + sessionId: "s1", + timestamp: "2026-01-01T12:00:05.000Z", + sequence: 6, + event: { + type: "completion_report", + report: { timestamp: "x", summary: "shipped it", status: "completed", artifacts: [] }, + } as never, + }, + { + sessionId: "s1", + timestamp: "2026-01-01T12:00:06.000Z", + sequence: 7, + event: { + type: "turn_diff_summary", + turnId: "t", + beforeSha: "a", + afterSha: "b", + files: [{ path: "x" }, { path: "y" }] as never, + totalAdditions: 12, + totalDeletions: 4, + } as never, + }, + { + sessionId: "s1", + timestamp: "2026-01-01T12:00:07.000Z", + sequence: 8, + event: { + type: "codex_context_compaction", + turnId: "t", + state: "started", + trigger: "manual", + } as never, + }, + ], + }); + + const body = lines.map((line) => line.body).join("\n"); + expect(body).toContain("[status] completed"); + expect(body).toContain("[error] rate limited"); + expect(body).toMatch(/\[done\] completed/); + expect(body).toContain("todos"); + expect(body).toContain("● Read"); + expect(body).toContain("◐ Write"); + expect(body).toContain("[agent] do thing (started)"); + expect(body).toContain("[done] turn summary: shipped it"); + expect(body).toContain("[diff] +12/-4 across 2 files"); + expect(body).toContain("⟳ compacting · manual"); + }); + + it("renders cloud, step_boundary, structured_question, prompt_suggestion, auto_approval_review, tool_use_summary, and delegation_state lines", () => { + const lines = renderChatLines({ + activeSession: null, + notices: [], + events: [ + { + sessionId: "s1", + timestamp: "2026-01-01T12:00:00.000Z", + sequence: 1, + event: { + type: "cloud_artifact", + turnId: "t", + itemId: "i", + agentId: "a", + runId: "r", + path: "/tmp/out.txt", + lanePath: "/tmp/lane", + } as never, + }, + { + sessionId: "s1", + timestamp: "2026-01-01T12:00:01.000Z", + sequence: 2, + event: { + type: "cloud_status", + turnId: "t", + runId: "r", + status: "running", + detail: "spinning up", + } as never, + }, + { + sessionId: "s1", + timestamp: "2026-01-01T12:00:02.000Z", + sequence: 3, + event: { type: "step_boundary", stepNumber: 3 } as never, + }, + { + sessionId: "s1", + timestamp: "2026-01-01T12:00:03.000Z", + sequence: 4, + event: { + type: "structured_question", + question: "Approve refactor?", + itemId: "q1", + } as never, + }, + { + sessionId: "s1", + timestamp: "2026-01-01T12:00:04.000Z", + sequence: 5, + event: { type: "prompt_suggestion", suggestion: "try /compact" } as never, + }, + { + sessionId: "s1", + timestamp: "2026-01-01T12:00:05.000Z", + sequence: 6, + event: { + type: "auto_approval_review", + targetItemId: "x", + reviewStatus: "started", + action: "shell", + } as never, + }, + { + sessionId: "s1", + timestamp: "2026-01-01T12:00:06.000Z", + sequence: 7, + event: { type: "tool_use_summary", summary: "ran 4 tools", toolUseIds: [] } as never, + }, + { + sessionId: "s1", + timestamp: "2026-01-01T12:00:07.000Z", + sequence: 8, + event: { + type: "delegation_state", + message: "handoff to worker-a", + contract: { status: "active", workerIntent: "implement" } as never, + } as never, + }, + ], + }); + + const body = lines.map((line) => line.body).join("\n"); + expect(body).toContain("[cloud] artifact"); + expect(body).toContain("out.txt"); + expect(body).toContain("[cloud] running"); + expect(body).toContain("── step 3 ──"); + expect(body).toContain("[?] Approve refactor?"); + expect(body).toContain("💡 try /compact"); + expect(body).toMatch(/\[auto-approval\] started/); + expect(body).toContain("[tools] ran 4 tools"); + expect(body).toContain("[delegation] handoff to worker-a"); + }); + + it("suppresses pending_input_resolved and tokens events from the chat transcript", () => { + const lines = renderChatLines({ + activeSession: null, + notices: [], + events: [ + { + sessionId: "s1", + timestamp: "2026-01-01T12:00:00.000Z", + sequence: 1, + event: { + type: "pending_input_resolved", + itemId: "q1", + resolution: "accepted", + } as never, + }, + { + sessionId: "s1", + timestamp: "2026-01-01T12:00:01.000Z", + sequence: 2, + event: { + type: "tokens", + turnId: "t", + inputTokens: 100, + outputTokens: 50, + contextWindow: 10_000, + } as never, + }, + ], + }); + expect(lines).toHaveLength(0); + }); + + it("fixes the system_notice continue regression (does not duplicate subsequent rows)", () => { + const lines = renderChatLines({ + activeSession: null, + notices: [], + events: [ + { + sessionId: "s1", + timestamp: "2026-01-01T12:00:00.000Z", + sequence: 1, + event: { type: "system_notice", noticeKind: "info", message: "hi" } as never, + }, + { + sessionId: "s1", + timestamp: "2026-01-01T12:00:01.000Z", + sequence: 2, + event: { type: "text", text: "after notice" }, + }, + ], + }); + // Without the `continue;` fix the loop would fall through and a duplicate + // empty/error row could be appended. Assert the two-event input → two-line + // output, with the notice first and the text second. + expect(lines).toHaveLength(2); + expect(lines[0]?.tone).toBe("notice"); + expect(lines[1]?.tone).toBe("assistant"); + }); + + it("routes severity-bearing system_notice variants to tone=error in the TUI", () => { + const lines = renderChatLines({ + activeSession: null, + notices: [], + events: [ + { + sessionId: "s1", + timestamp: "2026-01-01T12:00:00.000Z", + sequence: 1, + event: { type: "system_notice", noticeKind: "error", message: "🛡 guardian: sandbox tripped" } as never, + }, + { + sessionId: "s1", + timestamp: "2026-01-01T12:00:01.000Z", + sequence: 2, + event: { type: "system_notice", noticeKind: "warning", message: "⚠ deprecated: old method" } as never, + }, + { + sessionId: "s1", + timestamp: "2026-01-01T12:00:02.000Z", + sequence: 3, + event: { type: "system_notice", noticeKind: "config", message: "⚙ config: stale layer" } as never, + }, + { + sessionId: "s1", + timestamp: "2026-01-01T12:00:03.000Z", + sequence: 4, + event: { type: "system_notice", noticeKind: "rate_limit", message: "rate limit hit" } as never, + }, + ], + }); + expect(lines).toHaveLength(4); + expect(lines[0]?.tone).toBe("error"); // error noticeKind + expect(lines[1]?.tone).toBe("notice"); // warning is informational + expect(lines[2]?.tone).toBe("notice"); // config is informational + expect(lines[3]?.tone).toBe("error"); // rate_limit is severity-bearing + }); + it("summarizes command pass and fail counts when present", () => { const events = [{ sessionId: "s1", diff --git a/apps/ade-cli/src/tuiClient/adeApi.ts b/apps/ade-cli/src/tuiClient/adeApi.ts index 36d20dd16..bb69fb25d 100644 --- a/apps/ade-cli/src/tuiClient/adeApi.ts +++ b/apps/ade-cli/src/tuiClient/adeApi.ts @@ -22,6 +22,7 @@ import type { AgentChatSession, AgentChatSessionSummary, AgentChatSlashCommand, + CodexThreadGoal, } from "../../../desktop/src/shared/types/chat"; import type { AiSettingsStatus, OpenCodeRuntimeSnapshot } from "../../../desktop/src/shared/types/config"; import type { LaneSummary } from "../../../desktop/src/shared/types/lanes"; @@ -280,6 +281,8 @@ export type TokenStats = { streaming: boolean; inputTokens: number | null; outputTokens: number | null; + /** Last-turn input tokens read from cache (Codex `cachedInputTokens` / `cacheReadTokens`). */ + cachedInputTokens: number | null; costUsd: number | null; }; @@ -291,6 +294,7 @@ export function latestTokenStats( let streaming = false; let inputTokens: number | null = null; let outputTokens: number | null = null; + let cachedInputTokens: number | null = null; let costUsd: number | null = null; let eventLimit: number | null = null; for (const envelope of events) { @@ -300,12 +304,38 @@ export function latestTokenStats( if (event.type === "tokens") { inputTokens = typeof event.inputTokens === "number" ? event.inputTokens : inputTokens; outputTokens = typeof event.outputTokens === "number" ? event.outputTokens : outputTokens; + if (typeof event.cacheReadTokens === "number") cachedInputTokens = event.cacheReadTokens; if (typeof event.contextWindow === "number") eventLimit = event.contextWindow; } + if (event.type === "codex_token_usage") { + const usage = event.usage && typeof event.usage === "object" ? event.usage as Record : null; + const total = usage?.total && typeof usage.total === "object" ? usage.total as Record : null; + const last = usage?.last && typeof usage.last === "object" ? usage.last as Record : null; + inputTokens = typeof last?.inputTokens === "number" + ? last.inputTokens + : typeof total?.inputTokens === "number" ? total.inputTokens : inputTokens; + outputTokens = typeof last?.outputTokens === "number" + ? last.outputTokens + : typeof total?.outputTokens === "number" ? total.outputTokens : outputTokens; + // Codex passes cached read tokens as either cacheReadTokens (camelCase) or + // cachedInputTokens (snake-cased upstream variant aliased through). Prefer + // last-turn reading over total. + const readFromBucket = (bucket: Record | null): number | null => { + if (!bucket) return null; + if (typeof bucket.cacheReadTokens === "number") return bucket.cacheReadTokens; + if (typeof (bucket as { cachedInputTokens?: unknown }).cachedInputTokens === "number") { + return (bucket as { cachedInputTokens: number }).cachedInputTokens; + } + return null; + }; + cachedInputTokens = readFromBucket(last) ?? readFromBucket(total) ?? cachedInputTokens; + if (typeof usage?.modelContextWindow === "number") eventLimit = usage.modelContextWindow; + } if (event.type === "done") { const usage = event.usage && typeof event.usage === "object" ? event.usage as Record : null; inputTokens = typeof usage?.inputTokens === "number" ? usage.inputTokens : inputTokens; outputTokens = typeof usage?.outputTokens === "number" ? usage.outputTokens : outputTokens; + if (typeof usage?.cacheReadTokens === "number") cachedInputTokens = usage.cacheReadTokens; costUsd = typeof event.costUsd === "number" ? event.costUsd : costUsd; } } @@ -314,5 +344,23 @@ export function latestTokenStats( if (used != null && limit != null && limit > 0) { percent = Math.max(0, Math.min(100, Math.round((used / limit) * 100))); } - return { percent, streaming, inputTokens, outputTokens, costUsd }; + return { percent, streaming, inputTokens, outputTokens, cachedInputTokens, costUsd }; +} + +/** + * Walk the event stream and return the most recently observed Codex goal. + * Returns `null` when no goal has been set or the latest event is a clear. + */ +export function latestGoal(events: AgentChatEventEnvelope[]): CodexThreadGoal | null { + let goal: CodexThreadGoal | null = null; + for (const envelope of events) { + const event = envelope.event as Record; + if (event.type === "codex_goal_updated") { + const next = (event as { goal?: CodexThreadGoal | null }).goal ?? null; + goal = next ?? null; + } else if (event.type === "codex_goal_cleared") { + goal = null; + } + } + return goal; } diff --git a/apps/ade-cli/src/tuiClient/app.tsx b/apps/ade-cli/src/tuiClient/app.tsx index dc3d619ea..1c130b41a 100644 --- a/apps/ade-cli/src/tuiClient/app.tsx +++ b/apps/ade-cli/src/tuiClient/app.tsx @@ -20,6 +20,7 @@ import type { AgentChatPermissionMode, AgentChatSessionSummary, AgentChatSlashCommand, + CodexThreadGoal, } from "../../../desktop/src/shared/types/chat"; import type { AiSettingsStatus, OpenCodeRuntimeSnapshot } from "../../../desktop/src/shared/types/config"; import type { LaneSummary } from "../../../desktop/src/shared/types/lanes"; @@ -35,6 +36,7 @@ import { getSlashCommands, getStoredApiKeyProviders, interruptChat, + latestGoal, latestTokenStats, listChatSessions, listLanes, @@ -42,7 +44,6 @@ import { newestSession, renameChat, respondToInput, - resumeChat, sendChatMessage, updateChatModel, } from "./adeApi"; @@ -59,6 +60,7 @@ import { ModelStatus } from "./components/ModelStatus"; import { FooterControls } from "./components/FooterControls"; import { theme } from "./theme"; import { chooseInitialLane } from "./project"; +import { resolveDrawerChatSelection } from "./drawerSelection"; import { latestExpandableFailureId, renderObject, summarizeDiffChanges } from "./format"; import { startTuiHeartbeat, type TuiHeartbeat } from "./heartbeat"; import { loadAdeCodeState, saveAdeCodeState } from "./state"; @@ -317,11 +319,39 @@ function compactNumber(value: number): string { } function formatTokenSummary(stats: ReturnType): string | null { + // Compact last-turn breakdown: `+2.3k/1.1k (450✶)` — input / output (cached marker). const parts: string[] = []; - if (stats.inputTokens != null) parts.push(`in ${compactNumber(stats.inputTokens)}`); - if (stats.outputTokens != null) parts.push(`out ${compactNumber(stats.outputTokens)}`); + if (stats.inputTokens != null || stats.outputTokens != null) { + const left = stats.inputTokens != null ? `+${compactNumber(stats.inputTokens)}` : "+0"; + const right = stats.outputTokens != null ? compactNumber(stats.outputTokens) : "0"; + parts.push(`${left}/${right}`); + } + if (stats.cachedInputTokens != null && stats.cachedInputTokens > 0) { + parts.push(`(${compactNumber(stats.cachedInputTokens)}✶)`); + } if (stats.costUsd != null) parts.push(`$${stats.costUsd.toFixed(2)}`); - return parts.length ? parts.join(" · ") : null; + return parts.length ? parts.join(" ") : null; +} + +function formatGoalBannerLine(goal: CodexThreadGoal | null): string | null { + if (!goal?.objective) return null; + const objective = goal.objective.trim(); + if (!objective) return null; + const right: string[] = []; + const used = goal.tokensUsed ?? null; + const budget = goal.tokenBudget ?? null; + if (used != null && budget != null) { + right.push(`${compactNumber(used)}/${compactNumber(budget)}`); + } else if (used != null) { + right.push(`${compactNumber(used)} tokens`); + } + if (typeof goal.timeUsedSeconds === "number" && goal.timeUsedSeconds > 0) { + const seconds = Math.round(goal.timeUsedSeconds); + const mins = Math.floor(seconds / 60); + right.push(mins > 0 ? `${mins}m ${seconds % 60}s` : `${seconds}s`); + } + if (goal.status) right.push(goal.status.replace(/_/g, " ")); + return right.length ? `◎ ${objective} ${right.join(" · ")}` : `◎ ${objective}`; } function buildSetupRows(args: { @@ -648,8 +678,8 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } const [formValues, setFormValues] = useState>({}); const [formFieldIndex, setFormFieldIndex] = useState(0); const [rightSelectionIndex, setRightSelectionIndex] = useState(0); - const [drawerOpen, setDrawerOpen] = useState(false); - const [rightOpen, setRightOpen] = useState(false); + const [drawerOpen, setDrawerOpen] = useState(true); + const [rightOpen, setRightOpen] = useState(true); const [activePane, setActivePane] = useState("chat"); const [prompt, setPrompt] = useState(""); const [error, setError] = useState(null); @@ -1065,15 +1095,18 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } }, [activeLaneId, drawerLaneId, drawerLaneRows, selectedDrawerLaneAction, selectedDrawerLaneId]); useEffect(() => { - if (selectedDrawerChatAction) return; - if (draftChatActive && drawerLaneId === activeLaneId) { - setSelectedDrawerChatId(null); - setSelectedDrawerChatAction("new-chat"); - return; - } - if (selectedDrawerChatId && drawerVisibleLaneSessions.some((session) => session.sessionId === selectedDrawerChatId)) return; - const activeChatInDrawer = drawerVisibleLaneSessions.find((session) => session.sessionId === activeSessionId); - setSelectedDrawerChatId(activeChatInDrawer?.sessionId ?? drawerVisibleLaneSessions[0]?.sessionId ?? null); + const next = resolveDrawerChatSelection({ + activeLaneId, + activeSessionId, + draftChatActive, + drawerLaneId, + drawerVisibleLaneSessions, + selectedDrawerChatAction, + selectedDrawerChatId, + }); + if (!next) return; + setSelectedDrawerChatId(next.selectedDrawerChatId); + setSelectedDrawerChatAction(next.selectedDrawerChatAction); }, [activeLaneId, activeSessionId, draftChatActive, drawerLaneId, drawerVisibleLaneSessions, selectedDrawerChatAction, selectedDrawerChatId]); useEffect(() => { @@ -1872,16 +1905,6 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } } return; } - if (name === "/resume") { - if (!sessionId) { - setRightPane({ kind: "details", title: "Resume", body: "No active chat is selected." }); - return; - } - await resumeChat(conn, sessionId); - addNotice("Resumed chat.", "success"); - await refreshState(); - return; - } if (name === "/model") { if (args) { if (!sessionId) { @@ -2207,6 +2230,39 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } } }, [addNotice, focusAfterDetails, refreshState, selectActiveLaneId, selectActiveSessionId]); + const openLatestImage = useCallback(() => { + let target: string | null = null; + for (const envelope of events) { + const event = envelope.event as Record; + if (event.type === "codex_image_generation") { + const candidate = (event as { result?: unknown }).result; + if (typeof candidate === "string" && candidate && !/^https?:|^data:/i.test(candidate)) { + target = candidate; + } + } + if (event.type === "codex_image_view") { + const local = (event as { path?: unknown }).path; + if (typeof local === "string" && local) target = local; + } + } + if (!target) { + addNotice("No image to open in the recent history.", "info"); + return; + } + try { + if (process.platform === "darwin") { + spawn("open", [target], { stdio: "ignore", detached: true }).unref(); + } else if (process.platform === "win32") { + spawn("cmd", ["/c", "start", "", target], { stdio: "ignore", detached: true }).unref(); + } else { + spawn("xdg-open", [target], { stdio: "ignore", detached: true }).unref(); + } + addNotice(`Opening ${path.basename(target)}…`, "info"); + } catch (err) { + addNotice(err instanceof Error ? err.message : String(err), "error"); + } + }, [addNotice, events]); + const submitPrompt = useCallback(async (value: string) => { const text = value.trim(); if (!text && rightPane.kind !== "form") return; @@ -2509,7 +2565,20 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } } if (key.ctrl && input === "o") { - focusDrawer(); + if (drawerOpen && pane === "drawer") { + setDrawerOpen(false); + focusChat(); + } else { + focusDrawer(); + } + return; + } + + if (key.ctrl && input === "l" && pane === "chat") { + setClearedAt(new Date().toISOString()); + setEvents([]); + setChatScrollOffset(0); + addNotice("Viewport cleared. Durable chat history is unchanged.", "info"); return; } @@ -2944,6 +3013,19 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } }); return; } + if ( + pane === "chat" + && !prompt.trim() + && !pendingApproval + && rightPane.kind !== "form" + && !slashRows.length + && !key.ctrl + && !key.meta + && input === "h" + ) { + openLatestImage(); + return; + } const linePrefix = inputBeforeLineBreak(input); if (textInputActive && (key.return || linePrefix != null)) { const suffix = linePrefix == null ? "" : printableInput(linePrefix); @@ -2982,8 +3064,14 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } const promptFocused = (activePane === "chat" && footerControl == null) || (activePane === "details" && rightPane.kind === "form"); const drawerFooterSelected = footerControl === "drawer"; const detailsFooterSelected = footerControl === "details"; - const statusRows = streaming ? 1 : 0; - const chatRowBudget = Math.max(4, rows - 12 - statusRows); + const currentGoal = useMemo(() => latestGoal(events), [events]); + const goalBannerText = useMemo(() => formatGoalBannerLine(currentGoal), [currentGoal]); + // Overhead: Header 1 + GoalBanner (0–1) + ModelStatus 1 + FooterControls 1 + + // prompt-box border 3 + flex padding 2 ≈ 8. Streaming annotation sits inside + // the prompt-box, so it costs 0 rows. Mention/slash palettes add their own + // rows when active; ChatView clamps via maxRows. + const goalBannerRows = goalBannerText ? 1 : 0; + const chatRowBudget = Math.max(4, rows - 10 - goalBannerRows); if (error && !connection) { return ( @@ -3000,8 +3088,10 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } projectName={projectName} lane={activeLane} /> - {streaming ? ( - ● streaming live{tokenSummary ? ` · ${tokenSummary}` : ""} · ctrl-c interrupts + {goalBannerText ? ( + + {goalBannerText} + ) : null} {drawerOpen ? ( @@ -3055,6 +3145,7 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } {prompt} + {streaming ? {" · streaming"} : null} |clear|status active|paused|budget |budget clear]" }, { name: "/help", description: "Show keymap and command help", placement: "right" }, { name: "/model", description: "Pick the active chat model", placement: "right" }, { name: "/effort", description: "Pick reasoning effort", placement: "right" }, diff --git a/apps/ade-cli/src/tuiClient/components/ModelStatus.tsx b/apps/ade-cli/src/tuiClient/components/ModelStatus.tsx index 5affc2a49..6341be66e 100644 --- a/apps/ade-cli/src/tuiClient/components/ModelStatus.tsx +++ b/apps/ade-cli/src/tuiClient/components/ModelStatus.tsx @@ -17,10 +17,10 @@ function ContextMeter({ percent, summary }: { percent: number; summary: string | const color = meterColor(percent); return ( - {percent}% - {"█".repeat(filled)} + {"▓".repeat(filled)} {"░".repeat(empty)} - {summary ? {` · ${summary}`} : null} + {` ${percent}%`} + {summary ? {` ${summary}`} : null} ); } diff --git a/apps/ade-cli/src/tuiClient/drawerSelection.ts b/apps/ade-cli/src/tuiClient/drawerSelection.ts new file mode 100644 index 000000000..222043b06 --- /dev/null +++ b/apps/ade-cli/src/tuiClient/drawerSelection.ts @@ -0,0 +1,34 @@ +import type { AgentChatSessionSummary } from "../../../desktop/src/shared/types/chat"; + +export type DrawerChatSelection = { + selectedDrawerChatId: string | null; + selectedDrawerChatAction: "new-chat" | null; +}; + +export function resolveDrawerChatSelection(args: { + activeLaneId: string | null; + activeSessionId: string | null; + draftChatActive: boolean; + drawerLaneId: string | null; + drawerVisibleLaneSessions: AgentChatSessionSummary[]; + selectedDrawerChatAction: "new-chat" | null; + selectedDrawerChatId: string | null; +}): DrawerChatSelection | null { + if (args.selectedDrawerChatAction) return null; + if ( + args.selectedDrawerChatId + && args.drawerVisibleLaneSessions.some((session) => session.sessionId === args.selectedDrawerChatId) + ) { + return null; + } + if (args.draftChatActive && args.drawerLaneId === args.activeLaneId) { + return { selectedDrawerChatId: null, selectedDrawerChatAction: "new-chat" }; + } + const activeChatInDrawer = args.drawerVisibleLaneSessions.find( + (session) => session.sessionId === args.activeSessionId, + ); + return { + selectedDrawerChatId: activeChatInDrawer?.sessionId ?? args.drawerVisibleLaneSessions[0]?.sessionId ?? null, + selectedDrawerChatAction: null, + }; +} diff --git a/apps/ade-cli/src/tuiClient/format.ts b/apps/ade-cli/src/tuiClient/format.ts index bb75ba01a..2e8c16cc8 100644 --- a/apps/ade-cli/src/tuiClient/format.ts +++ b/apps/ade-cli/src/tuiClient/format.ts @@ -2,6 +2,7 @@ import path from "node:path"; import { getModelById } from "../../../desktop/src/shared/modelRegistry"; import type { AgentChatEventEnvelope, AgentChatProvider, AgentChatSessionSummary } from "../../../desktop/src/shared/types/chat"; import type { LaneSummary } from "../../../desktop/src/shared/types/lanes"; +import { glyphFor } from "./theme"; import type { LocalNotice } from "./types"; function timeLabel(value: string): string { @@ -38,6 +39,13 @@ function summarizeCommandOutput(output: unknown): string { return text; } +function compactTokenCount(value: number | null | undefined): string | null { + if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) return null; + if (value >= 1_000_000) return `${(value / 1_000_000).toFixed(1)}M`; + if (value >= 1_000) return `${(value / 1_000).toFixed(1)}k`; + return String(Math.round(value)); +} + export function compactPath(value: string, max = 42): string { if (value.length <= max) return value; const base = path.basename(value); @@ -321,6 +329,60 @@ export function renderChatLines(args: { }); continue; } + if (event.type === "plan") { + const completed = event.steps.filter((step) => step.status === "completed").length; + const header = event.streamingText + ? `plan ${event.state ?? "updated"} ${singleLine(event.streamingText, 110)}` + : `plan ${completed}/${event.steps.length} complete`; + const steps = event.steps + .slice(0, 8) + .map((step) => `${glyphFor(step.status)} ${step.text}`) + .join("\n"); + lines.push({ + id, + tone: "notice", + body: steps ? `${header}\n${steps}` : header, + }); + continue; + } + if (event.type === "web_search") { + const statusGlyph = event.status === "running" ? "…" : event.status === "failed" ? "x" : "✓"; + const head = `${statusGlyph} web ${singleLine(event.query, 96)}`; + const actionLines = event.actions?.length + ? event.actions.map((action) => { + const kind = action.type || "action"; + const detail = action.title ?? action.url ?? action.query ?? ""; + return ` ${kind.padEnd(12, " ")} ${singleLine(detail, 96)}`.trimEnd(); + }) + : event.action ? [` ${event.action}`] : []; + lines.push({ + id, + tone: event.status === "failed" ? "error" : "tool", + body: actionLines.length ? `${head}\n${actionLines.join("\n")}` : head, + }); + continue; + } + if (event.type === "codex_image_generation" || event.type === "codex_image_view") { + const isGeneration = event.type === "codex_image_generation"; + const title = isGeneration + ? event.revisedPrompt ?? event.prompt ?? "image" + : event.title ?? event.url ?? event.path ?? "image"; + lines.push({ + id, + tone: event.status === "failed" ? "error" : "tool", + body: `${event.status === "running" ? "…" : event.status === "failed" ? "x" : "✓"} ${isGeneration ? "image generated" : "image"} ${singleLine(title, 120)}`, + }); + continue; + } + // codex_goal_updated / codex_goal_cleared: rendered as amber banner above + // chat by app.tsx — suppress here. codex_token_usage: rendered in + // ContextMeter footer — suppress here too. + if (event.type === "codex_goal_updated" || event.type === "codex_goal_cleared") { + continue; + } + if (event.type === "codex_token_usage") { + continue; + } if (event.type === "approval_request") { const record = event as unknown as Record; const files = Array.isArray(record.files) ? record.files : []; @@ -342,13 +404,150 @@ export function renderChatLines(args: { }); continue; } - if (event.type === "system_notice") { + if (event.type === "codex_context_compaction") { + const verb = event.state === "started" ? "compacting" : "compacted"; + lines.push({ + id, + tone: "notice", + body: `⟳ ${verb} · ${event.trigger}`, + }); + continue; + } + if (event.type === "status") { + const tone = event.turnStatus === "failed" + ? "error" as const + : event.turnStatus === "interrupted" ? "error" as const : "notice" as const; + lines.push({ id, tone, body: `[status] ${event.turnStatus}${event.message ? ` · ${singleLine(event.message, 120)}` : ""}` }); + continue; + } + if (event.type === "error") { + lines.push({ id, tone: "error", body: `[error] ${singleLine(event.message, 160)}` }); + continue; + } + if (event.type === "done") { + const usage = event.usage ?? {}; + const parts = [ + compactTokenCount(usage.inputTokens) ? `in ${compactTokenCount(usage.inputTokens)}` : null, + compactTokenCount(usage.outputTokens) ? `out ${compactTokenCount(usage.outputTokens)}` : null, + typeof event.costUsd === "number" ? `$${event.costUsd.toFixed(2)}` : null, + ].filter(Boolean); + lines.push({ + id, + tone: "notice", + body: `[done] ${event.status}${parts.length ? ` · ${parts.join(" · ")}` : ""}`, + }); + continue; + } + if (event.type === "activity") { + lines.push({ id, tone: "notice", body: `· ${event.activity}${event.detail ? ` ${singleLine(event.detail, 96)}` : ""}` }); + continue; + } + if (event.type === "tokens") { + // Tokens drive the ContextMeter footer; do not render in chat. + continue; + } + if (event.type === "cloud_artifact") { + lines.push({ id, tone: "notice", body: `[cloud] artifact · ${compactPath(event.path)}` }); + continue; + } + if (event.type === "cloud_status") { + lines.push({ + id, + tone: event.status === "error" ? "error" : "notice", + body: `[cloud] ${event.status}${event.detail ? ` · ${singleLine(event.detail, 96)}` : ""}`, + }); + continue; + } + if (event.type === "step_boundary") { + lines.push({ id, tone: "notice", body: `── step ${event.stepNumber} ──` }); + continue; + } + if (event.type === "todo_update") { + const todoLines = event.items + .slice(0, 12) + .map((todo) => `${glyphFor(todo.status)} ${todo.description}`); + lines.push({ id, tone: "notice", body: `todos\n${todoLines.join("\n")}` }); + continue; + } + if (event.type === "subagent_started") { + lines.push({ id, tone: "notice", body: `[agent] ${singleLine(event.description, 96)} (started)` }); + continue; + } + if (event.type === "subagent_progress") { lines.push({ id, tone: "notice", + body: `[agent] ${singleLine(event.description ?? event.summary, 80)} (working)`, + }); + continue; + } + if (event.type === "subagent_result") { + lines.push({ + id, + tone: event.status === "failed" ? "error" : "notice", + body: `[agent] ${singleLine(event.summary, 96)} (${event.status})`, + }); + continue; + } + if (event.type === "structured_question") { + lines.push({ id, tone: "approval", body: `[?] ${singleLine(event.question, 160)}` }); + continue; + } + if (event.type === "tool_use_summary") { + lines.push({ id, tone: "notice", body: `[tools] ${singleLine(event.summary, 160)}` }); + continue; + } + if (event.type === "completion_report") { + lines.push({ id, tone: "notice", body: `[done] turn summary: ${singleLine(event.report.summary, 160)}` }); + continue; + } + if (event.type === "auto_approval_review") { + lines.push({ + id, + tone: "notice", + body: `[auto-approval] ${event.reviewStatus}${event.action ? ` · ${event.action}` : ""}`, + }); + continue; + } + if (event.type === "prompt_suggestion") { + lines.push({ id, tone: "notice", body: `💡 ${singleLine(event.suggestion, 160)}` }); + continue; + } + if (event.type === "turn_diff_summary") { + lines.push({ + id, + tone: "notice", + body: `[diff] +${event.totalAdditions}/-${event.totalDeletions} across ${event.files.length} file${event.files.length === 1 ? "" : "s"}`, + }); + continue; + } + if (event.type === "pending_input_resolved") { + continue; + } + if (event.type === "delegation_state") { + const label = event.message ?? event.contract.status ?? event.contract.workerIntent ?? "state"; + lines.push({ id, tone: "notice", body: `[delegation] ${singleLine(label, 160)}` }); + continue; + } + if (event.type === "system_notice") { + // Surface the severity-bearing noticeKinds with an error tone so the TUI + // colorizes them distinctively. Guardian warnings, rate limits, thread + // errors, and provider health issues map to `tone: "error"`; warnings and + // config issues keep the default notice tone. + const noticeKind = (event as { noticeKind?: string }).noticeKind; + const tone: "notice" | "error" = noticeKind === "error" + || noticeKind === "rate_limit" + || noticeKind === "thread_error" + || noticeKind === "provider_health" + ? "error" + : "notice"; + lines.push({ + id, + tone, header: `${providerEventLabel(args.activeSession?.provider)} · ${timeLabel(envelope.timestamp)}`, body: singleLine((event as { message?: unknown }).message, 160), }); + continue; } } return coalesceLines(lines).slice(-(args.maxLines ?? 80)); diff --git a/apps/ade-cli/src/tuiClient/theme.ts b/apps/ade-cli/src/tuiClient/theme.ts index ec6227272..d0fccd137 100644 --- a/apps/ade-cli/src/tuiClient/theme.ts +++ b/apps/ade-cli/src/tuiClient/theme.ts @@ -52,12 +52,26 @@ const PROVIDER_THEME: Record = { const FALLBACK_PROVIDER: ProviderTheme = { glyph: "•", color: MUTED_FG, label: "Agent" }; +export type PlanStepStatus = "pending" | "in_progress" | "completed" | "failed"; + +const PLAN_STEP_GLYPH: Record = { + in_progress: "◐", + pending: "○", + completed: "●", + failed: "✕", +}; + +export function glyphFor(status: string | null | undefined): string { + return PLAN_STEP_GLYPH[status as PlanStepStatus] ?? "○"; +} + export const theme = { color: { accent: ACCENT, accentDim: ACCENT_DIM, fg: FG, mutedFg: MUTED_FG, + notice: NOTICE, border: MUTED_FG, borderFocused: ACCENT, success: SUCCESS, @@ -75,4 +89,5 @@ export const theme = { lane(lane: LaneSummary | null | undefined): string { return lane?.color || ACCENT; }, + glyphFor, } as const; diff --git a/apps/desktop/package-lock.json b/apps/desktop/package-lock.json index 571ce7528..94ac4852c 100644 --- a/apps/desktop/package-lock.json +++ b/apps/desktop/package-lock.json @@ -21,6 +21,7 @@ "@lobehub/icons": "^5.2.0", "@lobehub/icons-static-svg": "^1.84.0", "@lobehub/ui": "^5.6.3", + "@openai/codex": "0.130.0", "@opencode-ai/sdk": "^1.4.2", "@phosphor-icons/react": "^2.1.10", "@pierre/diffs": "^1.1.21", @@ -3650,6 +3651,128 @@ "node": ">=10" } }, + "node_modules/@openai/codex": { + "version": "0.130.0", + "resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.130.0.tgz", + "integrity": "sha512-WGDj+RZ3TXWC/7MlwprgLWOqzpwatPIINPhP3IRzHA0ni+o3QZ4i4xrS2uWwGmHUJ395J5JHwoZAAZYyfJyz6w==", + "license": "Apache-2.0", + "bin": { + "codex": "bin/codex.js" + }, + "engines": { + "node": ">=16" + }, + "optionalDependencies": { + "@openai/codex-darwin-arm64": "npm:@openai/codex@0.130.0-darwin-arm64", + "@openai/codex-darwin-x64": "npm:@openai/codex@0.130.0-darwin-x64", + "@openai/codex-linux-arm64": "npm:@openai/codex@0.130.0-linux-arm64", + "@openai/codex-linux-x64": "npm:@openai/codex@0.130.0-linux-x64", + "@openai/codex-win32-arm64": "npm:@openai/codex@0.130.0-win32-arm64", + "@openai/codex-win32-x64": "npm:@openai/codex@0.130.0-win32-x64" + } + }, + "node_modules/@openai/codex-darwin-arm64": { + "name": "@openai/codex", + "version": "0.130.0-darwin-arm64", + "resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.130.0-darwin-arm64.tgz", + "integrity": "sha512-R9pkGC7kwC8yQ8el5hvBlmugQlcsG/pHMEFgZluu03X9fD2TezGxdq3KqRDRCZuMYl07ILamVEoqknuJ0cq7MA==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@openai/codex-darwin-x64": { + "name": "@openai/codex", + "version": "0.130.0-darwin-x64", + "resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.130.0-darwin-x64.tgz", + "integrity": "sha512-gJ+7J8djevgtdra+NgDAiQQPW+O3KTsgGfE3E5dpDfww3zS5OCeV0V2dhxqnJdlOjOSDw99o0P2LqBv19mhpRw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@openai/codex-linux-arm64": { + "name": "@openai/codex", + "version": "0.130.0-linux-arm64", + "resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.130.0-linux-arm64.tgz", + "integrity": "sha512-tFtH0V9/hEI3d9y7zP92BXI9FM4Z3+STNQaOR52Czv18TRtCFUp7CbIUYaToopuq6UBfnE1VKr8RLhwT5FcbmA==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@openai/codex-linux-x64": { + "name": "@openai/codex", + "version": "0.130.0-linux-x64", + "resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.130.0-linux-x64.tgz", + "integrity": "sha512-3VcNlez99xdnEf+kB1IOpWv9fICYV9PiGj4sLCO4TCcShLnyxe+YBGa3poknkvXLnMG0qiN9SMnYS2FGrMxQcA==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@openai/codex-win32-arm64": { + "name": "@openai/codex", + "version": "0.130.0-win32-arm64", + "resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.130.0-win32-arm64.tgz", + "integrity": "sha512-vdpmiNp57L/arZabltLXn8TyEtNa7W1meOEkr+3R6W/8ZyBt++wuqz1Orv134OT2grrcFJsIVCAIPiqUxCvBkA==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@openai/codex-win32-x64": { + "name": "@openai/codex", + "version": "0.130.0-win32-x64", + "resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.130.0-win32-x64.tgz", + "integrity": "sha512-FzMznm7fr5/nbjZgOujZ9Y9AbdGm7ji1FOoWiY3U+srqauvZaTgn6o6aCheSL7kuymu7nTLOO/cAyWV6NuesqQ==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16" + } + }, "node_modules/@opencode-ai/sdk": { "version": "1.14.28", "resolved": "https://registry.npmjs.org/@opencode-ai/sdk/-/sdk-1.14.28.tgz", diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 171589b77..435149b48 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -61,6 +61,7 @@ "@lobehub/icons": "^5.2.0", "@lobehub/icons-static-svg": "^1.84.0", "@lobehub/ui": "^5.6.3", + "@openai/codex": "0.130.0", "@opencode-ai/sdk": "^1.4.2", "@phosphor-icons/react": "^2.1.10", "@pierre/diffs": "^1.1.21", @@ -166,6 +167,13 @@ "node_modules/@anthropic-ai/claude-agent-sdk/**", "node_modules/@anthropic-ai/claude-agent-sdk-darwin-arm64/**", "node_modules/@anthropic-ai/claude-agent-sdk-darwin-x64/**", + "node_modules/@openai/codex/**", + "node_modules/@openai/codex-darwin-arm64/**", + "node_modules/@openai/codex-darwin-x64/**", + "node_modules/@openai/codex-linux-arm64/**", + "node_modules/@openai/codex-linux-x64/**", + "node_modules/@openai/codex-win32-arm64/**", + "node_modules/@openai/codex-win32-x64/**", "node_modules/@cursor/sdk/**", "node_modules/@cursor/sdk-darwin-arm64/**", "node_modules/@cursor/sdk-darwin-x64/**", diff --git a/apps/desktop/pnpm-workspace.yaml b/apps/desktop/pnpm-workspace.yaml new file mode 100644 index 000000000..ed30c53fb --- /dev/null +++ b/apps/desktop/pnpm-workspace.yaml @@ -0,0 +1,11 @@ +allowBuilds: + cpu-features: set this to true or false + electron: set this to true or false + electron-winstaller: set this to true or false + esbuild: set this to true or false + node-pty: set this to true or false + onnxruntime-node: set this to true or false + protobufjs: set this to true or false + sharp: set this to true or false + sqlite3: set this to true or false + ssh2: set this to true or false diff --git a/apps/desktop/scripts/prepare-universal-mac-inputs.mjs b/apps/desktop/scripts/prepare-universal-mac-inputs.mjs index 8db867883..dc1bf6c10 100644 --- a/apps/desktop/scripts/prepare-universal-mac-inputs.mjs +++ b/apps/desktop/scripts/prepare-universal-mac-inputs.mjs @@ -208,6 +208,12 @@ async function copyFromAppBundle(x64AppPath, sourceRelativePath, targetRelativeP } async function seedFromAppBundle(x64AppPath) { + await copyFromAppBundle( + x64AppPath, + "Contents/Resources/app.asar.unpacked/node_modules/@openai/codex-darwin-x64", + "node_modules/@openai/codex-darwin-x64", + "Codex x64 package", + ); await copyFromAppBundle( x64AppPath, "Contents/Resources/app.asar.unpacked/node_modules/@img/sharp-darwin-x64", @@ -230,6 +236,12 @@ async function seedFromAppBundle(x64AppPath) { async function seedFromLockfileAndPinnedArtifacts() { const packageLock = await loadPackageLock(); + await seedPackageFromResolvedUrl( + packageLock, + "node_modules/@openai/codex-darwin-x64", + "node_modules/@openai/codex-darwin-x64", + "Codex x64 package", + ); await seedPackageFromResolvedUrl( packageLock, "node_modules/@img/sharp-darwin-x64", @@ -246,6 +258,19 @@ async function seedFromLockfileAndPinnedArtifacts() { } async function assertUniversalInputsReady() { + await assertPathExists( + path.join( + appDir, + "node_modules", + "@openai", + "codex-darwin-x64", + "vendor", + "x86_64-apple-darwin", + "codex", + "codex", + ), + "x64 Codex CLI binary", + ); await assertPathExists( path.join(appDir, "node_modules", "@img", "sharp-darwin-x64", "lib", "sharp-darwin-x64.node"), "x64 sharp native module", diff --git a/apps/desktop/scripts/runtimeBinaryPermissions.cjs b/apps/desktop/scripts/runtimeBinaryPermissions.cjs index 3490fc209..16b503456 100644 --- a/apps/desktop/scripts/runtimeBinaryPermissions.cjs +++ b/apps/desktop/scripts/runtimeBinaryPermissions.cjs @@ -70,12 +70,14 @@ function collectDesktopRuntimeExecutableCandidates(rootPath) { } for (const packageDir of listDirectories(path.join(rootPath, "node_modules", "@openai"))) { - if (!path.basename(packageDir).startsWith("codex-darwin-")) continue; + if (!path.basename(packageDir).startsWith("codex-")) continue; for (const vendorDir of listDirectories(path.join(packageDir, "vendor"))) { - candidates.push({ - filePath: path.join(vendorDir, "codex", "codex"), - label: "Codex CLI binary", - }); + for (const binaryName of ["codex", "codex.exe"]) { + candidates.push({ + filePath: path.join(vendorDir, "codex", binaryName), + label: "Codex CLI binary", + }); + } candidates.push({ filePath: path.join(vendorDir, "path", "rg"), label: "Codex ripgrep helper", diff --git a/apps/desktop/src/main/main.ts b/apps/desktop/src/main/main.ts index 2a69c7240..76c2dcaa3 100644 --- a/apps/desktop/src/main/main.ts +++ b/apps/desktop/src/main/main.ts @@ -1115,7 +1115,7 @@ app.whenReady().then(async () => { const currentIpcWindowId = (): number | null => ipcWindowScope.getStore() ?? null; - const useInProcessProjectRuntime = (): boolean => + const shouldUseInProcessProjectRuntime = (): boolean => process.env.NODE_ENV === "test" || process.env.ADE_ENABLE_DESKTOP_IN_PROCESS_RUNTIME === "1" || process.env.ADE_DISABLE_LOCAL_RUNTIME_DAEMON === "1" @@ -1127,7 +1127,7 @@ app.whenReady().then(async () => { }; const bindingForLocalProject = (project: ProjectInfo | null): OpenProjectBinding | null => - project + project && !shouldUseInProcessProjectRuntime() ? { kind: "local", key: `local:${project.rootPath}`, @@ -4950,7 +4950,7 @@ app.whenReady().then(async () => { durationMs: Date.now() - baseRefStartedAt, }); const initStartedAt = Date.now(); - const ctx = useInProcessProjectRuntime() + const ctx = shouldUseInProcessProjectRuntime() ? await initContextForProjectRoot({ projectRoot: repoRoot!, baseRef, @@ -4968,7 +4968,7 @@ app.whenReady().then(async () => { projectOpenLogger.info("project.open.context_initialized", { selectedPath, repoRoot, - mode: useInProcessProjectRuntime() ? "in_process" : "local_runtime_daemon", + mode: shouldUseInProcessProjectRuntime() ? "in_process" : "local_runtime_daemon", durationMs: Date.now() - initStartedAt, }); projectContexts.set(repoRoot!, ctx); @@ -5557,7 +5557,9 @@ app.whenReady().then(async () => { ipcWindowScope.run(BrowserWindow.fromWebContents(event.sender)?.id ?? null, fn), getWindowSession, bindRemoteProject: bindWindowToRemoteProject, - localRuntimeConnectionPool: localRuntimePool, + localRuntimeConnectionPool: shouldUseInProcessProjectRuntime() + ? null + : localRuntimePool, createWindow: openAdeWindow, closeWindow: closeAdeWindow, switchProjectFromDialog, diff --git a/apps/desktop/src/main/packagedRuntimeSmoke.ts b/apps/desktop/src/main/packagedRuntimeSmoke.ts index 2afac9e90..4a6f75a92 100644 --- a/apps/desktop/src/main/packagedRuntimeSmoke.ts +++ b/apps/desktop/src/main/packagedRuntimeSmoke.ts @@ -103,6 +103,7 @@ async function main(): Promise { const pty = await import("node-pty"); const claude = await import("@anthropic-ai/claude-agent-sdk"); const claudeExecutable = resolveClaudeCodeExecutable(); + const codexExecutable = resolveCodexExecutable(); const ptyProbe = await probePty(); const claudeStartup = await probeClaudeStartup(claudeExecutable); @@ -113,7 +114,8 @@ async function main(): Promise { claudeExecutablePath: claudeExecutable.path, claudeExecutableSource: claudeExecutable.source, claudeStartup, - codexExecutable: typeof resolveCodexExecutable, + codexExecutablePath: codexExecutable.path, + codexExecutableSource: codexExecutable.source, ptyProbe, })); } diff --git a/apps/desktop/src/main/services/ai/codexExecutable.test.ts b/apps/desktop/src/main/services/ai/codexExecutable.test.ts index 785b257c9..82efe590e 100644 --- a/apps/desktop/src/main/services/ai/codexExecutable.test.ts +++ b/apps/desktop/src/main/services/ai/codexExecutable.test.ts @@ -1,4 +1,7 @@ import { describe, expect, it, vi } from "vitest"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; const mockState = vi.hoisted(() => ({ resolveExecutableFromKnownLocations: vi.fn(), @@ -11,7 +14,7 @@ vi.mock("./cliExecutableResolver", () => ({ import { resolveCodexExecutable } from "./codexExecutable"; describe("resolveCodexExecutable", () => { - it("uses the detected Codex auth path before falling back to PATH lookup", () => { + it("uses the detected Codex auth path after bundled lookup is unavailable", () => { mockState.resolveExecutableFromKnownLocations.mockReset(); expect( @@ -28,6 +31,7 @@ describe("resolveCodexExecutable", () => { env: { PATH: "/usr/bin:/bin", }, + bundledRoots: [], }), ).toEqual({ path: "/Users/arul/.npm-global/bin/codex", @@ -45,6 +49,7 @@ describe("resolveCodexExecutable", () => { CODEX_EXECUTABLE: "/opt/codex/bin/codex", PATH: "/usr/bin:/bin", }, + bundledRoots: [], }), ).toEqual({ path: "/opt/codex/bin/codex", @@ -52,4 +57,49 @@ describe("resolveCodexExecutable", () => { }); expect(mockState.resolveExecutableFromKnownLocations).not.toHaveBeenCalled(); }); + + it("prefers the bundled platform Codex binary before auth or common PATH fallback", () => { + mockState.resolveExecutableFromKnownLocations.mockReset(); + + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "ade-codex-bundle-")); + const binaryPath = path.join( + tmpDir, + "codex-darwin-arm64", + "vendor", + "aarch64-apple-darwin", + "codex", + "codex", + ); + fs.mkdirSync(path.dirname(binaryPath), { recursive: true }); + fs.writeFileSync(binaryPath, "#!/bin/sh\n", "utf8"); + fs.chmodSync(binaryPath, 0o755); + + try { + expect( + resolveCodexExecutable({ + auth: [ + { + type: "cli-subscription", + cli: "codex", + path: "/Users/arul/.npm-global/bin/codex", + authenticated: true, + verified: true, + }, + ], + env: { + PATH: "/usr/bin:/bin", + }, + bundledRoots: [tmpDir], + platform: "darwin", + arch: "arm64", + }), + ).toEqual({ + path: binaryPath, + source: "bundled", + }); + expect(mockState.resolveExecutableFromKnownLocations).not.toHaveBeenCalled(); + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }); }); diff --git a/apps/desktop/src/main/services/ai/codexExecutable.ts b/apps/desktop/src/main/services/ai/codexExecutable.ts index 9a5efca3e..a11470ffc 100644 --- a/apps/desktop/src/main/services/ai/codexExecutable.ts +++ b/apps/desktop/src/main/services/ai/codexExecutable.ts @@ -1,9 +1,26 @@ import type { DetectedAuth } from "./authDetector"; +import fs from "node:fs"; +import path from "node:path"; import { resolveExecutableFromKnownLocations } from "./cliExecutableResolver"; export type CodexExecutableResolution = { path: string; - source: "auth" | "path" | "common-dir" | "fallback-command"; + source: "bundled" | "auth" | "path" | "common-dir" | "fallback-command"; +}; + +const CODEX_PLATFORM_PACKAGES: Partial>>> = { + darwin: { + arm64: "codex-darwin-arm64", + x64: "codex-darwin-x64", + }, + linux: { + arm64: "codex-linux-arm64", + x64: "codex-linux-x64", + }, + win32: { + arm64: "codex-win32-arm64", + x64: "codex-win32-x64", + }, }; function findCodexAuthPath(auth?: DetectedAuth[]): string | null { @@ -15,21 +32,115 @@ function findCodexAuthPath(auth?: DetectedAuth[]): string | null { return null; } +function pathExists(filePath: string): boolean { + try { + fs.accessSync(filePath, fs.constants.X_OK); + return true; + } catch { + try { + fs.accessSync(filePath, fs.constants.F_OK); + return process.platform === "win32"; + } catch { + return false; + } + } +} + +function listDirectories(rootPath: string): string[] { + try { + return fs.readdirSync(rootPath, { withFileTypes: true }) + .filter((entry) => entry.isDirectory()) + .map((entry) => path.join(rootPath, entry.name)); + } catch { + return []; + } +} + +function findVendorCodexBinary(packageRoot: string, platform: NodeJS.Platform): string | null { + const binaryName = platform === "win32" ? "codex.exe" : "codex"; + for (const vendorRoot of listDirectories(path.join(packageRoot, "vendor"))) { + const candidate = path.join(vendorRoot, "codex", binaryName); + if (pathExists(candidate)) return candidate; + } + return null; +} + +function collectBundledCodexRoots(env: NodeJS.ProcessEnv): string[] { + const roots: string[] = []; + const explicitRoot = env.ADE_CODEX_BUNDLE_ROOT?.trim(); + if (explicitRoot) roots.push(explicitRoot); + + const processWithResources = process as NodeJS.Process & { resourcesPath?: string }; + if (processWithResources.resourcesPath) { + roots.push( + path.join(processWithResources.resourcesPath, "app.asar.unpacked", "node_modules", "@openai"), + path.join(processWithResources.resourcesPath, "app", "node_modules", "@openai"), + ); + } + + roots.push(path.join(process.cwd(), "node_modules", "@openai")); + + let current = __dirname; + for (;;) { + roots.push(path.join(current, "node_modules", "@openai")); + const next = path.dirname(current); + if (next === current) break; + current = next; + } + + return [...new Set(roots)]; +} + +function findBundledCodexExecutable(args: { + env: NodeJS.ProcessEnv; + bundledRoots?: string[]; + platform?: NodeJS.Platform; + arch?: NodeJS.Architecture; +}): string | null { + if (args.env.ADE_DISABLE_BUNDLED_CODEX === "1") return null; + + const platform = args.platform ?? process.platform; + const arch = args.arch ?? process.arch; + const packageName = CODEX_PLATFORM_PACKAGES[platform]?.[arch]; + if (!packageName) return null; + + const roots = args.bundledRoots ?? collectBundledCodexRoots(args.env); + for (const root of roots) { + const packageRoot = path.join(root, packageName); + const executable = findVendorCodexBinary(packageRoot, platform); + if (executable) return executable; + } + return null; +} + export function resolveCodexExecutable(args?: { auth?: DetectedAuth[]; env?: NodeJS.ProcessEnv; + bundledRoots?: string[]; + platform?: NodeJS.Platform; + arch?: NodeJS.Architecture; }): CodexExecutableResolution { const env = args?.env ?? process.env; - const authPath = findCodexAuthPath(args?.auth); - if (authPath) { - return { path: authPath, source: "auth" }; - } - const envPath = env.CODEX_EXECUTABLE?.trim() || env.CODEX_EXECUTABLE_PATH?.trim(); if (envPath) { return { path: envPath, source: "path" }; } + const bundledPath = findBundledCodexExecutable({ + env, + bundledRoots: args?.bundledRoots, + platform: args?.platform, + arch: args?.arch, + }); + if (bundledPath) { + return { path: bundledPath, source: "bundled" }; + } + + const authPath = findCodexAuthPath(args?.auth); + if (authPath) { + return { path: authPath, source: "auth" }; + } + const resolved = resolveExecutableFromKnownLocations("codex", env); if (resolved) { return { diff --git a/apps/desktop/src/main/services/chat/agentChatService.test.ts b/apps/desktop/src/main/services/chat/agentChatService.test.ts index 42d91cbdf..66b708066 100644 --- a/apps/desktop/src/main/services/chat/agentChatService.test.ts +++ b/apps/desktop/src/main/services/chat/agentChatService.test.ts @@ -2039,7 +2039,10 @@ describe("createAgentChatService", () => { ); const startPayload = mockState.codexRequestPayloads.find((payload) => payload.method === "thread/start"); - expect((startPayload?.params as { reasoningEffort?: unknown } | undefined)?.reasoningEffort).toBe("low"); + const startParams = startPayload?.params as { effort?: unknown; reasoningEffort?: unknown; reasoning_effort?: unknown } | undefined; + expect(startParams?.effort).toBe("low"); + expect(startParams?.reasoningEffort).toBeUndefined(); + expect(startParams?.reasoning_effort).toBeUndefined(); }); it("starts mission Codex app-server sessions without global MCP servers", async () => { @@ -2072,20 +2075,17 @@ describe("createAgentChatService", () => { "model_reasoning_effort=\"low\"", "-c", "mcp_servers={}", - "--disable", - "plugins", - "--disable", - "apps", - "--disable", - "browser_use", - "--disable", - "computer_use", ], expect.any(Object), ); const spawnCall = vi.mocked(spawn).mock.calls.find((call) => call[0] === "codex" && Array.isArray(call[1]) && call[1].includes("app-server") ); + const spawnArgs = spawnCall?.[1] as string[] | undefined; + expect(spawnArgs).toBeDefined(); + expect(spawnArgs).not.toContain("--disable"); + expect(spawnArgs).not.toContain("browser_use"); + expect(spawnArgs).not.toContain("computer_use"); const spawnOptions = spawnCall?.[2] as { env?: NodeJS.ProcessEnv } | undefined; const codexHome = spawnOptions?.env?.CODEX_HOME; expect(codexHome).toContain("ade-mission-codex-home"); @@ -2134,6 +2134,14 @@ describe("createAgentChatService", () => { }), }), ); + const spawnCall = vi.mocked(spawn).mock.calls.find((call) => + call[0] === "codex" && Array.isArray(call[1]) && call[1].includes("app-server") + ); + const spawnArgs = spawnCall?.[1] as string[] | undefined; + expect(spawnArgs).toBeDefined(); + expect(spawnArgs).not.toContain("--disable"); + expect(spawnArgs).not.toContain("browser_use"); + expect(spawnArgs).not.toContain("computer_use"); }); }); @@ -3140,6 +3148,38 @@ describe("createAgentChatService", () => { expect(loginCmd).toBeUndefined(); }); + it("removes dead-listed Codex slash commands from the palette", async () => { + const { service } = createService(); + const session = await service.createSession({ + laneId: "lane-1", + provider: "codex", + model: "gpt-5.5", + }); + + const commands = service.getSlashCommands({ sessionId: session.id }); + const names = commands.map((c) => c.name); + // §A.6 leftovers (removed handlers/IPC) + expect(names).not.toContain("/fork"); + expect(names).not.toContain("/resume"); + expect(names).not.toContain("/rollback"); + expect(names).not.toContain("/unarchive"); + // Codex-CLI-only surfaces with no ADE consumer + expect(names).not.toContain("/apps"); + expect(names).not.toContain("/plugins"); + expect(names).not.toContain("/ps"); + expect(names).not.toContain("/stop"); + // Duplicate ADE composer/lane flows + expect(names).not.toContain("/mention"); + expect(names).not.toContain("/new"); + // TUI-only configuration + expect(names).not.toContain("/statusline"); + expect(names).not.toContain("/title"); + // Destructive runtime side-effect; ADE owns /quit + expect(names).not.toContain("/exit"); + // /inject was added by F.2 + expect(names).toContain("/inject"); + }); + it("includes project Claude Code command files before SDK init completes", async () => { const commandsDir = path.join(tmpRoot, ".claude", "commands"); fs.mkdirSync(commandsDir, { recursive: true }); @@ -3231,6 +3271,34 @@ describe("createAgentChatService", () => { ])); }); + it("advertises Codex CLI parity slash command hints for Codex sessions", async () => { + const { service } = createService(); + const session = await service.createSession({ + laneId: "lane-1", + provider: "codex", + model: "gpt-5.5", + }); + + const commands = service.getSlashCommands({ sessionId: session.id }); + expect(commands).toEqual(expect.arrayContaining([ + expect.objectContaining({ + name: "/fast", + argumentHint: "[on|off|status]", + source: "local", + }), + expect.objectContaining({ + name: "/plan", + argumentHint: "[prompt]", + source: "local", + }), + expect.objectContaining({ + name: "/goal", + argumentHint: "[pause|resume|clear|budget |]", + source: "local", + }), + ])); + }); + it("includes project Claude command files for Codex-backed sessions", async () => { const commandsDir = path.join(tmpRoot, ".claude", "commands"); const promptsDir = path.join(tmpRoot, ".codex", "prompts"); @@ -6348,11 +6416,11 @@ describe("createAgentChatService", () => { await waitForEvent( events, (event): event is AgentChatEventEnvelope & { - event: Extract; + event: Extract; } => - event.event.type === "plan_text" + event.event.type === "plan" && event.event.itemId === "codex-plan-1" - && event.event.text.includes("Inspect the app-server wiring"), + && (event.event.streamingText ?? "").includes("Inspect the app-server wiring"), ); const approvalEvent = await waitForEvent( events, @@ -6435,11 +6503,11 @@ describe("createAgentChatService", () => { await waitForEvent( events, (event): event is AgentChatEventEnvelope & { - event: Extract; + event: Extract; } => - event.event.type === "plan_text" + event.event.type === "plan" && event.event.itemId === `codex-plan:${session.id}:turn-1` - && event.event.text.includes("Patch the handoff"), + && (event.event.streamingText ?? "").includes("Patch the handoff"), ); mockState.emitCodexPayload({ @@ -6497,8 +6565,8 @@ describe("createAgentChatService", () => { await waitForEvent( events, (event): event is AgentChatEventEnvelope & { - event: Extract; - } => event.event.type === "plan_text" && event.event.itemId === "codex-plan-failed", + event: Extract; + } => event.event.type === "plan" && event.event.itemId === "codex-plan-failed", ); mockState.emitCodexPayload({ @@ -6572,6 +6640,44 @@ describe("createAgentChatService", () => { expect(collaborationMode?.settings?.developer_instructions).toBeNull(); }); + it("handles Codex /plan prompts inline and sends the next app-server turn in plan mode", async () => { + const { service } = createService(); + const session = await service.createSession({ + laneId: "lane-1", + provider: "codex", + model: "gpt-5.5", + }); + + await service.sendMessage({ + sessionId: session.id, + text: "/plan Inspect the repo first.", + }); + + await vi.waitFor(() => { + expect(mockState.codexRequestPayloads.some((payload) => payload.method === "turn/start")).toBe(true); + }); + + const summary = await service.getSessionSummary(session.id); + expect(summary?.permissionMode).toBe("plan"); + expect(summary?.interactionMode).toBe("plan"); + expect(summary?.codexApprovalPolicy).toBe("on-request"); + expect(summary?.codexSandbox).toBe("read-only"); + + const turnStartRequest = mockState.codexRequestPayloads.find((payload) => payload.method === "turn/start"); + const params = turnStartRequest?.params as { + approvalPolicy?: unknown; + sandboxPolicy?: { type?: unknown }; + collaborationMode?: { mode?: unknown }; + input?: Array<{ text?: unknown }>; + } | undefined; + const textInput = params?.input?.map((entry) => String(entry.text ?? "")).join("\n") ?? ""; + expect(textInput).toContain("Inspect the repo first."); + expect(textInput).not.toContain("/plan"); + expect(params?.approvalPolicy).toBe("on-request"); + expect(params?.sandboxPolicy?.type).toBe("readOnly"); + expect(params?.collaborationMode?.mode).toBe("plan"); + }); + it("sends fast service tier for supported Codex models when enabled", async () => { const { service } = createService(); const session = await service.createSession({ @@ -6601,6 +6707,43 @@ describe("createAgentChatService", () => { expect(readPersistedChatState(session.id).codexFastMode).toBe(true); }); + it("handles Codex /fast commands inline and applies fast tier to the next app-server turn", async () => { + const events: AgentChatEventEnvelope[] = []; + const { service } = createService({ + onEvent: (event: AgentChatEventEnvelope) => events.push(event), + }); + const session = await service.createSession({ + laneId: "lane-1", + provider: "codex", + model: "gpt-5.5", + }); + + await service.sendMessage({ + sessionId: session.id, + text: "/fast on", + }, { awaitDispatch: true }); + + expect(mockState.codexRequestPayloads.some((payload) => payload.method === "turn/start")).toBe(false); + expect((await service.getSessionSummary(session.id))?.codexFastMode).toBe(true); + expect(readPersistedChatState(session.id).codexFastMode).toBe(true); + expect(events.some((event) => + event.event.type === "system_notice" + && event.event.message === "Codex Fast mode is on." + )).toBe(true); + + mockState.codexRequestPayloads = []; + await service.sendMessage({ + sessionId: session.id, + text: "Use fast mode now.", + }); + + await vi.waitFor(() => { + expect(mockState.codexRequestPayloads.some((payload) => payload.method === "turn/start")).toBe(true); + }); + const turnStartRequest = mockState.codexRequestPayloads.find((payload) => payload.method === "turn/start"); + expect((turnStartRequest?.params as { serviceTier?: unknown } | undefined)?.serviceTier).toBe("fast"); + }); + it("explicitly clears Codex service tier when fast mode is off", async () => { const { service } = createService(); const session = await service.createSession({ @@ -6650,6 +6793,419 @@ describe("createAgentChatService", () => { expect((await service.getSessionSummary(session.id))?.codexFastMode).toBe(true); }); + it("routes Codex /goal pause, resume, and budget commands to app-server goal RPCs", async () => { + mockState.codexResponseOverrides.set("thread/goal/set", (payload) => { + const params = payload.params as Record; + return { + goal: { + objective: "Ship CLI parity", + status: params.status ?? "active", + tokenBudget: Object.prototype.hasOwnProperty.call(params, "tokenBudget") ? params.tokenBudget : 5000, + tokensUsed: 25, + timeUsedSeconds: 60, + createdAt: 1_760_000_000, + updatedAt: 1_760_000_001, + }, + }; + }); + const { service } = createService(); + const session = await service.createSession({ + laneId: "lane-1", + provider: "codex", + model: "gpt-5.5", + }); + + await service.sendMessage({ + sessionId: session.id, + text: "/goal pause", + }, { awaitDispatch: true }); + + const pauseRequest = mockState.codexRequestPayloads.find((payload) => payload.method === "thread/goal/set"); + expect(pauseRequest?.params).toMatchObject({ + threadId: expect.any(String), + status: "paused", + }); + expect(mockState.codexRequestPayloads.some((payload) => payload.method === "turn/start")).toBe(false); + + mockState.codexRequestPayloads = []; + await service.sendMessage({ + sessionId: session.id, + text: "/goal resume", + }, { awaitDispatch: true }); + expect(mockState.codexRequestPayloads.find((payload) => payload.method === "thread/goal/set")?.params).toMatchObject({ + status: "active", + }); + + mockState.codexRequestPayloads = []; + await service.sendMessage({ + sessionId: session.id, + text: "/goal budget 5_000", + }, { awaitDispatch: true }); + expect(mockState.codexRequestPayloads.find((payload) => payload.method === "thread/goal/set")?.params).toMatchObject({ + tokenBudget: 5000, + }); + + mockState.codexRequestPayloads = []; + await service.sendMessage({ + sessionId: session.id, + text: "/goal budget clear", + }, { awaitDispatch: true }); + expect(mockState.codexRequestPayloads.find((payload) => payload.method === "thread/goal/set")?.params).toMatchObject({ + tokenBudget: null, + }); + + mockState.codexRequestPayloads = []; + await service.sendMessage({ + sessionId: session.id, + text: "/goal budget 5k", + }, { awaitDispatch: true }); + expect(mockState.codexRequestPayloads.some((payload) => payload.method === "thread/goal/set")).toBe(false); + expect(mockState.codexRequestPayloads.some((payload) => payload.method === "turn/start")).toBe(false); + }); + + it("routes Codex /inject to thread/inject_items and emits a notice", async () => { + mockState.codexResponseOverrides.set("thread/inject_items", () => ({})); + const onEvent = vi.fn(); + const { service } = createService({ onEvent }); + const session = await service.createSession({ + laneId: "lane-1", + provider: "codex", + model: "gpt-5.5", + }); + + await service.sendMessage({ + sessionId: session.id, + text: "/inject Remember this for the rest of the thread.\nSecond line here.", + }, { awaitDispatch: true }); + + const injectRequest = mockState.codexRequestPayloads.find((payload) => payload.method === "thread/inject_items"); + // ThreadInjectItemsParams.items takes raw Responses API items + // (ResponseItem::Message), not a synthetic { type: "user_message" } shape. + expect(injectRequest?.params).toMatchObject({ + threadId: expect.any(String), + items: [ + { + type: "message", + role: "user", + content: [ + { type: "input_text", text: "Remember this for the rest of the thread.\nSecond line here." }, + ], + }, + ], + }); + expect(mockState.codexRequestPayloads.some((payload) => payload.method === "turn/start")).toBe(false); + const injectedNotice = onEvent.mock.calls + .map((call) => call[0]) + .find((env: any) => env?.event?.type === "system_notice" && typeof env.event.message === "string" && env.event.message.startsWith("[injected]")); + expect(injectedNotice?.event.message).toContain("Remember this for the rest of the thread."); + }); + + it("rejects /inject without context body", async () => { + const { service } = createService(); + const session = await service.createSession({ + laneId: "lane-1", + provider: "codex", + model: "gpt-5.5", + }); + + await service.sendMessage({ + sessionId: session.id, + text: "/inject ", + }, { awaitDispatch: true }); + + expect(mockState.codexRequestPayloads.some((payload) => payload.method === "thread/inject_items")).toBe(false); + expect(mockState.codexRequestPayloads.some((payload) => payload.method === "turn/start")).toBe(false); + }); + + it("routes /review with no args to review/start with target type=uncommittedChanges", async () => { + const { service } = createService(); + const session = await service.createSession({ + laneId: "lane-1", + provider: "codex", + model: "gpt-5.5", + }); + + await service.sendMessage({ + sessionId: session.id, + text: "/review", + }, { awaitDispatch: true }); + + // ReviewTarget union (codex v2 protocol): uncommittedChanges | baseBranch | commit | custom. + const reviewRequest = mockState.codexRequestPayloads.find((payload) => payload.method === "review/start"); + expect(reviewRequest?.params).toMatchObject({ + threadId: expect.any(String), + target: { type: "uncommittedChanges" }, + }); + }); + + it("routes /review branch to review/start with target type=baseBranch", async () => { + const { service } = createService(); + const session = await service.createSession({ + laneId: "lane-1", + provider: "codex", + model: "gpt-5.5", + }); + + await service.sendMessage({ + sessionId: session.id, + text: "/review branch feature/foo", + }, { awaitDispatch: true }); + + const reviewRequest = mockState.codexRequestPayloads.find((payload) => payload.method === "review/start"); + expect(reviewRequest?.params).toMatchObject({ + target: { type: "baseBranch", branch: "feature/foo" }, + }); + }); + + it("routes /review prompt to review/start with target type=custom", async () => { + const { service } = createService(); + const session = await service.createSession({ + laneId: "lane-1", + provider: "codex", + model: "gpt-5.5", + }); + + await service.sendMessage({ + sessionId: session.id, + text: "/review prompt audit the auth middleware", + }, { awaitDispatch: true }); + + const reviewRequest = mockState.codexRequestPayloads.find((payload) => payload.method === "review/start"); + expect(reviewRequest?.params).toMatchObject({ + target: { type: "custom", instructions: "audit the auth middleware" }, + }); + }); + + it("rejects /review branch with no name and does not call review/start", async () => { + const onEvent = vi.fn(); + const { service } = createService({ onEvent }); + const session = await service.createSession({ + laneId: "lane-1", + provider: "codex", + model: "gpt-5.5", + }); + + await service.sendMessage({ + sessionId: session.id, + text: "/review branch ", + }, { awaitDispatch: true }); + + expect(mockState.codexRequestPayloads.some((payload) => payload.method === "review/start")).toBe(false); + const usageNotice = onEvent.mock.calls + .map((call) => call[0]) + .find((env: any) => env?.event?.type === "system_notice" + && typeof env.event.message === "string" + && env.event.message.includes("/review branch")); + expect(usageNotice).toBeDefined(); + }); + + it("rejects /review prompt with no text and does not call review/start", async () => { + const onEvent = vi.fn(); + const { service } = createService({ onEvent }); + const session = await service.createSession({ + laneId: "lane-1", + provider: "codex", + model: "gpt-5.5", + }); + + await service.sendMessage({ + sessionId: session.id, + text: "/review prompt ", + }, { awaitDispatch: true }); + + expect(mockState.codexRequestPayloads.some((payload) => payload.method === "review/start")).toBe(false); + const usageNotice = onEvent.mock.calls + .map((call) => call[0]) + .find((env: any) => env?.event?.type === "system_notice" + && typeof env.event.message === "string" + && env.event.message.includes("/review prompt")); + expect(usageNotice).toBeDefined(); + }); + + it("routes /review diff to target.uncommittedChanges", async () => { + const { service } = createService(); + const session = await service.createSession({ + laneId: "lane-1", + provider: "codex", + model: "gpt-5.5", + }); + + await service.sendMessage({ + sessionId: session.id, + text: "/review diff", + }, { awaitDispatch: true }); + + const reviewRequest = mockState.codexRequestPayloads.find((payload) => payload.method === "review/start"); + expect(reviewRequest?.params).toMatchObject({ + target: { type: "uncommittedChanges" }, + }); + }); + + it("surfaces Codex deprecation/warning/guardian/config notifications as system_notice rows", async () => { + const onEvent = vi.fn(); + const { service } = createService({ onEvent }); + const session = await service.createSession({ + laneId: "lane-1", + provider: "codex", + model: "gpt-5.5", + }); + + await service.sendMessage({ + sessionId: session.id, + text: "Kick off codex.", + }); + await vi.waitFor(() => { + expect(mockState.codexRequestPayloads.some((payload) => payload.method === "thread/start")).toBe(true); + }); + + mockState.emitCodexPayload({ + jsonrpc: "2.0", + method: "deprecationNotice", + params: { message: "old feature gone" }, + }); + mockState.emitCodexPayload({ + jsonrpc: "2.0", + method: "warning", + params: { message: "watch out" }, + }); + mockState.emitCodexPayload({ + jsonrpc: "2.0", + method: "guardianWarning", + params: { message: "sandbox tripped" }, + }); + mockState.emitCodexPayload({ + jsonrpc: "2.0", + method: "configWarning", + params: { message: "config layer stale" }, + }); + + await vi.waitFor(() => { + const notices = onEvent.mock.calls + .map((call) => call[0]) + .filter((env: any) => env?.event?.type === "system_notice"); + const messages = notices.map((env: any) => env.event.message); + expect(messages).toEqual(expect.arrayContaining([ + "⚠ deprecated: old feature gone", + "⚠ watch out", + "🛡 guardian: sandbox tripped", + "⚙ config: config layer stale", + ])); + }); + + const guardianNotice = onEvent.mock.calls + .map((call) => call[0]) + .find((env: any) => env?.event?.type === "system_notice" && env.event.message.startsWith("🛡 guardian:")); + expect(guardianNotice?.event.noticeKind).toBe("error"); + + const deprecationNotice = onEvent.mock.calls + .map((call) => call[0]) + .find((env: any) => env?.event?.type === "system_notice" && env.event.message.startsWith("⚠ deprecated:")); + expect(deprecationNotice?.event.noticeKind).toBe("warning"); + + const configNotice = onEvent.mock.calls + .map((call) => call[0]) + .find((env: any) => env?.event?.type === "system_notice" && env.event.message.startsWith("⚙ config:")); + expect(configNotice?.event.noticeKind).toBe("config"); + }); + + it("populates optOutNotificationMethods in initialize when runtimeMode is 'print'", async () => { + const { service } = createService(); + const session = await service.createSession({ + laneId: "lane-1", + provider: "codex", + model: "gpt-5.5", + runtimeMode: "print", + }); + + await service.sendMessage({ + sessionId: session.id, + text: "Hello.", + }); + await vi.waitFor(() => { + expect(mockState.codexRequestPayloads.some((payload) => payload.method === "initialize")).toBe(true); + }); + + const initializeRequest = mockState.codexRequestPayloads.find((payload) => payload.method === "initialize"); + const capabilities = (initializeRequest?.params as { capabilities?: { optOutNotificationMethods?: string[] } }) + ?.capabilities; + expect(capabilities?.optOutNotificationMethods).toEqual([ + "item/agentMessage/delta", + "item/reasoning/summaryTextDelta", + "item/reasoning/textDelta", + "item/commandExecution/outputDelta", + ]); + }); + + it("sends an empty optOutNotificationMethods list when runtimeMode is undefined (default interactive)", async () => { + const { service } = createService(); + const session = await service.createSession({ + laneId: "lane-1", + provider: "codex", + model: "gpt-5.5", + }); + + await service.sendMessage({ + sessionId: session.id, + text: "Hello.", + }); + await vi.waitFor(() => { + expect(mockState.codexRequestPayloads.some((payload) => payload.method === "initialize")).toBe(true); + }); + + const initializeRequest = mockState.codexRequestPayloads.find((payload) => payload.method === "initialize"); + const capabilities = (initializeRequest?.params as { capabilities?: { optOutNotificationMethods?: string[] } }) + ?.capabilities; + expect(capabilities?.optOutNotificationMethods).toEqual([]); + }); + + it("ignores deprecation/warning notifications with missing or empty message", async () => { + const onEvent = vi.fn(); + const { service } = createService({ onEvent }); + const session = await service.createSession({ + laneId: "lane-1", + provider: "codex", + model: "gpt-5.5", + }); + + await service.sendMessage({ + sessionId: session.id, + text: "Kick off codex.", + }); + await vi.waitFor(() => { + expect(mockState.codexRequestPayloads.some((payload) => payload.method === "thread/start")).toBe(true); + }); + + const beforeNoticeCount = onEvent.mock.calls + .map((call) => call[0]) + .filter((env: any) => env?.event?.type === "system_notice").length; + + // Missing payload entirely. + mockState.emitCodexPayload({ jsonrpc: "2.0", method: "deprecationNotice" }); + // Empty params. + mockState.emitCodexPayload({ jsonrpc: "2.0", method: "warning", params: {} }); + // Wrong field name (handler should silently no-op). + mockState.emitCodexPayload({ jsonrpc: "2.0", method: "configWarning", params: { note: "ignored" } }); + // Whitespace-only. + mockState.emitCodexPayload({ jsonrpc: "2.0", method: "guardianWarning", params: { message: " " } }); + + // Settle: emit a real notice so vi.waitFor has something to wait on. + mockState.emitCodexPayload({ jsonrpc: "2.0", method: "warning", params: { message: "real one" } }); + await vi.waitFor(() => { + const messages = onEvent.mock.calls + .map((call) => call[0]) + .filter((env: any) => env?.event?.type === "system_notice") + .map((env: any) => env.event.message); + expect(messages).toContain("⚠ real one"); + }); + + const afterMessages = onEvent.mock.calls + .map((call) => call[0]) + .filter((env: any) => env?.event?.type === "system_notice") + .map((env: any) => env.event.message); + // Only the real notice should have been added beyond the baseline. + expect(afterMessages.length).toBe(beforeNoticeCount + 1); + }); + it("clears fast mode when switching a session away from Codex", async () => { const { service } = createService(); const session = await service.createSession({ @@ -6783,9 +7339,9 @@ describe("createAgentChatService", () => { } | undefined; expect(params?.approvalPolicy).toBe("never"); expect(params?.sandbox).toBe("danger-full-access"); - expect(params?.reasoningEffort).toBe("medium"); - expect(params?.reasoning_effort).toBe("medium"); expect(params?.effort).toBe("medium"); + expect(params?.reasoningEffort).toBeUndefined(); + expect(params?.reasoning_effort).toBeUndefined(); const turnStartRequest = mockState.codexRequestPayloads.find((payload) => payload.method === "turn/start"); const turnStartParams = turnStartRequest?.params as { @@ -6798,8 +7354,8 @@ describe("createAgentChatService", () => { expect(turnStartParams?.approvalPolicy).toBe("never"); expect(turnStartParams?.sandboxPolicy?.type).toBe("dangerFullAccess"); expect(turnStartParams?.effort).toBe("medium"); - expect(turnStartParams?.reasoningEffort).toBe("medium"); - expect(turnStartParams?.reasoning_effort).toBe("medium"); + expect(turnStartParams?.reasoningEffort).toBeUndefined(); + expect(turnStartParams?.reasoning_effort).toBeUndefined(); }); it("serializes every Codex permission mode to the app-server wire shapes", async () => { @@ -6915,9 +7471,10 @@ describe("createAgentChatService", () => { }); const threadStartRequest = mockState.codexRequestPayloads.find((payload) => payload.method === "thread/start"); - expect((threadStartRequest?.params as { reasoningEffort?: unknown; reasoning_effort?: unknown; effort?: unknown } | undefined)?.reasoningEffort).toBe("xhigh"); - expect((threadStartRequest?.params as { reasoningEffort?: unknown; reasoning_effort?: unknown; effort?: unknown } | undefined)?.reasoning_effort).toBe("xhigh"); - expect((threadStartRequest?.params as { reasoningEffort?: unknown; reasoning_effort?: unknown; effort?: unknown } | undefined)?.effort).toBe("xhigh"); + const threadStartParams = threadStartRequest?.params as { reasoningEffort?: unknown; reasoning_effort?: unknown; effort?: unknown } | undefined; + expect(threadStartParams?.effort).toBe("xhigh"); + expect(threadStartParams?.reasoningEffort).toBeUndefined(); + expect(threadStartParams?.reasoning_effort).toBeUndefined(); const turnStartRequest = mockState.codexRequestPayloads.find((payload) => payload.method === "turn/start"); const turnStartParams = turnStartRequest?.params as { approvalPolicy?: unknown; @@ -6929,8 +7486,8 @@ describe("createAgentChatService", () => { expect(turnStartParams?.approvalPolicy).toBe("on-failure"); expect(turnStartParams?.sandboxPolicy?.type).toBe("workspaceWrite"); expect(turnStartParams?.effort).toBe("xhigh"); - expect(turnStartParams?.reasoningEffort).toBe("xhigh"); - expect(turnStartParams?.reasoning_effort).toBe("xhigh"); + expect(turnStartParams?.reasoningEffort).toBeUndefined(); + expect(turnStartParams?.reasoning_effort).toBeUndefined(); const summary = await service.getSessionSummary(session.id); expect(summary?.codexApprovalPolicy).toBe("on-failure"); @@ -7012,8 +7569,8 @@ describe("createAgentChatService", () => { } | undefined; expect(params?.approvalPolicy).toBe("never"); expect(params?.sandbox).toBe("danger-full-access"); - expect(params?.reasoningEffort).toBe("medium"); expect(params?.effort).toBe("medium"); + expect(params?.reasoningEffort).toBeUndefined(); const turnStartRequest = mockState.codexRequestPayloads.find((payload) => payload.method === "turn/start"); const turnStartParams = turnStartRequest?.params as { @@ -7304,9 +7861,10 @@ describe("createAgentChatService", () => { const resumed = await service.resumeSession({ sessionId: session.id }); const resumeRequest = mockState.codexRequestPayloads.find((payload) => payload.method === "thread/resume"); - expect((resumeRequest?.params as { reasoningEffort?: unknown; reasoning_effort?: unknown; effort?: unknown } | undefined)?.reasoningEffort).toBe("xhigh"); - expect((resumeRequest?.params as { reasoningEffort?: unknown; reasoning_effort?: unknown; effort?: unknown } | undefined)?.reasoning_effort).toBe("xhigh"); - expect((resumeRequest?.params as { reasoningEffort?: unknown; reasoning_effort?: unknown; effort?: unknown } | undefined)?.effort).toBe("xhigh"); + const resumeParams = resumeRequest?.params as { reasoningEffort?: unknown; reasoning_effort?: unknown; effort?: unknown } | undefined; + expect(resumeParams?.effort).toBe("xhigh"); + expect(resumeParams?.reasoningEffort).toBeUndefined(); + expect(resumeParams?.reasoning_effort).toBeUndefined(); expect(resumed.codexApprovalPolicy).toBe("on-failure"); expect(resumed.codexSandbox).toBe("workspace-write"); expect(resumed.permissionMode).toBe("default"); @@ -10271,7 +10829,6 @@ describe("createAgentChatService", () => { expect( mockState.codexRequestPayloads.find((payload) => payload.id === "native-request-1"), ).toMatchObject({ - jsonrpc: "2.0", id: "native-request-1", result: { answers: {}, diff --git a/apps/desktop/src/main/services/chat/agentChatService.ts b/apps/desktop/src/main/services/chat/agentChatService.ts index e5e089dee..977c9d6f2 100644 --- a/apps/desktop/src/main/services/chat/agentChatService.ts +++ b/apps/desktop/src/main/services/chat/agentChatService.ts @@ -120,13 +120,20 @@ import type { AgentChatSteerResult, AgentChatSendArgs, AgentChatRuntime, + AgentChatRuntimeMode, AgentChatCloudOverrides, AgentChatCloudRunStatus, + AgentChatPlanStep, AgentChatSuggestLaneNameArgs, AgentChatCursorConfigOption, AgentChatCursorConfigValue, AgentChatCursorModeSnapshot, AgentChatOpenCodePermissionMode, + CodexPlanState, + CodexThreadGoal, + CodexThreadTokenUsage, + CodexTokenUsageBreakdown, + CodexWebSearchAction, PendingInputQuestion, PendingInputRequest, PendingInputSource, @@ -226,7 +233,6 @@ import { resolveCursorSdkUserHome, runCursorSdkCloudRequest, type CursorSdkPooled, - type CursorSdkRuntimeMeta, } from "./cursorSdkPool"; import { acquireDroidAcpConnection, @@ -239,7 +245,6 @@ import { discoverDroidCliModelDescriptors } from "./droidModelsDiscovery"; import { mapCursorSdkMessageToChatEvents, mapCursorSdkRunResultToDoneEvent, - mapTurnEndedTokensToEvent, } from "./cursorSdkEventMapper"; import { allowCursorHook, @@ -354,6 +359,8 @@ type PersistedChatState = { awaitingInput?: boolean; requestedCwd?: string | null; idleSinceAt?: string | null; + /** Non-interactive runtime mode (e.g. "print" for one-shot CLI output). Drives initialize handshake opt-outs. */ + runtimeMode?: AgentChatRuntimeMode; /** Recent terminal Codex turn ids, used to suppress late replayed lifecycle events. */ codexTerminalTurnIds?: string[]; /** Persisted "Allow for Session" tool approval overrides (Claude runtime). */ @@ -409,6 +416,9 @@ type CodexRuntime = { fileDeltaByItemId: Map; fileChangesByItemId: Map>; planTextByItemId: Map; + manualCompactionItemIds: Set; + manualCompactionPending: boolean; + webSearchActionsByItemId: Map; activeSubagents: Map; interruptedTurnIds: Set; ignoredTurnIds: Set; @@ -432,7 +442,7 @@ type CodexRuntime = { request: (method: string, params?: unknown) => Promise; notify: (method: string, params?: unknown) => void; sendResponse: (id: string | number, result: unknown) => void; - sendError: (id: string | number, message: string) => void; + sendError: (id: string | number, message: string, code?: number) => void; slashCommands: Array<{ name: string; description: string; argumentHint?: string }>; rateLimits: { remaining: number | null; limit: number | null; resetAt: string | null } | null; collaborationModes: Set | null; @@ -492,34 +502,25 @@ const CODEX_BUILT_IN_SLASH_COMMANDS: AgentChatSlashCommand[] = [ { name: "/permissions", description: "Set what Codex can do without asking first.", source: "sdk" }, { name: "/sandbox-add-read-dir", description: "Grant sandbox read access to an extra directory.", source: "sdk" }, { name: "/agent", description: "Switch the active agent thread.", source: "sdk" }, - { name: "/apps", description: "Browse apps and insert them into your prompt.", source: "sdk" }, - { name: "/plugins", description: "Browse installed and discoverable plugins.", source: "sdk" }, { name: "/clear", description: "Clear the terminal and start a fresh chat.", source: "sdk" }, - { name: "/compact", description: "Summarize the visible conversation to free tokens.", source: "sdk" }, + { name: "/compact", description: "Summarize the visible conversation to free tokens.", source: "local" }, { name: "/copy", description: "Copy the latest completed Codex output.", source: "sdk" }, { name: "/diff", description: "Show the Git diff, including untracked files.", source: "sdk" }, - { name: "/exit", description: "Exit the CLI.", source: "sdk" }, { name: "/experimental", description: "Toggle experimental features.", source: "sdk" }, { name: "/feedback", description: "Send logs to the Codex maintainers.", source: "sdk" }, { name: "/init", description: "Generate an AGENTS.md scaffold in the current directory.", source: "sdk" }, + { name: "/goal", description: "Set, show, pause, resume, budget, or clear the thread goal.", source: "local", argumentHint: "[pause|resume|clear|budget |]" }, + { name: "/inject", description: "Inject context text into Codex thread history.", source: "local", argumentHint: "" }, { name: "/logout", description: "Sign out of Codex.", source: "sdk" }, { name: "/mcp", description: "List configured MCP tools.", source: "sdk" }, - { name: "/mention", description: "Attach a file to the conversation.", source: "sdk" }, { name: "/model", description: "Choose the active model and reasoning effort.", source: "sdk" }, - { name: "/fast", description: "Toggle Fast mode for supported models.", source: "sdk" }, - { name: "/plan", description: "Switch to plan mode and optionally send a prompt.", source: "sdk" }, + { name: "/fast", description: "Toggle Fast mode for supported models.", source: "local", argumentHint: "[on|off|status]" }, + { name: "/plan", description: "Switch to plan mode and optionally send a prompt.", source: "local", argumentHint: "[prompt]" }, { name: "/personality", description: "Choose a communication style for responses.", source: "sdk" }, - { name: "/ps", description: "Show experimental background terminals and recent output.", source: "sdk" }, - { name: "/stop", description: "Stop all background terminals.", source: "sdk" }, - { name: "/fork", description: "Fork the current conversation into a new thread.", source: "sdk" }, - { name: "/resume", description: "Resume a saved conversation from your session list.", source: "sdk" }, - { name: "/new", description: "Start a new conversation inside the same CLI session.", source: "sdk" }, { name: "/quit", description: "Exit the CLI.", source: "sdk" }, - { name: "/review", description: "Ask Codex to review your working tree.", source: "sdk" }, + { name: "/review", description: "Ask Codex to review your working tree, a branch, or a prompt.", source: "local", argumentHint: "[diff|branch |prompt ]" }, { name: "/status", description: "Display session configuration and token usage.", source: "sdk" }, { name: "/debug-config", description: "Print config layer and requirements diagnostics.", source: "sdk" }, - { name: "/statusline", description: "Configure TUI status-line fields.", source: "sdk" }, - { name: "/title", description: "Configure terminal window or tab title fields.", source: "sdk" }, ]; const CLAUDE_BUILT_IN_SLASH_COMMANDS: AgentChatSlashCommand[] = [ @@ -1563,6 +1564,120 @@ function normalizeUsagePayload( return { inputTokens, outputTokens }; } +function numberOrNull(value: unknown): number | null { + return typeof value === "number" && Number.isFinite(value) ? value : null; +} + +function stringOrNull(value: unknown): string | null { + return typeof value === "string" && value.trim().length ? value.trim() : null; +} + +function codexTimestampOrNull(value: unknown): string | null { + if (typeof value === "number" && Number.isFinite(value)) return String(value); + return stringOrNull(value); +} + +function normalizeCodexTokenBreakdown(value: unknown): CodexTokenUsageBreakdown | undefined { + const record = asRecord(value); + if (!record) return undefined; + const inputTokens = numberOrNull(record.inputTokens ?? record.input_tokens ?? record.promptTokens ?? record.prompt_tokens); + const outputTokens = numberOrNull(record.outputTokens ?? record.output_tokens ?? record.completionTokens ?? record.completion_tokens); + const cacheReadTokens = numberOrNull(record.cacheReadTokens ?? record.cache_read_tokens ?? record.cachedInputTokens ?? record.cached_input_tokens); + const cacheWriteTokens = numberOrNull(record.cacheWriteTokens ?? record.cache_write_tokens ?? record.cacheCreationTokens ?? record.cache_creation_tokens); + const totalTokens = numberOrNull(record.totalTokens ?? record.total_tokens ?? record.total); + const normalized: CodexTokenUsageBreakdown = {}; + if (inputTokens != null) normalized.inputTokens = inputTokens; + if (outputTokens != null) normalized.outputTokens = outputTokens; + if (cacheReadTokens != null) normalized.cacheReadTokens = cacheReadTokens; + if (cacheWriteTokens != null) normalized.cacheWriteTokens = cacheWriteTokens; + if (totalTokens != null) normalized.totalTokens = totalTokens; + if (normalized.totalTokens == null) { + const derivedTotal = + (normalized.inputTokens ?? 0) + + (normalized.outputTokens ?? 0) + + (normalized.cacheReadTokens ?? 0) + + (normalized.cacheWriteTokens ?? 0); + if (derivedTotal > 0) normalized.totalTokens = derivedTotal; + } + return Object.keys(normalized).length ? normalized : undefined; +} + +function normalizeCodexThreadTokenUsage(params: Record): CodexThreadTokenUsage | null { + const tokenUsage = asRecord(params.tokenUsage ?? params.token_usage) ?? params; + const total = normalizeCodexTokenBreakdown(tokenUsage.total); + const last = normalizeCodexTokenBreakdown(tokenUsage.last); + const fallback = !total && !last ? normalizeCodexTokenBreakdown(tokenUsage) : undefined; + const modelContextWindow = numberOrNull( + tokenUsage.modelContextWindow + ?? tokenUsage.model_context_window + ?? tokenUsage.contextWindow + ?? tokenUsage.context_window, + ); + const normalized: CodexThreadTokenUsage = { + threadId: extractCodexThreadId(params) ?? null, + turnId: extractCodexTurnId(params) ?? null, + ...(total || fallback ? { total: total ?? fallback } : {}), + ...(last ? { last } : {}), + ...(modelContextWindow != null ? { modelContextWindow } : {}), + }; + return normalized.total || normalized.last || normalized.modelContextWindow != null ? normalized : null; +} + +function normalizeCodexGoalPayload(value: unknown): CodexThreadGoal | null { + const record = asRecord(value); + if (!record) return null; + const goalRecord = asRecord(record.goal) ?? record; + const statusRaw = stringOrNull(goalRecord.status)?.toLowerCase() ?? null; + const status: CodexThreadGoal["status"] = + statusRaw === "active" || statusRaw === "paused" || statusRaw === "complete" || statusRaw === "cancelled" + ? statusRaw + : statusRaw === "budgetlimited" || statusRaw === "budget_limited" || statusRaw === "budget-limited" + ? "budget_limited" + : statusRaw + ? "unknown" + : undefined; + const normalized: CodexThreadGoal = { + objective: stringOrNull(goalRecord.objective ?? goalRecord.text ?? goalRecord.goal), + tokenBudget: numberOrNull(goalRecord.tokenBudget ?? goalRecord.token_budget), + tokensUsed: numberOrNull(goalRecord.tokensUsed ?? goalRecord.tokens_used), + timeUsedSeconds: numberOrNull(goalRecord.timeUsedSeconds ?? goalRecord.time_used_seconds), + createdAt: codexTimestampOrNull(goalRecord.createdAt ?? goalRecord.created_at), + updatedAt: codexTimestampOrNull(goalRecord.updatedAt ?? goalRecord.updated_at), + ...(status ? { status } : {}), + }; + return Object.values(normalized).some((entry) => entry != null) ? normalized : null; +} + +function normalizeCodexWebSearchAction(value: unknown): CodexWebSearchAction | null { + if (typeof value === "string") { + const action = value.trim(); + return action.length ? { type: action } : null; + } + const record = asRecord(value); + if (!record) return null; + const rawStatus = stringOrNull(record.status)?.toLowerCase(); + const status: CodexWebSearchAction["status"] = + rawStatus === "pending" || rawStatus === "running" || rawStatus === "completed" || rawStatus === "failed" + ? rawStatus + : undefined; + const type = stringOrNull(record.type ?? record.action ?? record.kind) ?? "action"; + return { + type, + ...(status ? { status } : {}), + ...(stringOrNull(record.query) ? { query: stringOrNull(record.query) as string } : {}), + ...(stringOrNull(record.url ?? record.link) ? { url: stringOrNull(record.url ?? record.link) as string } : {}), + ...(stringOrNull(record.title) ? { title: stringOrNull(record.title) as string } : {}), + ...(stringOrNull(record.snippet ?? record.text) ? { snippet: stringOrNull(record.snippet ?? record.text) as string } : {}), + }; +} + +function normalizeCodexWebSearchActions(...values: unknown[]): CodexWebSearchAction[] { + return values + .flatMap((value) => Array.isArray(value) ? value : value == null ? [] : [value]) + .map((value) => normalizeCodexWebSearchAction(value)) + .filter((action): action is CodexWebSearchAction => action != null); +} + const KNOWN_CODEX_EFFORTS = new Set(CODEX_REASONING_EFFORTS.map((e) => e.effort)); const EFFORT_ALIASES: Record> = { @@ -2287,6 +2402,13 @@ function buildStreamingUserContent( for (const attachment of args.attachments) { try { + if (attachment.type === "image-url") { + parts.push({ + type: "text", + text: `\nImage URL: ${attachment.url}`, + }); + continue; + } const data = readFileWithinRootSecure(attachment._rootPath, attachment._resolvedPath); const mediaType = inferAttachmentMediaType(attachment); @@ -4902,6 +5024,27 @@ export function createAgentChatService(args: { } }; + const getCodexResumeContext = (sessionId: string): { + sessionId: string; + threadId: string; + laneWorktreePath: string; + isMission: boolean; + provider: AgentChatProvider; + } | null => { + const managed = managedSessions.get(sessionId); + if (!managed) return null; + const { session, laneWorktreePath } = managed; + const threadId = session.threadId?.trim() ?? ""; + if (!threadId.length) return null; + return { + sessionId, + threadId, + laneWorktreePath, + isMission: session.surface === "mission", + provider: session.provider, + }; + }; + const getChatTranscript = async ({ sessionId, limit = DEFAULT_TRANSCRIPT_READ_LIMIT, @@ -6320,6 +6463,7 @@ export function createAgentChatService(args: { ...(managed.session.capabilityMode ? { capabilityMode: managed.session.capabilityMode } : {}), ...(managed.session.completion ? { completion: managed.session.completion } : {}), ...(managed.session.threadId ? { threadId: managed.session.threadId } : {}), + ...(managed.session.runtimeMode ? { runtimeMode: managed.session.runtimeMode } : {}), ...(managed.runtime?.kind === "droid" && managed.runtime.acpSessionId ? { acpSessionId: managed.runtime.acpSessionId } : {}), @@ -6592,6 +6736,9 @@ export function createAgentChatService(args: { ? { idleSinceAt: null } : {}), ...(codexTerminalTurnIds?.length ? { codexTerminalTurnIds } : {}), + ...(record.runtimeMode === "print" || record.runtimeMode === "interactive" + ? { runtimeMode: record.runtimeMode } + : {}), updatedAt: typeof record.updatedAt === "string" && record.updatedAt.trim().length ? record.updatedAt : nowIso() }; hydrateNativePermissionControls(hydrated as Parameters[0]); @@ -7522,6 +7669,7 @@ export function createAgentChatService(args: { status: mapTerminalStatusToChatStatus(row.status), idleSinceAt: persisted?.idleSinceAt ?? null, ...(persisted?.threadId ? { threadId: persisted.threadId } : {}), + ...(persisted?.runtimeMode ? { runtimeMode: persisted.runtimeMode } : {}), ...(persisted?.requestedCwd != null && String(persisted.requestedCwd).trim().length ? { requestedCwd: String(persisted.requestedCwd).trim() } : {}), @@ -7684,14 +7832,78 @@ export function createAgentChatService(args: { : await buildAutoMemoryTurnPlan(managed, userText, attachments); const autoMemoryNotice = autoMemoryPlan ? buildAutoMemorySystemNotice(autoMemoryPlan) : null; - // Intercept /review command — route to review/start RPC instead of turn/start + // Intercept /review command — route to review/start RPC instead of turn/start. + // ReviewTarget variants (per codex-rs/app-server-protocol/schema/typescript/v2/ReviewTarget.ts): + // { type: "uncommittedChanges" } + // { type: "baseBranch", branch } + // { type: "commit", sha, title } + // { type: "custom", instructions } if (args.promptText.trim().startsWith("/review")) { + const reviewArgs = args.promptText.trim().replace(/^\/review(?:\s+|$)/i, "").trim(); + // Detect the subcommand keyword first (with or without trailing arg) so + // `/review branch` and `/review branch ` both reject cleanly instead of + // falling through to the catch-all "custom" branch. + const branchPrefixMatch = /^branch(?:\s+(.*))?$/i.exec(reviewArgs); + const promptPrefixMatch = /^prompt(?:\s+(.*))?$/i.exec(reviewArgs); + const commitMatch = /^commit\s+(\S+)(?:\s+(.+))?$/i.exec(reviewArgs); + const diffMatch = /^(diff|uncommitted|uncommittedChanges)$/i.test(reviewArgs); + let target: unknown; + let usageError: string | null = null; + if (branchPrefixMatch) { + const branchName = (branchPrefixMatch[1] ?? "").trim(); + if (!branchName.length) { + usageError = "Usage: /review branch ."; + } else { + target = { type: "baseBranch", branch: branchName }; + } + } else if (promptPrefixMatch) { + const promptText = (promptPrefixMatch[1] ?? "").trim(); + if (!promptText.length) { + usageError = "Usage: /review prompt ."; + } else { + target = { type: "custom", instructions: promptText }; + } + } else if (commitMatch) { + const sha = commitMatch[1]!.trim(); + const title = commitMatch[2]?.trim() ?? null; + target = { type: "commit", sha, ...(title ? { title } : { title: null }) }; + } else if (diffMatch || !reviewArgs) { + target = { type: "uncommittedChanges" }; + } else { + target = { type: "custom", instructions: reviewArgs }; + } + if (usageError) { + // Reject malformed /review usage gracefully. We cannot call + // completeInlineCodexSlash here because that helper is declared further + // down in this function (const TDZ), so inline the equivalent emission. + runtime.awaitingTurnStart = false; + const usageTurnId = randomUUID(); + markDispatched(); + persistDeliveredLaneDirectiveKey(managed, args.laneDirectiveKey); + markSessionIdleWithFreshCache(managed); + emitChatEvent(managed, { + type: "system_notice", + noticeKind: "info", + message: usageError, + turnId: usageTurnId, + }); + emitChatEvent(managed, { type: "status", turnStatus: "completed", turnId: usageTurnId }); + emitChatEvent(managed, { + type: "done", + turnId: usageTurnId, + status: "completed", + model: managed.session.model, + ...(managed.session.modelId ? { modelId: managed.session.modelId } : {}), + }); + persistChatState(managed); + return; + } runtime.awaitingTurnStart = true; let reviewResult: { turn?: { id?: string } }; try { reviewResult = await runtime.request<{ turn?: { id?: string } }>("review/start", { threadId: managed.session.threadId, - target: "uncommittedChanges", + target, }); } catch (error) { runtime.awaitingTurnStart = false; @@ -7713,6 +7925,222 @@ export function createAgentChatService(args: { return; } + const completeInlineCodexSlash = (message?: string) => { + const slashTurnId = randomUUID(); + markDispatched(); + persistDeliveredLaneDirectiveKey(managed, args.laneDirectiveKey); + markSessionIdleWithFreshCache(managed); + if (message) { + emitChatEvent(managed, { + type: "system_notice", + noticeKind: "info", + message, + turnId: slashTurnId, + }); + } + emitChatEvent(managed, { type: "status", turnStatus: "completed", turnId: slashTurnId }); + emitChatEvent(managed, { + type: "done", + turnId: slashTurnId, + status: "completed", + model: managed.session.model, + ...(managed.session.modelId ? { modelId: managed.session.modelId } : {}), + }); + persistChatState(managed); + }; + + const slashText = args.promptText.trim(); + let effectivePromptText = args.promptText; + + if (/^\/fast(?:\s|$)/i.test(slashText)) { + const fastArgs = slashText.replace(/^\/fast(?:\s+|$)/i, "").trim().toLowerCase(); + const supported = sessionSupportsCodexFastMode(managed.session); + const current = managed.session.codexFastMode === true && supported; + if (!supported) { + delete managed.session.codexFastMode; + completeInlineCodexSlash("Codex Fast mode is not available for this model."); + return; + } + if (!fastArgs || fastArgs === "toggle") { + const enabled = !current; + managed.session.codexFastMode = enabled; + if (runtime.threadResumed) { + runtime.threadResumed = false; + runtime.canAttachResumedTurnStart = false; + } + completeInlineCodexSlash(`Codex Fast mode is ${enabled ? "on" : "off"}.`); + return; + } + if (fastArgs === "status") { + completeInlineCodexSlash(`Codex Fast mode is ${current ? "on" : "off"}.`); + return; + } + if (fastArgs === "on" || fastArgs === "off") { + const enabled = fastArgs === "on"; + const changed = current !== enabled; + managed.session.codexFastMode = enabled; + if (changed && runtime.threadResumed) { + runtime.threadResumed = false; + runtime.canAttachResumedTurnStart = false; + } + completeInlineCodexSlash(`Codex Fast mode is ${enabled ? "on" : "off"}.`); + return; + } + completeInlineCodexSlash("Usage: /fast [on|off|status]."); + return; + } + + if (/^\/plan(?:\s|$)/i.test(slashText)) { + const planPrompt = slashText.replace(/^\/plan(?:\s+|$)/i, "").trim(); + managed.session.permissionMode = "plan"; + managed.session.interactionMode = "plan"; + managed.session.codexConfigSource = "flags"; + managed.session.codexApprovalPolicy = "on-request"; + managed.session.codexSandbox = "read-only"; + persistChatState(managed); + if (!planPrompt) { + completeInlineCodexSlash("Codex plan mode is on."); + return; + } + effectivePromptText = planPrompt; + } + + if (/^\/compact(?:\s|$)/i.test(slashText)) { + runtime.manualCompactionPending = true; + await runtime.request("thread/compact/start", { + threadId: managed.session.threadId, + }); + completeInlineCodexSlash("Codex context compaction started."); + return; + } + + if (/^\/inject(?:\s|$)/i.test(slashText)) { + const injectBody = slashText.replace(/^\/inject(?:\s+|$)/i, ""); + const trimmed = injectBody.trim(); + if (!trimmed) { + completeInlineCodexSlash("Usage: /inject ."); + return; + } + // Codex's ThreadInjectItemsParams expects raw Responses API items + // (ResponseItem::Message → `{ type: "message", role, content: [ContentItem::InputText] }`), + // not a `{ type: "user_message", text }` shape. Mirror the wire shape used by + // codex-rs/tui/src/app/side.rs::side_boundary_prompt_item. + await runtime.request("thread/inject_items", { + threadId: managed.session.threadId, + items: [ + { + type: "message", + role: "user", + content: [{ type: "input_text", text: trimmed }], + }, + ], + }); + const firstLine = trimmed.split(/\r?\n/)[0] ?? trimmed; + const preview = firstLine.length > 80 ? `${firstLine.slice(0, 77)}...` : firstLine; + emitChatEvent(managed, { + type: "system_notice", + noticeKind: "info", + message: `[injected] ${preview}`, + }); + completeInlineCodexSlash("Context injected into Codex thread history."); + return; + } + + if (/^\/goal(?:\s|$)/i.test(slashText)) { + const goalArgs = slashText.replace(/^\/goal(?:\s+|$)/i, "").trim(); + if (!goalArgs || /^show$/i.test(goalArgs) || /^status$/i.test(goalArgs)) { + const response = await runtime.request<{ goal?: unknown }>("thread/goal/get", { + threadId: managed.session.threadId, + }); + const goal = normalizeCodexGoalPayload(response); + managed.session.codexGoal = goal; + emitChatEvent(managed, { + type: "codex_goal_updated", + goal, + }); + completeInlineCodexSlash(goal?.objective ? "Codex goal is current." : "No active Codex goal."); + return; + } + if (/^(clear|reset|none)$/i.test(goalArgs)) { + await runtime.request("thread/goal/clear", { + threadId: managed.session.threadId, + }); + managed.session.codexGoal = null; + emitChatEvent(managed, { type: "codex_goal_cleared" }); + completeInlineCodexSlash("Codex goal cleared."); + return; + } + const statusMatch = /^status\s+(active|paused|complete)$/i.exec(goalArgs); + const pauseResumeMatch = /^(pause|resume)$/i.exec(goalArgs); + if (/^status(?:\s|$)/i.test(goalArgs) && !statusMatch) { + completeInlineCodexSlash("Usage: /goal status active|paused|complete."); + return; + } + if (statusMatch || pauseResumeMatch) { + const rawStatus = (statusMatch?.[1] ?? pauseResumeMatch?.[1] ?? "active").toLowerCase(); + const status = rawStatus === "pause" ? "paused" : rawStatus === "resume" ? "active" : rawStatus; + const response = await runtime.request<{ goal?: unknown }>("thread/goal/set", { + threadId: managed.session.threadId, + status, + }); + const goal = normalizeCodexGoalPayload(response); + managed.session.codexGoal = goal; + emitChatEvent(managed, { + type: "codex_goal_updated", + goal, + }); + completeInlineCodexSlash(`Codex goal ${status === "active" ? "resumed" : status}.`); + return; + } + const budgetMatch = /^budget\s+(.+)$/i.exec(goalArgs); + if (/^budget(?:\s|$)/i.test(goalArgs) && !budgetMatch) { + completeInlineCodexSlash("Usage: /goal budget |clear."); + return; + } + if (budgetMatch) { + const rawBudget = budgetMatch[1]?.trim() ?? ""; + const budgetDigits = rawBudget.replace(/_/g, ""); + const tokenBudget = /^(clear|none|reset)$/i.test(rawBudget) + ? null + : /^\d+$/.test(budgetDigits) + ? Number.parseInt(budgetDigits, 10) + : Number.NaN; + if (tokenBudget !== null && (!Number.isSafeInteger(tokenBudget) || tokenBudget < 1)) { + completeInlineCodexSlash("Usage: /goal budget |clear."); + return; + } + const response = await runtime.request<{ goal?: unknown }>("thread/goal/set", { + threadId: managed.session.threadId, + tokenBudget, + }); + const goal = normalizeCodexGoalPayload(response); + managed.session.codexGoal = goal; + emitChatEvent(managed, { + type: "codex_goal_updated", + goal, + }); + completeInlineCodexSlash(tokenBudget === null ? "Codex goal budget cleared." : `Codex goal budget set to ${tokenBudget}.`); + return; + } + const objective = goalArgs.replace(/^set\s+/i, "").trim(); + if (!objective) { + completeInlineCodexSlash("No Codex goal text was provided."); + return; + } + const response = await runtime.request<{ goal?: unknown }>("thread/goal/set", { + threadId: managed.session.threadId, + objective, + }); + const goal = normalizeCodexGoalPayload(response); + managed.session.codexGoal = goal; + emitChatEvent(managed, { + type: "codex_goal_updated", + goal, + }); + completeInlineCodexSlash("Codex goal updated."); + return; + } + const input: Array> = []; const reconstructionContext = providerSlashCommand ? "" : managed.pendingReconstructionContext?.trim() ?? ""; @@ -7774,11 +8202,15 @@ export function createAgentChatService(args: { } input.push({ type: "text", - text: args.promptText, + text: effectivePromptText, text_elements: [] }); for (const attachment of resolvedAttachments) { + if (attachment.type === "image-url") { + input.push({ type: "image", url: attachment.url }); + continue; + } const stagedPath = stageAttachmentForCodexInput(attachment); if (attachment.type === "image") { input.push({ type: "localImage", path: stagedPath }); @@ -7797,8 +8229,6 @@ export function createAgentChatService(args: { ...(managed.session.reasoningEffort ? { effort: managed.session.reasoningEffort, - reasoningEffort: managed.session.reasoningEffort, - reasoning_effort: managed.session.reasoningEffort, } : {}), ...codexServiceTierArgs(managed.session), @@ -9824,12 +10254,21 @@ export function createAgentChatService(args: { return; } - runtime.sendError(id, `Unsupported server request: ${method || "unknown"}`); + if ( + method === "attestation/generate" + || method === "account/chatgptAuthTokens/refresh" + || method === "item/tool/call" + ) { + runtime.sendError(id, `ADE does not provide Codex app-server capability '${method}'.`, -32601); + return; + } + + runtime.sendError(id, `Unsupported server request: ${method || "unknown"}`, -32601); }; const parseCodexPlanPayload = ( value: unknown, - ): { steps: Array<{ text: string; status: "pending" | "in_progress" | "completed" | "failed" }>; explanation: string | null } | null => { + ): { steps: AgentChatPlanStep[]; explanation: string | null } | null => { const record = (() => { if (typeof value !== "string") return asRecord(value); try { @@ -9883,6 +10322,7 @@ export function createAgentChatService(args: { runtime: CodexRuntime, payload: unknown, turnId: string | undefined, + options?: { itemId?: string; state?: CodexPlanState; streamingText?: string }, ): boolean => { const normalized = parseCodexPlanPayload(payload); if (!normalized) return false; @@ -9891,6 +10331,9 @@ export function createAgentChatService(args: { type: "plan", steps: normalized.steps, ...(turnId ? { turnId } : {}), + ...(options?.itemId ? { itemId: options.itemId } : {}), + ...(options?.state ? { state: options.state } : {}), + ...(options?.streamingText ? { streamingText: options.streamingText } : {}), explanation: normalized.explanation, }); @@ -10106,28 +10549,57 @@ export function createAgentChatService(args: { })(); if (itemType === "contextCompaction") { - // Codex emits contextCompaction via item/started + item/completed. - // Emit the boundary event once, on completion, so the UI badge matches - // Claude's post-compaction behavior. + const compactionTurnId = turnId ?? ""; + if (eventKind === "started") { + if (runtime.manualCompactionPending) { + runtime.manualCompactionItemIds.add(itemId); + runtime.manualCompactionPending = false; + } + const trigger = runtime.manualCompactionItemIds.has(itemId) ? "manual" : "auto"; + emitChatEvent(managed, { + type: "codex_context_compaction", + state: "started", + trigger, + turnId: compactionTurnId, + }); + return; + } if (eventKind === "completed") { + const trigger = runtime.manualCompactionItemIds.has(itemId) ? "manual" : "auto"; emitChatEvent(managed, { - type: "context_compact", - trigger: "auto", - turnId, + type: "codex_context_compaction", + state: "completed", + trigger, + turnId: compactionTurnId, }); + runtime.manualCompactionItemIds.delete(itemId); } return; } if (itemType === "plan") { + if (eventKind === "started") { + emitChatEvent(managed, { + type: "plan", + steps: [], + streamingText: "", + explanation: null, + state: "active", + turnId, + itemId, + }); + return; + } if (eventKind === "completed") { const hadStreamingText = runtime.planTextByItemId.has(itemId); const planText = readCodexPlanTextFromItem(item) ?? runtime.planTextByItemId.get(itemId) ?? null; if (planText) { if (!hadStreamingText) { emitChatEvent(managed, { - type: "plan_text", - text: planText, + type: "plan", + steps: [], + streamingText: planText, + state: "complete", turnId, itemId, }); @@ -10407,10 +10879,16 @@ export function createAgentChatService(args: { if (eventKind === "completed") { status = String(item.status ?? "completed") === "failed" ? "failed" : "completed"; } + const actions = normalizeCodexWebSearchActions(item.action, item.actions); + if (actions.length) { + runtime.webSearchActionsByItemId.set(itemId, actions); + evictOldestEntries(runtime.webSearchActionsByItemId, MAX_SESSION_MAP_ENTRIES); + } emitChatEvent(managed, { type: "web_search", query: String(item.query ?? ""), - action: typeof item.action === "string" ? item.action : undefined, + action: actions[0]?.type, + ...(actions.length ? { actions } : {}), itemId, turnId, status, @@ -10418,6 +10896,53 @@ export function createAgentChatService(args: { return; } + if (itemType === "imageGeneration") { + const status = eventKind === "completed" + ? String(item.status ?? "completed") === "failed" ? "failed" : "completed" + : "running"; + const result = stringOrNull(item.result ?? item.url ?? item.path ?? item.image); + // savedPath: only set if Codex reports a local filesystem path (not an http(s)/data URL). + const localPathField = stringOrNull(item.path ?? item.savedPath ?? item.saved_path); + const looksLikeLocalPath = (value: string | null): boolean => { + if (!value) return false; + if (/^https?:\/\//i.test(value)) return false; + if (/^data:/i.test(value)) return false; + return value.startsWith("/") || /^[a-zA-Z]:[\\/]/.test(value) || value.startsWith("~"); + }; + const savedPath = looksLikeLocalPath(localPathField) + ? localPathField + : looksLikeLocalPath(result) + ? result + : null; + emitChatEvent(managed, { + type: "codex_image_generation", + itemId, + turnId, + prompt: stringOrNull(item.prompt), + revisedPrompt: stringOrNull(item.revisedPrompt ?? item.revised_prompt), + result, + savedPath, + status, + }); + return; + } + + if (itemType === "imageView") { + const status = eventKind === "completed" + ? String(item.status ?? "completed") === "failed" ? "failed" : "completed" + : "running"; + emitChatEvent(managed, { + type: "codex_image_view", + itemId, + turnId, + path: stringOrNull(item.path), + url: stringOrNull(item.url), + title: stringOrNull(item.title ?? item.name), + status, + }); + return; + } + // Planning items → todo_update if (itemType === "planningItem" || itemType === "planning") { const steps = Array.isArray(item.steps) ? item.steps : Array.isArray(item.plan) ? item.plan : []; @@ -10593,6 +11118,7 @@ export function createAgentChatService(args: { } } runtime.planTextByItemId.clear(); + runtime.webSearchActionsByItemId.clear(); runtime.itemTurnIdByItemId.clear(); runtime.agentMessageScopeByTurn.clear(); runtime.agentMessageTextByTurn.clear(); @@ -10793,6 +11319,7 @@ export function createAgentChatService(args: { explanation: typeof params.explanation === "string" ? params.explanation : null, }, typeof params.turnId === "string" ? params.turnId : runtime.activeTurnId ?? undefined, + { state: "updated" }, ); return; } @@ -10845,6 +11372,7 @@ export function createAgentChatService(args: { runtime.fileDeltaByItemId.clear(); runtime.fileChangesByItemId.clear(); runtime.planTextByItemId.clear(); + runtime.webSearchActionsByItemId.clear(); runtime.agentMessageScopeByTurn.clear(); runtime.agentMessageTextByTurn.clear(); runtime.recentNotificationKeys.clear(); @@ -10877,6 +11405,12 @@ export function createAgentChatService(args: { if (method === "codex/event/web_search_begin") { const query = pickCodexTurnId(params.query, params.searchQuery, params.input) ?? ""; + const itemId = typeof params.itemId === "string" ? params.itemId : randomUUID(); + const actions = normalizeCodexWebSearchActions(params.action, params.actions); + if (actions.length) { + runtime.webSearchActionsByItemId.set(itemId, actions); + evictOldestEntries(runtime.webSearchActionsByItemId, MAX_SESSION_MAP_ENTRIES); + } emitChatEvent(managed, { type: "activity", activity: "web_searching", @@ -10886,13 +11420,50 @@ export function createAgentChatService(args: { emitChatEvent(managed, { type: "web_search", query, - itemId: typeof params.itemId === "string" ? params.itemId : randomUUID(), + itemId, turnId: turnIdFromParams ?? runtime.activeTurnId ?? undefined, + ...(actions.length ? { actions } : {}), status: "running", }); return; } + if (method === "thread/tokenUsage/updated") { + const usage = normalizeCodexThreadTokenUsage(params); + if (usage) { + managed.session.codexTokenUsage = usage; + emitChatEvent(managed, { + type: "codex_token_usage", + usage, + turnId: usage.turnId ?? turnIdFromParams ?? runtime.activeTurnId ?? undefined, + }); + persistChatState(managed); + } + return; + } + + if (method === "thread/goal/updated") { + const goal = normalizeCodexGoalPayload(params); + managed.session.codexGoal = goal; + emitChatEvent(managed, { + type: "codex_goal_updated", + goal, + turnId: turnIdFromParams ?? runtime.activeTurnId ?? undefined, + }); + persistChatState(managed); + return; + } + + if (method === "thread/goal/cleared") { + managed.session.codexGoal = null; + emitChatEvent(managed, { + type: "codex_goal_cleared", + turnId: turnIdFromParams ?? runtime.activeTurnId ?? undefined, + }); + persistChatState(managed); + return; + } + if ( method === "thread/status/changed" || method === "codex/event/task_started" @@ -10949,6 +11520,34 @@ export function createAgentChatService(args: { return; } + if (method === "deprecationNotice" || method === "warning" || method === "guardianWarning" || method === "configWarning") { + const messageText = typeof params.message === "string" + ? params.message + : typeof params.detail === "string" + ? params.detail + : ""; + const trimmed = messageText.trim(); + if (!trimmed) return; + const prefixed = method === "deprecationNotice" + ? `⚠ deprecated: ${trimmed}` + : method === "warning" + ? `⚠ ${trimmed}` + : method === "guardianWarning" + ? `🛡 guardian: ${trimmed}` + : `⚙ config: ${trimmed}`; + const noticeKind = method === "guardianWarning" + ? "error" as const + : method === "configWarning" + ? "config" as const + : "warning" as const; + emitChatEvent(managed, { + type: "system_notice", + noticeKind, + message: prefixed, + }); + return; + } + if (method === "item/plan/delta") { const explicitItemId = typeof params.itemId === "string" && params.itemId.trim().length ? params.itemId @@ -10965,8 +11564,10 @@ export function createAgentChatService(args: { return; } emitChatEvent(managed, { - type: "plan_text", - text: delta, + type: "plan", + steps: [], + streamingText: next, + state: "delta", turnId, itemId, }); @@ -11056,7 +11657,6 @@ export function createAgentChatService(args: { } if (missionCodexHome) { appServerArgs.push("-c", "mcp_servers={}"); - appServerArgs.push("--disable", "plugins", "--disable", "apps", "--disable", "browser_use", "--disable", "computer_use"); } const invocation = resolveCliSpawnInvocation(codexExecutable, appServerArgs); const proc = spawn(invocation.command, invocation.args, { @@ -11089,6 +11689,9 @@ export function createAgentChatService(args: { fileDeltaByItemId: new Map(), fileChangesByItemId: new Map>(), planTextByItemId: new Map(), + manualCompactionItemIds: new Set(), + manualCompactionPending: false, + webSearchActionsByItemId: new Map(), activeSubagents: new Map(), interruptedTurnIds: new Set(), ignoredTurnIds: new Set(), @@ -11107,7 +11710,6 @@ export function createAgentChatService(args: { runtime.nextRequestId += 1; const payload: JsonRpcEnvelope = { - jsonrpc: "2.0", id, method, ...(params !== undefined ? { params } : {}) @@ -11125,7 +11727,6 @@ export function createAgentChatService(args: { notify: (method: string, params?: unknown) => { if (!proc.stdin.writable) return; const payload: JsonRpcEnvelope = { - jsonrpc: "2.0", method, ...(params !== undefined ? { params } : {}) }; @@ -11133,12 +11734,12 @@ export function createAgentChatService(args: { }, sendResponse: (id: string | number, result: unknown) => { if (!proc.stdin.writable) return; - proc.stdin.write(`${JSON.stringify({ jsonrpc: "2.0", id, result })}\n`); + proc.stdin.write(`${JSON.stringify({ id, result })}\n`); }, - sendError: (id: string | number, message: string) => { + sendError: (id: string | number, message: string, code = -32001) => { if (!proc.stdin.writable) return; proc.stdin.write( - `${JSON.stringify({ jsonrpc: "2.0", id, error: { code: -32001, message } })}\n` + `${JSON.stringify({ id, error: { code, message } })}\n` ); } }; @@ -11256,14 +11857,23 @@ export function createAgentChatService(args: { }); }); + const optOutNotificationMethods = managed.session.runtimeMode === "print" + ? [ + "item/agentMessage/delta", + "item/reasoning/summaryTextDelta", + "item/reasoning/textDelta", + "item/commandExecution/outputDelta", + ] + : []; await runtime.request("initialize", { clientInfo: { - name: "ade", - title: "ADE", + name: "ade_desktop", + title: "ADE Desktop", version: appVersion }, capabilities: { - experimentalApi: true + experimentalApi: true, + optOutNotificationMethods, } }); @@ -11349,8 +11959,6 @@ export function createAgentChatService(args: { const startResponse = await runtime.request("thread/start", { model: managed.session.model, cwd: managed.laneWorktreePath, - reasoningEffort, - reasoning_effort: reasoningEffort, effort: reasoningEffort, ...codexServiceTierArgs(managed.session), ...codexPolicyArgs(codexPolicy), @@ -12013,12 +12621,19 @@ export function createAgentChatService(args: { !!a && typeof a === "object" && typeof (a as AgentChatFileRef).path === "string" - && ((a as AgentChatFileRef).type === "file" || (a as AgentChatFileRef).type === "image")) + && ((a as AgentChatFileRef).type === "file" || (a as AgentChatFileRef).type === "image" || (a as AgentChatFileRef).type === "image-url")) : []; const contextAttachments = normalizeChatContextAttachments(entry.contextAttachments); let resolvedAttachments: ResolvedAgentChatFileRef[] = []; try { resolvedAttachments = attachments.map((attachment) => { + if (attachment.type === "image-url") { + return { + ...attachment, + _resolvedPath: attachment.url, + _rootPath: projectRoot, + }; + } const isAbsolute = path.isAbsolute(attachment.path); const root = isAbsolute ? projectRoot : managed.laneWorktreePath; return { @@ -12318,6 +12933,7 @@ export function createAgentChatService(args: { automationId, automationRunId, requestedCwd, + runtimeMode, }: AgentChatCreateArgs): Promise => { const launchContext = resolveLaneLaunchContext({ laneService, @@ -12532,6 +13148,7 @@ export function createAgentChatService(args: { ...(typeof requestedCwd === "string" && requestedCwd.trim().length ? { requestedCwd: requestedCwd.trim() } : {}), + ...(runtimeMode === "print" ? { runtimeMode: "print" as const } : {}), }, transcriptPath, transcriptBytesWritten: fileSizeOrZero(transcriptPath), @@ -12748,6 +13365,23 @@ export function createAgentChatService(args: { if (!rawPath.length) { throw new Error("Attachment path is required."); } + if (attachment.type === "image-url") { + try { + const parsed = new URL((attachment.url || rawPath).trim()); + if (parsed.protocol !== "http:" && parsed.protocol !== "https:") { + throw new Error("unsupported protocol"); + } + return { + ...attachment, + path: rawPath, + url: parsed.toString(), + _resolvedPath: parsed.toString(), + _rootPath: projectRoot, + }; + } catch { + throw new Error(`Image URL attachment must be an http(s) URL: ${rawPath}`); + } + } const isAbsolute = path.isAbsolute(rawPath); const root = isAbsolute ? projectRoot : managed.laneWorktreePath; try { @@ -15894,11 +16528,10 @@ export function createAgentChatService(args: { threadId: threadIdToResume, model: managed.session.model, cwd: managed.laneWorktreePath, - reasoningEffort: resumeReasoningEffort, - reasoning_effort: resumeReasoningEffort, effort: resumeReasoningEffort, ...codexServiceTierArgs(managed.session), ...codexPolicyArgs(codexPolicy), + excludeTurns: true, persistExtendedHistory: true }); applyCodexEffectiveThreadState(managed, resumeResponse, { @@ -16307,6 +16940,10 @@ export function createAgentChatService(args: { }); } for (const attachment of preparedSteer.resolvedAttachments) { + if (attachment.type === "image-url") { + input.push({ type: "image", url: attachment.url }); + continue; + } const stagedPath = stageAttachmentForCodexInput(attachment); if (attachment.type === "image") { input.push({ type: "localImage", path: stagedPath }); @@ -16802,11 +17439,10 @@ export function createAgentChatService(args: { threadId, model: managed.session.model, cwd: managed.laneWorktreePath, - reasoningEffort: managed.session.reasoningEffort, - reasoning_effort: managed.session.reasoningEffort, effort: managed.session.reasoningEffort, ...codexServiceTierArgs(managed.session), ...codexPolicyArgs(codexPolicy), + excludeTurns: true, persistExtendedHistory: true }); applyCodexEffectiveThreadState(managed, resumeResponse, { @@ -18786,6 +19422,7 @@ export function createAgentChatService(args: { listSessions, getSessionSummary, getChatTranscript, + getCodexResumeContext, getChatEventHistory, ensureIdentitySession, approveToolUse, diff --git a/apps/desktop/src/main/services/chat/chatTextBatching.test.ts b/apps/desktop/src/main/services/chat/chatTextBatching.test.ts index d2214c63a..9642574d3 100644 --- a/apps/desktop/src/main/services/chat/chatTextBatching.test.ts +++ b/apps/desktop/src/main/services/chat/chatTextBatching.test.ts @@ -332,14 +332,6 @@ describe("chatTextBatching", () => { })).toBe(false); }); - it("does not flush for plan_text events", () => { - expect(shouldFlushBufferedAssistantTextForEvent({ - type: "plan_text", - text: "- step one", - turnId: "turn-1", - })).toBe(false); - }); - it("does not flush for subagent lifecycle events", () => { expect(shouldFlushBufferedAssistantTextForEvent({ type: "subagent_started", @@ -445,5 +437,34 @@ describe("chatTextBatching", () => { turnId: "turn-1", } as any)).toBe(true); }); + + it("does not flush for plan events that carry streaming text", () => { + expect(shouldFlushBufferedAssistantTextForEvent({ + type: "plan", + steps: [], + streamingText: "partial", + state: "delta", + turnId: "turn-1", + } as any)).toBe(false); + }); + + it("does not flush for plan item/started events with empty streaming text", () => { + expect(shouldFlushBufferedAssistantTextForEvent({ + type: "plan", + steps: [], + streamingText: "", + state: "active", + turnId: "turn-1", + } as any)).toBe(false); + }); + + it("flushes for structured plan updates without streaming text", () => { + expect(shouldFlushBufferedAssistantTextForEvent({ + type: "plan", + steps: [{ text: "step", status: "pending" }], + state: "updated", + turnId: "turn-1", + } as any)).toBe(true); + }); }); }); diff --git a/apps/desktop/src/main/services/chat/chatTextBatching.ts b/apps/desktop/src/main/services/chat/chatTextBatching.ts index 80ccc6df2..a41b20f0c 100644 --- a/apps/desktop/src/main/services/chat/chatTextBatching.ts +++ b/apps/desktop/src/main/services/chat/chatTextBatching.ts @@ -58,11 +58,12 @@ export function shouldFlushBufferedAssistantTextForEvent(event: AgentChatEvent): case "text": case "reasoning": case "activity": - case "plan_text": case "subagent_started": case "subagent_progress": case "subagent_result": return false; + case "plan": + return event.streamingText === undefined; default: return true; } diff --git a/apps/desktop/src/main/services/chat/codexCliLauncher.test.ts b/apps/desktop/src/main/services/chat/codexCliLauncher.test.ts new file mode 100644 index 000000000..56d570ee8 --- /dev/null +++ b/apps/desktop/src/main/services/chat/codexCliLauncher.test.ts @@ -0,0 +1,157 @@ +import { describe, expect, it, vi } from "vitest"; + +vi.mock("node:child_process", () => { + const execFileImpl = vi.fn(); + const spawnImpl = vi.fn(); + return { + execFile: execFileImpl, + spawn: spawnImpl, + __execFileMock: execFileImpl, + __spawnMock: spawnImpl, + }; +}); + +import * as cp from "node:child_process"; +import { + buildResumeArgv, + detectCodexResumeStrategy, + shellQuote, + spawnInNewTerminalWindow, +} from "./codexCliLauncher"; + +type MockedCp = typeof cp & { + __execFileMock: ReturnType; + __spawnMock: ReturnType; +}; + +const mocked = cp as MockedCp; + +function stubExecFile(stdout: string, stderr = ""): void { + mocked.__execFileMock.mockImplementation(((...allArgs: unknown[]) => { + const cb = allArgs[allArgs.length - 1]; + if (typeof cb === "function") { + setImmediate(() => (cb as (err: Error | null, stdout: string, stderr: string) => void)(null, stdout, stderr)); + } + return {} as never; + }) as never); +} + +function stubExecFileError(): void { + mocked.__execFileMock.mockImplementation(((...allArgs: unknown[]) => { + const cb = allArgs[allArgs.length - 1]; + if (typeof cb === "function") { + setImmediate(() => (cb as (err: Error) => void)(new Error("ENOENT"))); + } + return {} as never; + }) as never); +} + +describe("codexCliLauncher", () => { + describe("detectCodexResumeStrategy", () => { + it("picks the resume subcommand when --help advertises it", async () => { + stubExecFile("Usage: codex [OPTIONS] [COMMAND]\nCommands:\n resume Resume a thread\n ..."); + const strategy = await detectCodexResumeStrategy("/usr/local/bin/codex"); + expect(strategy.flagForm.kind).toBe("subcommand"); + expect(strategy.copyThreadIdToClipboard).toBe(false); + expect(buildResumeArgv(strategy, "abc-123")).toEqual(["resume", "abc-123"]); + }); + + it("falls back to --thread when only the flag is in help", async () => { + stubExecFile("Usage: codex [OPTIONS]\n --thread Resume a specific thread\n"); + const strategy = await detectCodexResumeStrategy("/usr/local/bin/codex"); + expect(strategy.flagForm.kind).toBe("long-flag"); + expect(strategy.copyThreadIdToClipboard).toBe(false); + expect(buildResumeArgv(strategy, "abc-123")).toEqual(["--thread", "abc-123"]); + }); + + it("falls back to interactive launch + clipboard when neither form exists", async () => { + stubExecFile("Usage: codex [OPTIONS]\nNo resume support"); + const strategy = await detectCodexResumeStrategy("/usr/local/bin/codex"); + expect(strategy.flagForm.kind).toBe("interactive"); + expect(strategy.copyThreadIdToClipboard).toBe(true); + expect(buildResumeArgv(strategy, "abc-123")).toEqual([]); + }); + + it("falls back to interactive when the --help probe itself fails", async () => { + stubExecFileError(); + const strategy = await detectCodexResumeStrategy("/usr/local/bin/codex"); + expect(strategy.flagForm.kind).toBe("interactive"); + expect(strategy.copyThreadIdToClipboard).toBe(true); + }); + }); + + describe("shellQuote", () => { + it("wraps in double quotes and escapes embedded quotes", () => { + expect(shellQuote("simple")).toBe("\"simple\""); + expect(shellQuote("with space")).toBe("\"with space\""); + expect(shellQuote("it's \"tricky\"")).toBe("\"it's \\\"tricky\\\"\""); + }); + }); + + describe("spawnInNewTerminalWindow", () => { + it("uses osascript on darwin", () => { + const spawnMock = mocked.__spawnMock; + spawnMock.mockReset(); + const fakeChild = { unref: vi.fn() }; + spawnMock.mockReturnValue(fakeChild as never); + + spawnInNewTerminalWindow({ + binary: "/usr/local/bin/codex", + argv: ["resume", "abc-123"], + cwd: "/tmp/lane", + platform: "darwin", + }); + + expect(spawnMock).toHaveBeenCalledTimes(1); + const [bin, args, opts] = spawnMock.mock.calls[0]!; + expect(bin).toBe("osascript"); + expect(args[0]).toBe("-e"); + expect(args[1]).toContain("Terminal"); + expect(args[1]).toContain("/tmp/lane"); + expect(args[1]).toContain("resume"); + expect(args[1]).toContain("abc-123"); + expect((opts as { detached: boolean }).detached).toBe(true); + expect(fakeChild.unref).toHaveBeenCalled(); + }); + + it("uses cmd /C start cmd /K on win32", () => { + const spawnMock = mocked.__spawnMock; + spawnMock.mockReset(); + const fakeChild = { unref: vi.fn() }; + spawnMock.mockReturnValue(fakeChild as never); + + spawnInNewTerminalWindow({ + binary: "C:\\codex.exe", + argv: ["resume", "abc-123"], + cwd: "C:\\lane", + platform: "win32", + }); + + const [bin, args] = spawnMock.mock.calls[0]!; + expect(bin).toBe("cmd.exe"); + expect(args[0]).toBe("/C"); + expect(args[1]).toBe("start"); + expect(args[2]).toBe("cmd"); + expect(args[3]).toBe("/K"); + expect(args[4]).toContain("resume"); + expect(args[4]).toContain("abc-123"); + }); + + it("falls through to gnome-terminal on linux", () => { + const spawnMock = mocked.__spawnMock; + spawnMock.mockReset(); + const fakeChild = { unref: vi.fn() }; + spawnMock.mockReturnValue(fakeChild as never); + + spawnInNewTerminalWindow({ + binary: "/usr/local/bin/codex", + argv: ["resume", "abc-123"], + cwd: "/tmp/lane", + platform: "linux", + }); + + const [bin] = spawnMock.mock.calls[0]!; + expect(bin).toBe("gnome-terminal"); + }); + }); +}); diff --git a/apps/desktop/src/main/services/chat/codexCliLauncher.ts b/apps/desktop/src/main/services/chat/codexCliLauncher.ts new file mode 100644 index 000000000..92ca0620d --- /dev/null +++ b/apps/desktop/src/main/services/chat/codexCliLauncher.ts @@ -0,0 +1,146 @@ +import { execFile, spawn } from "node:child_process"; + +function execFileAsync( + binary: string, + args: string[], + options: { timeout?: number }, +): Promise<{ stdout: string; stderr: string }> { + return new Promise((resolve, reject) => { + execFile(binary, args, options, (err, stdout, stderr) => { + if (err) { + reject(err); + return; + } + resolve({ stdout: stdout.toString(), stderr: stderr.toString() }); + }); + }); +} + +export type CodexResumeFlagForm = + | { kind: "subcommand"; argv: (threadId: string) => string[] } + | { kind: "long-flag"; argv: (threadId: string) => string[] } + | { kind: "interactive"; argv: () => string[] }; + +export type CodexResumeStrategy = { + /** Path to the codex binary (typically bundled). */ + binary: string; + /** How to launch a resume. `interactive` means launch codex without args and rely on the user's Ctrl+R picker. */ + flagForm: CodexResumeFlagForm; + /** True when we could not detect a `resume` subcommand or `--thread` flag; the caller should copy the threadId to clipboard. */ + copyThreadIdToClipboard: boolean; +}; + +/** + * Probe `codex --help` and decide which flag form to use to resume a specific thread. + * Returns a strategy that tells the caller how to spawn codex (and whether to also + * copy the threadId to the clipboard as a fallback when no direct flag exists). + */ +export async function detectCodexResumeStrategy(binary: string): Promise { + let helpText = ""; + try { + const { stdout, stderr } = await execFileAsync(binary, ["--help"], { timeout: 5000 }); + helpText = `${stdout}\n${stderr}`; + } catch (probeError) { + // If we can't read --help, fall back to interactive launch with clipboard. + return { + binary, + flagForm: { kind: "interactive", argv: () => [] }, + copyThreadIdToClipboard: true, + }; + } + + const lower = helpText.toLowerCase(); + // Prefer the explicit `resume` subcommand (post-0.130 form). Look for it in + // a subcommand-list context (e.g. " resume Resume a thread" or "Commands: + // resume ...") rather than as any English word in the help text. + const subcommandPatterns = [ + /(^|\n)\s+resume(\s|$)/, // indented list item + /commands:[\s\S]*?\bresume\b/, // anywhere in a "Commands:" section + /subcommands:[\s\S]*?\bresume\b/, + ]; + if (subcommandPatterns.some((re) => re.test(lower))) { + return { + binary, + flagForm: { kind: "subcommand", argv: (id) => ["resume", id] }, + copyThreadIdToClipboard: false, + }; + } + if (/--thread\b/.test(lower)) { + return { + binary, + flagForm: { kind: "long-flag", argv: (id) => ["--thread", id] }, + copyThreadIdToClipboard: false, + }; + } + return { + binary, + flagForm: { kind: "interactive", argv: () => [] }, + copyThreadIdToClipboard: true, + }; +} + +export function buildResumeArgv(strategy: CodexResumeStrategy, threadId: string): string[] { + if (strategy.flagForm.kind === "interactive") return strategy.flagForm.argv(); + return strategy.flagForm.argv(threadId); +} + +/** Quote a single arg for an interactive shell command. Wraps in double-quotes + * and escapes embedded backslashes/double-quotes. Suitable for `cmd /K` on + * Windows and POSIX shells alike. */ +export function shellQuote(arg: string): string { + return `"${arg.replace(/\\/g, "\\\\").replace(/"/g, "\\\"")}"`; +} + +export type SpawnNewTerminalOptions = { + binary: string; + argv: string[]; + cwd: string; + platform?: NodeJS.Platform; +}; + +/** + * Launch the user's default terminal with `codex ` running inside, cd'd + * to `cwd`. Returns once the launcher process has been spawned (detached). + */ +export function spawnInNewTerminalWindow(options: SpawnNewTerminalOptions): void { + const platform = options.platform ?? process.platform; + const command = [options.binary, ...options.argv].map(shellQuote).join(" "); + const cdCommand = `cd ${shellQuote(options.cwd)}`; + + if (platform === "darwin") { + // Use osascript so we can set cwd cleanly and `do script` runs an interactive shell. + const script = `tell application "Terminal" to do script "${cdCommand.replace(/"/g, "\\\"")} && ${command.replace(/"/g, "\\\"")}"`; + const child = spawn("osascript", ["-e", script], { detached: true, stdio: "ignore" }); + child.unref(); + return; + } + + if (platform === "win32") { + // `start cmd /K " && "` opens a new console window that stays open after the command exits. + const inner = `${cdCommand} && ${command}`; + const child = spawn("cmd.exe", ["/C", "start", "cmd", "/K", inner], { detached: true, stdio: "ignore", windowsHide: false }); + child.unref(); + return; + } + + // Linux/BSD: try a list of terminals; use the first one that's on PATH. + const candidates: Array<{ bin: string; argv: (script: string) => string[] }> = [ + { bin: "gnome-terminal", argv: (s) => ["--", "bash", "-c", s] }, + { bin: "konsole", argv: (s) => ["-e", "bash", "-c", s] }, + { bin: "xfce4-terminal", argv: (s) => ["-e", `bash -c ${shellQuote(s)}`] }, + { bin: "xterm", argv: (s) => ["-e", "bash", "-c", s] }, + ]; + const innerScript = `${cdCommand} && ${command}; exec bash`; + for (const candidate of candidates) { + try { + const child = spawn(candidate.bin, candidate.argv(innerScript), { detached: true, stdio: "ignore" }); + child.unref(); + return; + } catch { + // try next + } + } + // Last resort: xdg-terminal (often a shim on modern desktops). + const child = spawn("xdg-terminal", [`${cdCommand} && ${command}`], { detached: true, stdio: "ignore" }); + child.unref(); +} diff --git a/apps/desktop/src/main/services/ipc/registerIpc.ts b/apps/desktop/src/main/services/ipc/registerIpc.ts index e6188a2e0..5f037a533 100644 --- a/apps/desktop/src/main/services/ipc/registerIpc.ts +++ b/apps/desktop/src/main/services/ipc/registerIpc.ts @@ -228,6 +228,8 @@ import type { ExportHistoryResult, AgentChatApproveArgs, AgentChatArchiveArgs, + AgentChatCodexOpenInCliArgs, + AgentChatCodexOpenInCliResult, AgentChatClaudePermissionMode, AgentChatCreateArgs, AgentChatDeleteArgs, @@ -687,6 +689,12 @@ import type { createProjectScaffoldService } from "../projects/projectScaffoldSe import type { createAdeCliService } from "../cli/adeCliService"; import { getErrorMessage, isRecord, nowIso, resolvePathWithinRoot, toMemoryEntryDto } from "../shared/utils"; import { quoteWindowsCmdArg } from "../shared/processExecution"; +import { resolveCodexExecutable } from "../ai/codexExecutable"; +import { + buildResumeArgv, + detectCodexResumeStrategy, + spawnInNewTerminalWindow, +} from "../chat/codexCliLauncher"; export type AppContext = { db: AdeDb; @@ -6509,6 +6517,50 @@ export function registerIpc({ return ctx.agentChatService.getChatEventHistory(sessionId, maxEvents != null ? { maxEvents } : undefined); }); + ipcMain.handle(IPC.agentChatCodexOpenInCli, async ( + _event, + arg: AgentChatCodexOpenInCliArgs, + ): Promise => { + const ctx = getCtx(); + const sessionId = typeof arg?.sessionId === "string" ? arg.sessionId.trim() : ""; + const mode = arg?.mode === "new-window" ? "new-window" : "ade-terminal"; + if (!sessionId) { + throw new Error("agentChat.codex.openInCli requires a sessionId"); + } + if (!ctx.agentChatService) { + throw new Error("Open in Codex CLI is unavailable until a project is loaded in this window."); + } + const resumeCtx = ctx.agentChatService.getCodexResumeContext(sessionId); + if (!resumeCtx) { + throw new Error(`No resumable Codex thread for session ${sessionId}`); + } + if (resumeCtx.provider !== "codex") { + throw new Error("Open-in-CLI is only supported for Codex sessions"); + } + if (resumeCtx.isMission) { + throw new Error("Mission sessions cannot be resumed in Codex CLI (ephemeral CODEX_HOME)"); + } + const resolved = resolveCodexExecutable(); + const strategy = await detectCodexResumeStrategy(resolved.path); + const argv = buildResumeArgv(strategy, resumeCtx.threadId); + const result: AgentChatCodexOpenInCliResult = { + binary: resolved.path, + argv, + cwd: resumeCtx.laneWorktreePath, + threadId: resumeCtx.threadId, + copyThreadIdToClipboard: strategy.copyThreadIdToClipboard, + }; + if (mode === "new-window") { + spawnInNewTerminalWindow({ + binary: resolved.path, + argv, + cwd: resumeCtx.laneWorktreePath, + }); + result.spawnedNewWindow = true; + } + return result; + }); + ipcMain.handle(IPC.computerUseListArtifacts, async (_event, arg: ComputerUseArtifactListArgs = {}): Promise => { const ctx = ensureComputerUseBroker(); return ctx.computerUseArtifactBrokerService.listArtifacts(arg); diff --git a/apps/desktop/src/main/services/localRuntime/localRuntimeConnectionPool.test.ts b/apps/desktop/src/main/services/localRuntime/localRuntimeConnectionPool.test.ts index 7ab2270e6..dfe2d0483 100644 --- a/apps/desktop/src/main/services/localRuntime/localRuntimeConnectionPool.test.ts +++ b/apps/desktop/src/main/services/localRuntime/localRuntimeConnectionPool.test.ts @@ -305,7 +305,7 @@ describe("local runtime connection pool", () => { expect(fs.existsSync(tsxLoaderPath)).toBe(true); const adeHome = fs.mkdtempSync(path.join(os.tmpdir(), "ade-local-runtime-")); - const projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-local-runtime-project-")); + const projectRoot = fs.realpathSync.native(fs.mkdtempSync(path.join(os.tmpdir(), "ade-local-runtime-project-"))); const socketPath = path.join(adeHome, "sock", "ade.sock"); const originalEnv = { ADE_CLI_JS: process.env.ADE_CLI_JS, @@ -364,7 +364,7 @@ describe("local runtime connection pool", () => { expect(fs.existsSync(tsxLoaderPath)).toBe(true); const adeHome = fs.mkdtempSync(path.join(os.tmpdir(), "ade-local-runtime-version-")); - const projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-local-runtime-version-project-")); + const projectRoot = fs.realpathSync.native(fs.mkdtempSync(path.join(os.tmpdir(), "ade-local-runtime-version-project-"))); const socketPath = path.join(adeHome, "sock", "ade.sock"); const originalEnv = { ADE_CLI_JS: process.env.ADE_CLI_JS, @@ -450,7 +450,7 @@ describe("local runtime connection pool", () => { expect(fs.existsSync(tsxLoaderPath)).toBe(true); const adeHome = fs.mkdtempSync(path.join(os.tmpdir(), "ade-local-runtime-build-")); - const projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-local-runtime-build-project-")); + const projectRoot = fs.realpathSync.native(fs.mkdtempSync(path.join(os.tmpdir(), "ade-local-runtime-build-project-"))); const socketPath = path.join(adeHome, "sock", "ade.sock"); const originalEnv = { ADE_CLI_JS: process.env.ADE_CLI_JS, diff --git a/apps/desktop/src/preload/global.d.ts b/apps/desktop/src/preload/global.d.ts index 4a7e3e717..f14c16cf0 100644 --- a/apps/desktop/src/preload/global.d.ts +++ b/apps/desktop/src/preload/global.d.ts @@ -68,6 +68,8 @@ import type { AgentTool, AgentChatApproveArgs, AgentChatArchiveArgs, + AgentChatCodexOpenInCliArgs, + AgentChatCodexOpenInCliResult, AgentChatCreateArgs, AgentChatDeleteArgs, AgentChatSuggestLaneNameArgs, @@ -1485,6 +1487,11 @@ declare global { events: AgentChatEventEnvelope[]; truncated: boolean; }>; + codex: { + openInCli: ( + args: AgentChatCodexOpenInCliArgs, + ) => Promise; + }; }; computerUse: { listArtifacts: ( diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts index d79ccadf8..c8d96c80d 100644 --- a/apps/desktop/src/preload/preload.ts +++ b/apps/desktop/src/preload/preload.ts @@ -277,6 +277,8 @@ import type { AgentChatApproveArgs, AgentChatArchiveArgs, AgentChatCreateArgs, + AgentChatCodexOpenInCliArgs, + AgentChatCodexOpenInCliResult, AgentChatDeleteArgs, AgentChatSuggestLaneNameArgs, AgentChatDisposeArgs, @@ -5069,6 +5071,12 @@ contextBridge.exposeInMainWorld("ade", { ? runtime.result : ipcRenderer.invoke(IPC.agentChatGetEventHistory, args); }, + codex: { + openInCli: ( + args: AgentChatCodexOpenInCliArgs, + ): Promise => + ipcRenderer.invoke(IPC.agentChatCodexOpenInCli, args), + }, }, computerUse: { listArtifacts: async ( diff --git a/apps/desktop/src/renderer/browserMock.ts b/apps/desktop/src/renderer/browserMock.ts index 2125915a3..a6b86e3eb 100644 --- a/apps/desktop/src/renderer/browserMock.ts +++ b/apps/desktop/src/renderer/browserMock.ts @@ -4481,6 +4481,15 @@ if (typeof window !== "undefined" && shouldInstallBrowserMock(window)) { supportsInterrupt: false, }), saveTempAttachment: resolvedArg({ path: "/tmp/browser-mock-attachment" }), + codex: { + openInCli: async () => ({ + binary: "/usr/local/bin/codex", + argv: [] as string[], + cwd: "/tmp/browser-mock-lane", + threadId: "browser-mock-thread", + copyThreadIdToClipboard: true, + }), + }, getEventHistory: async (arg: { sessionId: string; maxEvents?: number; diff --git a/apps/desktop/src/renderer/components/app/App.tsx b/apps/desktop/src/renderer/components/app/App.tsx index 005bc28aa..8e6046557 100644 --- a/apps/desktop/src/renderer/components/app/App.tsx +++ b/apps/desktop/src/renderer/components/app/App.tsx @@ -243,7 +243,7 @@ function PersistentWorkSurface({ active }: { active: boolean }) { } }, [active, projectHydrated, hasActiveProject, showWelcome]); - if (!projectHydrated) { + if (!projectHydrated && !hasActiveProject) { return active ? GuardLoadingFallback : null; } diff --git a/apps/desktop/src/renderer/components/app/App.workKeepAlive.test.tsx b/apps/desktop/src/renderer/components/app/App.workKeepAlive.test.tsx index e7db1d3ad..85b824dd7 100644 --- a/apps/desktop/src/renderer/components/app/App.workKeepAlive.test.tsx +++ b/apps/desktop/src/renderer/components/app/App.workKeepAlive.test.tsx @@ -142,8 +142,9 @@ describe("App Work route keep-alive", () => { expect(workLifecycle.unmounts).toBe(0); }); - it("does not mount the Work page when the project is not yet hydrated", async () => { + it("does not mount the Work page when hydration has not found a project yet", async () => { appStoreState.projectHydrated = false; + appStoreState.project = { rootPath: "" }; const { App } = await import("./App"); render(); @@ -152,6 +153,23 @@ describe("App Work route keep-alive", () => { expect(workLifecycle.mounts).toBe(0); }); + it("keeps the Work page mounted during a same-project hydration refresh", async () => { + const { App } = await import("./App"); + + const { rerender } = render(); + + await screen.findByTestId("work-page"); + expect(workLifecycle.mounts).toBe(1); + expect(workLifecycle.unmounts).toBe(0); + + appStoreState.projectHydrated = false; + rerender(); + + expect(screen.getByTestId("work-page")).toBeTruthy(); + expect(workLifecycle.mounts).toBe(1); + expect(workLifecycle.unmounts).toBe(0); + }); + it("redirects to /project when there is no active project on the Work route", async () => { appStoreState.project = { rootPath: "" }; const { App } = await import("./App"); diff --git a/apps/desktop/src/renderer/components/app/AppShell.tsx b/apps/desktop/src/renderer/components/app/AppShell.tsx index 09b781a69..b0f750795 100644 --- a/apps/desktop/src/renderer/components/app/AppShell.tsx +++ b/apps/desktop/src/renderer/components/app/AppShell.tsx @@ -296,11 +296,16 @@ export function AppShell({ children }: { children: React.ReactNode }) { const lastRouteSaveProjectRootRef = useRef(undefined); const isOnboardingRoute = location.pathname === "/onboarding"; const isLanesRoute = location.pathname.startsWith("/lanes"); + const isLanesRouteRef = useRef(isLanesRoute); const shouldTrackTerminalAttention = Boolean(project?.rootPath) && !showWelcome && (location.pathname === "/work" || location.pathname === "/lanes"); + useEffect(() => { + isLanesRouteRef.current = isLanesRoute; + }, [isLanesRoute]); + useEffect(() => { logRendererDebugEvent("renderer.route_change", { pathname: location.pathname, @@ -423,11 +428,11 @@ export function AppShell({ children }: { children: React.ReactNode }) { laneRefreshTimer = window.setTimeout(() => { laneRefreshTimer = null; if (cancelled) return; - const includeDecoratedLaneSnapshots = isLanesRoute; + const includeDecoratedLaneSnapshots = isLanesRouteRef.current; void refreshLanes({ includeStatus: includeDecoratedLaneSnapshots, includeConflictStatus: includeDecoratedLaneSnapshots, - includeRebaseSuggestions: isLanesRoute, + includeRebaseSuggestions: includeDecoratedLaneSnapshots, includeAutoRebaseStatus: includeDecoratedLaneSnapshots, }); }, 1_200); @@ -501,7 +506,6 @@ export function AppShell({ children }: { children: React.ReactNode }) { refreshProviderMode, refreshKeybindings, setShowWelcome, - isLanesRoute, ]); useEffect(() => { diff --git a/apps/desktop/src/renderer/components/app/CommandPalette.test.tsx b/apps/desktop/src/renderer/components/app/CommandPalette.test.tsx index f2f132393..b11626be5 100644 --- a/apps/desktop/src/renderer/components/app/CommandPalette.test.tsx +++ b/apps/desktop/src/renderer/components/app/CommandPalette.test.tsx @@ -266,7 +266,19 @@ describe("CommandPalette", () => { "/Users/admin/Projects/FreshFolder", ); expect(switchProjectToPath).toHaveBeenCalledTimes(1); - expect(browseDirectories).toHaveBeenCalledTimes(3); + expect(switchProjectToPath).not.toHaveBeenCalledWith( + "/Users/admin/Projects/StaleRepo", + ); + expect(browseDirectories).toHaveBeenCalledWith({ + partialPath: "/Users/admin/Projects/StaleRepo/", + cwd: "/Users/admin/Projects/ADE", + limit: 200, + }); + expect(browseDirectories).toHaveBeenCalledWith({ + partialPath: "/Users/admin/Projects/FreshFolder/", + cwd: "/Users/admin/Projects/ADE", + limit: 200, + }); }); }); diff --git a/apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx b/apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx index fe4c49809..8d7660a26 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx @@ -19,6 +19,7 @@ import { type AgentChatInteractionMode, type AgentChatOpenCodePermissionMode, type AgentChatSlashCommand, + type CodexThreadTokenUsage, type ComputerUseOwnerSnapshot, type ChatSurfaceMode, type AppControlContextItem, @@ -36,6 +37,7 @@ import { getModelById, modelSupportsFastMode } from "../../../shared/modelRegist import { cn } from "../ui/cn"; import { ProviderModelSelector } from "../shared/ProviderModelSelector"; import { getPermissionOptions, safetyColors } from "../shared/permissionOptions"; +import { CodexTokenInline } from "./codex/CodexTokenInline"; import { ChatAttachmentTray } from "./ChatAttachmentTray"; import { ChatComposerShell } from "./ChatComposerShell"; import { LaneDialogShell } from "../lanes/LaneDialogShell"; @@ -54,6 +56,7 @@ const CLIPBOARD_IMAGE_PASTE_FALLBACK_DELAY_MS = 80; const ISSUE_CONTEXT_MENU_WIDTH = 256; const ISSUE_CONTEXT_MENU_GAP = 8; const ISSUE_CONTEXT_MENU_VIEWPORT_GUTTER = 8; +const IMAGE_URL_EXTENSION_RE = /\.(png|jpe?g|gif|webp|bmp|svg|ico|tiff?)(?:$|[?#])/i; type PasteShortcutEvent = { key: string; @@ -78,6 +81,27 @@ function isMacPasteShortcut(event: PasteShortcutEvent): boolean { ); } +/** + * Returns the normalized image URL only when `value` is *exactly* a URL with no + * other text around it (whitespace ok). We never want a paste to be silently + * swallowed if the user actually intended to paste a paragraph of text that + * happens to start with a URL. + */ +function normalizeImageAttachmentUrl(value: string | null | undefined): string | null { + const raw = value?.trim(); + if (!raw) return null; + // Reject if there are any embedded newlines or whitespace — must be a single token. + if (/\s/.test(raw)) return null; + try { + const parsed = new URL(raw); + if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return null; + if (!IMAGE_URL_EXTENSION_RE.test(`${parsed.pathname}${parsed.search}${parsed.hash}`)) return null; + return parsed.toString(); + } catch { + return null; + } +} + function getIssueContextMenuStyle(trigger: HTMLButtonElement): React.CSSProperties { const rect = trigger.getBoundingClientRect(); const maxLeft = Math.max( @@ -683,6 +707,7 @@ export function AgentChatComposer({ availableModelIds, reasoningEffort, codexFastMode = false, + codexTokenUsage = null, draft, attachments, contextAttachments = [], @@ -798,6 +823,7 @@ export function AgentChatComposer({ availableModelIds?: string[]; reasoningEffort: string | null; codexFastMode?: boolean; + codexTokenUsage?: CodexThreadTokenUsage | null; draft: string; attachments: AgentChatFileRef[]; contextAttachments?: AgentChatContextAttachment[]; @@ -921,6 +947,13 @@ export function AgentChatComposer({ const [attachmentResults, setAttachmentResults] = useState([]); const [attachmentCursor, setAttachmentCursor] = useState(0); const [attachError, setAttachError] = useState(null); + const [attachNotice, setAttachNotice] = useState<{ message: string; undoPath: string } | null>(null); + + useEffect(() => { + if (!attachNotice) return; + const timer = window.setTimeout(() => setAttachNotice(null), 5000); + return () => window.clearTimeout(timer); + }, [attachNotice]); const [issueContextMenuOpen, setIssueContextMenuOpen] = useState(false); const [linearIssuePickerOpen, setLinearIssuePickerOpen] = useState(false); const [selectedIosContextId, setSelectedIosContextId] = useState(null); @@ -1150,6 +1183,31 @@ export function AgentChatComposer({ } }; + const addImageUrlAttachment = useCallback((url: string): boolean => { + if (!canAttach) return false; + if (parallelChatMode && attachments.length >= PARALLEL_CHAT_MAX_ATTACHMENTS) { + setAttachError(`You can attach up to ${PARALLEL_CHAT_MAX_ATTACHMENTS} files for parallel launch.`); + return false; + } + setAttachError(null); + onAddAttachment({ path: url, type: "image-url", url }); + return true; + }, [attachments.length, canAttach, onAddAttachment, parallelChatMode]); + + const addImageUrlFromTransfer = useCallback(( + data: DataTransfer | React.ClipboardEvent["clipboardData"], + options?: { showNotice?: boolean }, + ): boolean => { + const url = normalizeImageAttachmentUrl(data.getData("text/uri-list")) + ?? normalizeImageAttachmentUrl(data.getData("text/plain")); + if (!url) return false; + const attached = addImageUrlAttachment(url); + if (attached && options?.showNotice) { + setAttachNotice({ message: "Image URL attached", undoPath: url }); + } + return attached; + }, [addImageUrlAttachment]); + const captureRichSelection = useCallback(() => { const editor = richEditorRef.current; const selection = window.getSelection(); @@ -2239,6 +2297,10 @@ export function AgentChatComposer({ if (fallbackAlreadyAttached) return; clipboardImagePasteHandledRef.current += 1; void addNativeClipboardImageAttachment(); + return; + } + if (addImageUrlFromTransfer(event.clipboardData, { showNotice: true })) { + event.preventDefault(); } return; } @@ -2251,7 +2313,9 @@ export function AgentChatComposer({ }; const handleDragOver = (event: React.DragEvent) => { - if (!canAttach || !event.dataTransfer.files.length) return; + const hasImageUrl = event.dataTransfer.types.includes("text/uri-list") + || event.dataTransfer.types.includes("text/plain"); + if (!canAttach || (!event.dataTransfer.files.length && !hasImageUrl)) return; event.preventDefault(); setDragActive(true); }; @@ -2262,10 +2326,12 @@ export function AgentChatComposer({ }; const handleDrop = (event: React.DragEvent) => { - if (!canAttach || !event.dataTransfer.files.length) return; + if (!canAttach || (!event.dataTransfer.files.length && !addImageUrlFromTransfer(event.dataTransfer))) return; event.preventDefault(); setDragActive(false); - void addFileAttachments(event.dataTransfer.files); + if (event.dataTransfer.files.length) { + void addFileAttachments(event.dataTransfer.files); + } }; const handleCommandMenuSelect = useCallback((item: ChatCommandMenuItem) => { @@ -2590,7 +2656,7 @@ export function AgentChatComposer({ ) ) : undefined} trays={ - attachments.length || contextAttachmentCount || attachError || selectedIosContext || selectedAppControlContext || selectedBuiltInBrowserContext || selectedMacosVmContext ? ( + attachments.length || contextAttachmentCount || attachError || attachNotice || selectedIosContext || selectedAppControlContext || selectedBuiltInBrowserContext || selectedMacosVmContext ? (
{selectedMacosVmContext ? (
@@ -2833,6 +2899,31 @@ export function AgentChatComposer({
) : null} + {attachNotice ? ( +
+ + {attachNotice.message} + + + +
+ ) : null} + {!parallelChatMode && sessionProvider === "codex" && codexTokenUsage ? ( + + ) : null} {/* Right: attachment, commands, proof, context, send */}
diff --git a/apps/desktop/src/renderer/components/chat/AgentChatMessageList.tsx b/apps/desktop/src/renderer/components/chat/AgentChatMessageList.tsx index 6080829d5..7196b128a 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatMessageList.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatMessageList.tsx @@ -22,7 +22,6 @@ import { Note, ChatCircleText, Info, - Lightning, MagnifyingGlass, Globe, ShieldCheck, @@ -90,6 +89,10 @@ import { } from "./chatUserMinimap.logic"; import { readPendingInputRequest, buildLegacyPendingInputFromApprovalEvent } from "./pendingInput"; import type { PendingInputQuestion, PendingInputRequest } from "../../../shared/types"; +import { CodexPlanCard } from "./codex/CodexPlanCard"; +import { CodexImageGenerationCard } from "./codex/CodexImageGenerationCard"; +import { CodexImageViewLine } from "./codex/CodexImageViewLine"; +import { CodexContextCompactionChip } from "./codex/CodexContextCompactionChip"; const NAVIGATION_SURFACES = new Set(["work", "missions", "lanes", "cto"]); type PendingInputResolution = Extract["resolution"]; @@ -696,6 +699,53 @@ function chatMarkdownUrlTransform(value: string): string { return defaultUrlTransform(value); } +type WebSearchActionListProps = { + actions: NonNullable["actions"]>; + isFailed: boolean; +}; + +function WebSearchActionList({ actions, isFailed }: WebSearchActionListProps) { + const [expanded, setExpanded] = useState(false); + const HEAD = 8; + const showAll = expanded || actions.length <= HEAD; + const visible = showAll ? actions : actions.slice(0, HEAD); + const hiddenCount = showAll ? 0 : actions.length - visible.length; + return ( +
+ {visible.map((action, index) => ( + + + {action.type} + + {action.title || action.url || action.query ? ( + + {action.title ?? action.url ?? action.query} + + ) : null} + + ))} + {hiddenCount > 0 ? ( + + ) : null} +
+ ); +} + function InlineDisclosureRow({ summary, children, @@ -2066,43 +2116,7 @@ function renderEvent( /* ── Plan ── */ if (event.type === "plan") { - const completedCount = event.steps.filter((step) => step.status === "completed").length; - return ( - - - - Plan updated - {completedCount}/{event.steps.length || 0} complete - {event.steps[0]?.text ? {summarizeInlineText(event.steps[0].text, 96)} : null} -
- } - > -
- {event.steps.length ? ( - event.steps.map((step, index) => ( -
-
- -
-
- {step.text} -
-
- )) - ) : ( -
No plan steps yet.
- )} -
- {event.explanation ? ( -
{event.explanation}
- ) : null} - - ); + return ; } /* ── TODO Update ── */ @@ -2220,12 +2234,23 @@ function renderEvent( {event.query}
+ {event.actions?.length ? ( + + ) : null} ); } + if (event.type === "codex_image_generation") { + return ; + } + + if (event.type === "codex_image_view") { + return ; + } + /* ── Auto Approval Review (Guardian) ── */ if (event.type === "auto_approval_review") { const isStarted = event.reviewStatus === "started"; @@ -2249,26 +2274,6 @@ function renderEvent( ); } - /* ── Plan Text (streaming plan delta) ── */ - if (event.type === "plan_text") { - return ( -
-
-
-
- - - Plan - -
-
- -
-
-
- ); - } - /* ── Subagent Started ── */ if (event.type === "subagent_started") { return ( @@ -2426,35 +2431,23 @@ function renderEvent( ); } - /* ── Context Compact ── */ + /* ── Context Compaction (new variant) ── */ + if (event.type === "codex_context_compaction") { + return ; + } + + /* ── Legacy Context Compact (kept for any pre-A.3 transcripts) ── */ if (event.type === "context_compact") { - const isAuto = event.trigger === "auto"; - const freedLabel = event.preTokens != null ? `~${formatTokenCount(event.preTokens)} tokens freed` : null; return ( -
-
-
-
- -
- - Context compacted - - {freedLabel ? ( - <> - · - {freedLabel} - - ) : null} - - {isAuto ? "auto" : "manual"} - -
-
-
+ ); } @@ -2488,6 +2481,9 @@ function renderEvent( file_persist: { border: "border-emerald-500/18", bg: "bg-emerald-500/[0.06]", text: "text-emerald-300", icon: Note }, memory: { border: "border-cyan-500/18", bg: "bg-cyan-500/[0.06]", text: "text-cyan-300", icon: MagnifyingGlass }, info: { border: "border-border/14", bg: "bg-surface-recessed/70", text: "text-muted-fg/55", icon: Note }, + warning: { border: "border-amber-500/18", bg: "bg-amber-500/[0.06]", text: "text-amber-300", icon: Warning }, + error: { border: "border-red-500/18", bg: "bg-red-500/[0.06]", text: "text-red-300", icon: Warning }, + config: { border: "border-border/14", bg: "bg-surface-recessed/70", text: "text-muted-fg/55", icon: Note }, }; const style = kindStyles[event.noticeKind] ?? kindStyles.info!; const NoticeIcon = style.icon; diff --git a/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx b/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx index be8ceda41..8c4a8e3b3 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx @@ -1,7 +1,13 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useNavigate } from "react-router-dom"; import { AnimatePresence, motion } from "motion/react"; -import { Cube, Desktop, DeviceMobile, Lightning, Plus } from "@phosphor-icons/react"; +import { + Cube, + Desktop, + DeviceMobile, + Lightning, + Plus, +} from "@phosphor-icons/react"; import { inferAttachmentType, PARALLEL_CHAT_MAX_ATTACHMENTS, @@ -28,6 +34,8 @@ import { type ChatSurfaceProfile, type ChatSurfacePresentation, type AgentChatSessionSummary, + type CodexThreadGoal, + type CodexThreadTokenUsage, type BuiltInBrowserContextItem, type ComputerUseOwnerSnapshot, type AppControlContextItem, @@ -85,6 +93,8 @@ import { ChatAppControlPanel } from "./ChatAppControlPanel"; import { ChatSubagentsPanel } from "./ChatSubagentsPanel"; import { ChatTasksPanel } from "./ChatTasksPanel"; import { ChatFileChangesPanel } from "./ChatFileChangesPanel"; +import { CodexGoalBanner } from "./codex/CodexGoalBanner"; +import { CodexOpenInCliButton } from "./codex/CodexOpenInCliButton"; import { ChatCursorCloudPanel, type ChatCursorCloudPanelHandle } from "./ChatCursorCloudPanel"; import { CursorCloudInlineLaunch, type CursorCloudInlineLaunchHandle } from "./CursorCloudInlineLaunch"; import { ChatGitToolbar } from "./ChatGitToolbar"; @@ -2096,6 +2106,23 @@ export function AgentChatPane({ }; return [...displayEvents.slice(0, insertAt), synthetic, ...displayEvents.slice(insertAt)]; }, [optimisticOutgoingMessage, presentation?.mode, presentation?.rewriteMissionControlTextTools, selectedEvents, selectedSession?.cursorCloudAgentId, selectedSession?.cursorPromotedTurnId, selectedSessionId]); + const selectedCodexGoal = useMemo(() => { + let goal = selectedSession?.codexGoal ?? null; + for (const envelope of selectedEventsForDisplay) { + const event = envelope.event; + if (event.type === "codex_goal_updated") goal = event.goal; + if (event.type === "codex_goal_cleared") goal = null; + } + return goal; + }, [selectedEventsForDisplay, selectedSession?.codexGoal]); + const selectedCodexTokenUsage = useMemo(() => { + let usage = selectedSession?.codexTokenUsage ?? null; + for (const envelope of selectedEventsForDisplay) { + const event = envelope.event; + if (event.type === "codex_token_usage") usage = event.usage; + } + return usage; + }, [selectedEventsForDisplay, selectedSession?.codexTokenUsage]); const selectedSubagentSnapshots = useMemo(() => deriveChatSubagentSnapshots(selectedEvents), [selectedEvents]); const selectedTurnDiffSummaries = useMemo(() => deriveTurnDiffSummaries(selectedEvents), [selectedEvents]); const selectedTodoItems = useMemo(() => deriveTodoItems(selectedEvents), [selectedEvents]); @@ -5319,6 +5346,42 @@ export function AgentChatPane({ ) : null} {showWorkspaceChrome && laneId ? setTerminalDrawerOpen((v) => !v)} /> : null} + {selectedSession?.provider === "codex" + && selectedSession.surface !== "mission" + && selectedSessionId + && selectedSession.threadId ? ( + { + // Open the ADE terminal drawer and write the resume command + // into the chat's active terminal pane (plan §D.1). Falls + // back to copying the command if the chat doesn't yet have an + // active terminal session. + setTerminalDrawerOpen(true); + const quoted = (parts: string[]) => + parts.map((p) => `'${p.replace(/'/g, "'\\''")}'`).join(" "); + const fullCommand = `cd ${quoted([args.cwd])} && ${quoted([args.binary, ...args.argv])}\r`; + void (async () => { + try { + const active = await window.ade.terminal.activeForChat({ + chatSessionId: selectedSessionId, + }); + if (active?.ptyId) { + await window.ade.terminal.write({ + ptyId: active.ptyId, + chatSessionId: selectedSessionId, + data: fullCommand, + }); + return; + } + } catch { + // fall through to clipboard + } + await navigator.clipboard.writeText(fullCommand.trimEnd()).catch(() => undefined); + })(); + }} + /> + ) : null} {resolvedChips.map((chip) => ( ) : null} + {selectedSession?.provider === "codex" && selectedCodexGoal?.objective && selectedSessionId ? ( + { + void window.ade.agentChat + .send({ sessionId: selectedSessionId, text: `/goal ${next}` }) + .catch(() => undefined); + }} + onClear={() => { + void window.ade.agentChat + .send({ sessionId: selectedSessionId, text: "/goal clear" }) + .catch(() => undefined); + }} + /> + ) : null} { expect(screen.queryByRole("button", { name: "Open context.txt" })).toBeNull(); }); + it("renders image URL attachments as URL chips without loading a local preview", () => { + const onRemove = vi.fn(); + + render( + , + ); + + expect(screen.getByText("example.com")).toBeTruthy(); + expect(getImageDataUrl).not.toHaveBeenCalled(); + + fireEvent.click(screen.getByRole("button", { name: "Remove example.com" })); + expect(onRemove).toHaveBeenCalledWith("https://example.com/diagram.png"); + }); + it("renders removable Linear issue context chips", () => { const onRemoveContext = vi.fn(); const contextAttachment = makeLinearIssueContextAttachment(makeIssue(), "manual"); diff --git a/apps/desktop/src/renderer/components/chat/ChatAttachmentTray.tsx b/apps/desktop/src/renderer/components/chat/ChatAttachmentTray.tsx index 4f5e57a08..073271612 100644 --- a/apps/desktop/src/renderer/components/chat/ChatAttachmentTray.tsx +++ b/apps/desktop/src/renderer/components/chat/ChatAttachmentTray.tsx @@ -1,6 +1,6 @@ import { useEffect, useRef, useState, type KeyboardEvent, type MouseEvent } from "react"; import { createPortal } from "react-dom"; -import { Copy, File, Image, X } from "@phosphor-icons/react"; +import { Copy, File, Globe, Image, X } from "@phosphor-icons/react"; import type { AgentChatContextAttachment, AgentChatFileRef, ChatSurfaceMode } from "../../../shared/types"; import { chatContextAttachmentKey } from "../../../shared/chatContextAttachments"; import { cn } from "../ui/cn"; @@ -212,6 +212,58 @@ function ImageAttachmentPreview({ ); } +function ImageUrlAttachmentChip({ + path, + url, + label, + toneClassName, + onRemove, +}: { + path: string; + url: string; + label: string; + toneClassName: string; + onRemove?: (path: string) => void; +}) { + const [imageFailed, setImageFailed] = useState(false); + return ( + + {imageFailed ? ( + + ) : ( + {label} setImageFailed(true)} + className="h-8 w-8 shrink-0 rounded-sm border border-white/10 bg-black/30 object-cover" + /> + )} + + {label} + + {onRemove ? ( + + ) : null} + + ); +} + function ImageLightbox({ name, dataUrl, @@ -354,6 +406,26 @@ export function ChatAttachmentTray({ /> ))} {attachments.map((attachment) => { + if (attachment.type === "image-url") { + const label = (() => { + try { + const parsed = new URL(attachment.url); + return parsed.hostname || attachment.url; + } catch { + return attachmentName(attachment.url); + } + })(); + return ( + + ); + } if (attachment.type === "image") { return ( | Extract | Extract - | Extract; + | Extract + // Goal and token-usage events drive the pinned goal banner and chat-column-bottom + // token footer; the inline transcript rows would be duplicate noise. + | Extract + | Extract + | Extract; type ChatTranscriptVisibleEvent = Exclude; @@ -285,14 +290,6 @@ function shouldMergeTextRows( return !previous.turnId && !next.turnId && !previous.itemId && !next.itemId; } -function shouldMergePlanTextRows( - previous: Extract, - next: Extract, -): boolean { - return turnAndItemMatch(previous, next) - || (!previous.turnId && !next.turnId && !previous.itemId && !next.itemId); -} - function buildCollapseKey( prefix: string, event: { turnId?: string; itemId?: string; logicalItemId?: string }, @@ -542,6 +539,16 @@ export function appendCollapsedChatTranscriptEvent( return; } + // Codex goal + token usage events drive the pinned goal banner / chat-bottom + // token footer; inline transcript rows would be duplicate noise. + if ( + event.type === "codex_goal_updated" + || event.type === "codex_goal_cleared" + || event.type === "codex_token_usage" + ) { + return; + } + if (event.type === "status") { const normalizedMessage = summarizeInlineText(event.message ?? "", 120).toLowerCase(); const keepStatus = @@ -640,24 +647,6 @@ export function appendCollapsedChatTranscriptEvent( } } - if (event.type === "plan_text") { - if (!event.text.trim().length) return; - const previous = rows[rows.length - 1]; - if (previous?.event.type === "plan_text" && shouldMergePlanTextRows(previous.event, event)) { - rows[rows.length - 1] = { - ...previous, - timestamp: envelope.timestamp, - event: { - ...previous.event, - text: mergeStreamingText(previous.event.text, event.text), - ...(event.turnId && !previous.event.turnId ? { turnId: event.turnId } : {}), - ...(event.itemId && !previous.event.itemId ? { itemId: event.itemId } : {}), - }, - }; - return; - } - } - if (event.type === "system_notice") { const previous = rows[rows.length - 1]; if ( @@ -701,16 +690,6 @@ export function appendCollapsedChatTranscriptEvent( if (event.type === "plan") { const nextTurn = event.turnId ?? null; if (nextTurn !== null) { - for (let index = rows.length - 1; index >= 0; index -= 1) { - const candidate = rows[index]; - if ( - candidate?.event.type === "plan_text" - && (candidate.event.turnId ?? null) === nextTurn - ) { - rows.splice(index, 1); - } - } - const matchIndex = [...rows] .reverse() .findIndex((candidate) => diff --git a/apps/desktop/src/renderer/components/chat/codex/CodexContextCompactionChip.tsx b/apps/desktop/src/renderer/components/chat/codex/CodexContextCompactionChip.tsx new file mode 100644 index 000000000..638a10673 --- /dev/null +++ b/apps/desktop/src/renderer/components/chat/codex/CodexContextCompactionChip.tsx @@ -0,0 +1,46 @@ +import type { AgentChatEvent } from "../../../../shared/types"; +import { cn } from "../../ui/cn"; + +type CompactionEvent = Extract; + +type CodexContextCompactionChipProps = { + event: CompactionEvent; + timestamp?: string; +}; + +function formatClock(timestamp: string | undefined): string | null { + if (!timestamp) return null; + try { + const date = new Date(timestamp); + if (Number.isNaN(date.getTime())) return null; + return date.toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit" }); + } catch { + return null; + } +} + +export function CodexContextCompactionChip({ event, timestamp }: CodexContextCompactionChipProps) { + const isStarted = event.state === "started"; + const clock = formatClock(timestamp); + const verb = isStarted ? "Compacting context" : "Context compacted"; + const tooltip = `${verb}${clock ? ` at ${clock}` : ""} · trigger: ${event.trigger}`; + + return ( +
+ + {isStarted ? "⟳" : "✓"} + {isStarted ? "compacting" : "compacted"} + +
+ ); +} + +export default CodexContextCompactionChip; diff --git a/apps/desktop/src/renderer/components/chat/codex/CodexGoalBanner.test.tsx b/apps/desktop/src/renderer/components/chat/codex/CodexGoalBanner.test.tsx new file mode 100644 index 000000000..4844d724b --- /dev/null +++ b/apps/desktop/src/renderer/components/chat/codex/CodexGoalBanner.test.tsx @@ -0,0 +1,82 @@ +/* @vitest-environment jsdom */ + +import { afterEach, describe, expect, it, vi } from "vitest"; +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { CodexGoalBanner } from "./CodexGoalBanner"; + +afterEach(() => cleanup()); + +describe("CodexGoalBanner", () => { + it("invokes onEdit with the typed objective when the user presses Enter", () => { + const onEdit = vi.fn(); + render( + undefined} + />, + ); + + // Click the objective text to enter edit mode + fireEvent.click(screen.getByText("Refactor auth middleware")); + const input = screen.getByLabelText("Edit goal objective") as HTMLInputElement; + fireEvent.change(input, { target: { value: "Refactor auth for compliance" } }); + fireEvent.keyDown(input, { key: "Enter" }); + + expect(onEdit).toHaveBeenCalledWith("Refactor auth for compliance"); + }); + + it("invokes onClear when the clear button is clicked", () => { + const onClear = vi.fn(); + render( + undefined} + onClear={onClear} + />, + ); + + fireEvent.click(screen.getByLabelText("Clear goal")); + expect(onClear).toHaveBeenCalledTimes(1); + }); + + it("does not invoke onEdit when Escape cancels the edit", () => { + const onEdit = vi.fn(); + render( + undefined} + />, + ); + + fireEvent.click(screen.getByText("Refactor auth")); + const input = screen.getByLabelText("Edit goal objective"); + fireEvent.change(input, { target: { value: "Discarded change" } }); + fireEvent.keyDown(input, { key: "Escape" }); + + expect(onEdit).not.toHaveBeenCalled(); + }); + + it("returns null when the goal has no objective", () => { + const { container } = render( + undefined} + onClear={() => undefined} + />, + ); + expect(container.firstChild).toBeNull(); + }); + + it("shows the status pill label with underscores replaced", () => { + render( + undefined} + onClear={() => undefined} + />, + ); + expect(screen.getByText(/budget limited/i)).toBeTruthy(); + }); +}); diff --git a/apps/desktop/src/renderer/components/chat/codex/CodexGoalBanner.tsx b/apps/desktop/src/renderer/components/chat/codex/CodexGoalBanner.tsx new file mode 100644 index 000000000..fd124f9a8 --- /dev/null +++ b/apps/desktop/src/renderer/components/chat/codex/CodexGoalBanner.tsx @@ -0,0 +1,199 @@ +import { useEffect, useRef, useState } from "react"; +import type { CodexThreadGoal } from "../../../../shared/types"; +import { cn } from "../../ui/cn"; + +const AMBER = "#F59E0B"; + +type CodexGoalBannerProps = { + goal: CodexThreadGoal; + onEdit?: (nextObjective: string) => void; + onClear?: () => void; +}; + +function formatTokens(value: number | null | undefined): string { + if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) return "0"; + if (value >= 1_000_000) return `${(value / 1_000_000).toFixed(1)}M`; + if (value >= 1_000) return `${(value / 1_000).toFixed(1)}k`; + return String(Math.round(value)); +} + +function formatElapsed(seconds: number | null | undefined): string | null { + if (typeof seconds !== "number" || !Number.isFinite(seconds) || seconds <= 0) return null; + if (seconds < 60) return `${Math.round(seconds)}s`; + const minutes = Math.floor(seconds / 60); + const remainder = Math.round(seconds % 60); + if (minutes < 60) return remainder ? `${minutes}m ${remainder}s` : `${minutes}m`; + const hours = Math.floor(minutes / 60); + const remMinutes = minutes % 60; + return remMinutes ? `${hours}h ${remMinutes}m` : `${hours}h`; +} + +function statusPillClass(status: CodexThreadGoal["status"]): string { + switch (status) { + case "complete": + return "bg-emerald-500/12 text-emerald-200/85 ring-1 ring-inset ring-emerald-400/25"; + case "paused": + return "bg-fg/8 text-fg/55 ring-1 ring-inset ring-fg/15"; + case "budget_limited": + return "bg-red-500/12 text-red-200/85 ring-1 ring-inset ring-red-400/25"; + case "cancelled": + return "bg-fg/8 text-fg/45 ring-1 ring-inset ring-fg/15"; + case "active": + default: + return "bg-amber-500/12 text-amber-100 ring-1 ring-inset ring-amber-400/30"; + } +} + +function statusLabel(status: CodexThreadGoal["status"]): string { + if (!status || status === "unknown") return "active"; + return status.replace("_", " "); +} + +export function CodexGoalBanner({ goal, onEdit, onClear }: CodexGoalBannerProps) { + const objective = (goal.objective ?? "").trim(); + const [editing, setEditing] = useState(false); + const [draft, setDraft] = useState(objective); + const inputRef = useRef(null); + + useEffect(() => { + if (!editing) setDraft(objective); + }, [editing, objective]); + + useEffect(() => { + if (editing && inputRef.current) { + inputRef.current.focus(); + inputRef.current.select(); + } + }, [editing]); + + // Render nothing for an empty objective. Keep this after hook calls so we + // never break the rules of hooks if the parent toggles between empty and + // populated goal states without unmounting. + if (!objective) return null; + + const tokensUsed = goal.tokensUsed ?? 0; + const tokenBudget = goal.tokenBudget ?? 0; + const hasBudget = tokenBudget > 0; + const ratio = hasBudget ? Math.max(0, Math.min(1, tokensUsed / tokenBudget)) : 0; + const overSoftCap = hasBudget && tokensUsed > tokenBudget * 0.85; + const elapsed = formatElapsed(goal.timeUsedSeconds); + const status = goal.status ?? "active"; + + const submitEdit = () => { + const next = draft.trim(); + setEditing(false); + if (!next || next === objective) return; + onEdit?.(next); + }; + + const cancelEdit = () => { + setEditing(false); + setDraft(objective); + }; + + return ( +
+
+ + {"◎"} + + + {editing ? ( + setDraft(e.target.value)} + onBlur={submitEdit} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + submitEdit(); + } else if (e.key === "Escape") { + e.preventDefault(); + cancelEdit(); + } + }} + className="min-w-0 flex-1 rounded border border-amber-400/30 bg-amber-950/30 px-2 py-0.5 text-[length:calc(var(--chat-font-size)*12/14)] font-medium leading-tight text-amber-50 outline-none focus:border-amber-300/60" + aria-label="Edit goal objective" + /> + ) : ( + + )} + + + {statusLabel(status)} + + + {onEdit && !editing ? ( + + ) : null} + {onClear && !editing ? ( + + ) : null} +
+ +
+
+ {hasBudget ? ( +
+ ) : null} +
+ + {formatTokens(tokensUsed)} + {hasBudget ? / {formatTokens(tokenBudget)} : null} + + {elapsed ? ( + <> + · + {elapsed} + + ) : null} +
+
+ ); +} + +export default CodexGoalBanner; diff --git a/apps/desktop/src/renderer/components/chat/codex/CodexImageGenerationCard.test.tsx b/apps/desktop/src/renderer/components/chat/codex/CodexImageGenerationCard.test.tsx new file mode 100644 index 000000000..bc2201076 --- /dev/null +++ b/apps/desktop/src/renderer/components/chat/codex/CodexImageGenerationCard.test.tsx @@ -0,0 +1,83 @@ +/* @vitest-environment jsdom */ + +import type { ComponentProps } from "react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { CodexImageGenerationCard } from "./CodexImageGenerationCard"; + +type ImageGenEvent = ComponentProps["event"]; + +const baseEvent: ImageGenEvent = { + type: "codex_image_generation", + itemId: "item-1", + status: "completed", + prompt: "A small bird in flight", + revisedPrompt: "A small bird soaring across an indigo sunset sky", + result: null, + savedPath: null, +}; + +describe("CodexImageGenerationCard", () => { + let originalAde: typeof window.ade; + let openPath: ReturnType; + + beforeEach(() => { + openPath = vi.fn(async () => undefined); + originalAde = (window as { ade?: unknown }).ade as typeof window.ade; + (window as unknown as { ade: { app: { openPath: typeof openPath } } }).ade = { + ...(originalAde ?? {}), + app: { + ...(originalAde?.app ?? {}), + openPath, + }, + } as never; + }); + + afterEach(() => { + cleanup(); + (window as unknown as { ade: typeof originalAde }).ade = originalAde; + }); + + it("renders the Open button only when savedPath is set, and triggers window.ade.app.openPath on click", () => { + const { rerender } = render( + , + ); + expect(screen.queryByRole("button", { name: /open/i })).toBeNull(); + + rerender( + , + ); + const openBtn = screen.getByRole("button", { name: /open/i }); + fireEvent.click(openBtn); + expect(openPath).toHaveBeenCalledWith("/tmp/out/bird.png"); + }); + + it("renders an thumbnail when result is an https URL", () => { + const { container } = render( + , + ); + const imgs = container.querySelectorAll("img"); + expect(imgs.length).toBe(1); + expect(imgs[0]!.getAttribute("src")).toBe("https://cdn.example.com/bird.png"); + expect(imgs[0]!.getAttribute("loading")).toBe("lazy"); + }); + + it("renders the file-path-with-icon branch (no ) when only savedPath is set", () => { + const { container } = render( + , + ); + expect(container.querySelectorAll("img").length).toBe(0); + expect(screen.getByText("bird.png")).toBeTruthy(); + }); + + it("keeps the revised prompt hidden by default and reveals it when clicked", () => { + render(); + expect(screen.queryByText(/soaring across an indigo sunset/)).toBeNull(); + fireEvent.click(screen.getByRole("button", { name: /revised prompt/i })); + expect(screen.getByText(/soaring across an indigo sunset/)).toBeTruthy(); + }); +}); diff --git a/apps/desktop/src/renderer/components/chat/codex/CodexImageGenerationCard.tsx b/apps/desktop/src/renderer/components/chat/codex/CodexImageGenerationCard.tsx new file mode 100644 index 000000000..280a1368c --- /dev/null +++ b/apps/desktop/src/renderer/components/chat/codex/CodexImageGenerationCard.tsx @@ -0,0 +1,146 @@ +import { useState } from "react"; +import { motion } from "motion/react"; +import { ArrowSquareOut, FileImage, Image as ImageIcon, XCircle } from "@phosphor-icons/react"; +import type { AgentChatEvent } from "../../../../shared/types"; +import { ChatStatusGlyph } from "../chatStatusVisuals"; +import { cn } from "../../ui/cn"; + +type ImageGenerationEvent = Extract; + +type CodexImageGenerationCardProps = { + event: ImageGenerationEvent; +}; + +function basename(path: string): string { + const trimmed = path.replace(/[\\/]+$/, ""); + const m = trimmed.match(/[^\\/]+$/); + return m ? m[0] : trimmed; +} + +function isHttpOrDataUrl(value: string | null | undefined): boolean { + if (!value) return false; + return /^(https?:\/\/|data:)/i.test(value); +} + +// Plan §B.4: "no `file://` prefix; renderer must handle this." Codex sometimes +// emits local paths as file:// URLs — `window.ade.app.openPath` expects an OS +// path, so strip the prefix before passing it through. +function stripFileUrlPrefix(value: string | null): string | null { + if (!value) return value; + if (!/^file:\/\//i.test(value)) return value; + return value.replace(/^file:\/\//i, ""); +} + +export function CodexImageGenerationCard({ event }: CodexImageGenerationCardProps) { + const isRunning = event.status === "running"; + const isFailed = event.status === "failed"; + const result = event.result ?? null; + const previewSrc = isHttpOrDataUrl(result) ? result : null; + const savedPathRaw = event.savedPath ?? (!isHttpOrDataUrl(result) ? result : null); + const savedPath = stripFileUrlPrefix(savedPathRaw); + const prompt = (event.prompt ?? "").trim(); + const revised = (event.revisedPrompt ?? "").trim(); + const showRevisedDisclosure = Boolean(revised && revised !== prompt); + const [revisedOpen, setRevisedOpen] = useState(false); + + const titleText = prompt || revised || (savedPath ? basename(savedPath) : "Generated image"); + + const openSaved = () => { + if (!savedPath) return; + void window.ade.app.openPath(savedPath).catch(() => undefined); + }; + + return ( + +
+
+ {isRunning ? ( + + ) : isFailed ? ( + + ) : ( + + )} +
+ +
+
+

+ {isFailed ? "Image generation failed" : isRunning ? "Generating image" : "Image generated"} +

+
+

+ {titleText} +

+ + {previewSrc ? ( +
+ {titleText} +
+ ) : savedPath ? ( +
+ + + {basename(savedPath)} + +
+ ) : null} + + {savedPath ? ( +
+ +
+ ) : null} + + {showRevisedDisclosure ? ( +
+ + {revisedOpen ? ( +

+ {revised} +

+ ) : null} +
+ ) : null} +
+
+
+ ); +} + +export default CodexImageGenerationCard; diff --git a/apps/desktop/src/renderer/components/chat/codex/CodexImageViewLine.tsx b/apps/desktop/src/renderer/components/chat/codex/CodexImageViewLine.tsx new file mode 100644 index 000000000..381643fbf --- /dev/null +++ b/apps/desktop/src/renderer/components/chat/codex/CodexImageViewLine.tsx @@ -0,0 +1,79 @@ +import { ArrowUpRight } from "@phosphor-icons/react"; +import type { AgentChatEvent } from "../../../../shared/types"; +import { openExternalUrl } from "../../../lib/openExternal"; + +type ImageViewEvent = Extract; + +type CodexImageViewLineProps = { + event: ImageViewEvent; +}; + +function basename(path: string): string { + const trimmed = path.replace(/[\\/]+$/, ""); + const m = trimmed.match(/[^\\/]+$/); + return m ? m[0] : trimmed; +} + +function deriveDisplayName(event: ImageViewEvent): string { + if (event.title?.trim()) return event.title.trim(); + if (event.path?.trim()) return basename(event.path.trim()); + if (event.url?.trim()) { + try { + const parsed = new URL(event.url.trim()); + const tail = basename(parsed.pathname); + return tail || parsed.hostname; + } catch { + return event.url.trim(); + } + } + return "image"; +} + +function stripFileUrlPrefix(value: string | null): string | null { + if (!value) return value; + if (!/^file:\/\//i.test(value)) return value; + return value.replace(/^file:\/\//i, ""); +} + +export function CodexImageViewLine({ event }: CodexImageViewLineProps) { + const displayName = deriveDisplayName(event); + // Codex may pass a local path either in `event.path` or as a `file://` URL + // in `event.url`. Normalize both into a real OS path before handing off to + // `window.ade.app.openPath` (see plan §B.4). + const localPath = stripFileUrlPrefix(event.path?.trim() || null) + ?? (event.url && /^file:\/\//i.test(event.url) ? stripFileUrlPrefix(event.url.trim()) : null); + const url = event.url?.trim() && !/^file:\/\//i.test(event.url.trim()) ? event.url.trim() : null; + const canOpen = Boolean(localPath || url); + + const handleOpen = () => { + if (localPath) { + void window.ade.app.openPath(localPath).catch(() => undefined); + return; + } + if (url) openExternalUrl(url); + }; + + return ( +
+ {"↳"} + Viewing image: + + {displayName} + + {canOpen ? ( + + ) : null} +
+ ); +} + +export default CodexImageViewLine; diff --git a/apps/desktop/src/renderer/components/chat/codex/CodexOpenInCliButton.tsx b/apps/desktop/src/renderer/components/chat/codex/CodexOpenInCliButton.tsx new file mode 100644 index 000000000..4fd570664 --- /dev/null +++ b/apps/desktop/src/renderer/components/chat/codex/CodexOpenInCliButton.tsx @@ -0,0 +1,164 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { ArrowSquareOut, Terminal } from "@phosphor-icons/react"; +import { cn } from "../../ui/cn"; + +type CodexOpenInCliButtonProps = { + sessionId: string; + /** + * Called when the user picks "In ADE terminal". The button does the IPC call to + * resolve the resume command; the parent is responsible for revealing the + * built-in terminal drawer and feeding the command into it. The path is the + * chat lane's worktree. + */ + onUseAdeTerminal: (args: { + binary: string; + argv: string[]; + cwd: string; + threadId: string; + copyThreadIdToClipboard: boolean; + }) => void; +}; + +export function CodexOpenInCliButton({ sessionId, onUseAdeTerminal }: CodexOpenInCliButtonProps) { + const [open, setOpen] = useState(false); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + const [toast, setToast] = useState(null); + const containerRef = useRef(null); + + useEffect(() => { + if (!open) return; + const onDocClick = (e: MouseEvent) => { + if (!containerRef.current) return; + if (containerRef.current.contains(e.target as Node)) return; + setOpen(false); + }; + const onEsc = (e: KeyboardEvent) => { + if (e.key === "Escape") setOpen(false); + }; + document.addEventListener("mousedown", onDocClick); + document.addEventListener("keydown", onEsc); + return () => { + document.removeEventListener("mousedown", onDocClick); + document.removeEventListener("keydown", onEsc); + }; + }, [open]); + + useEffect(() => { + if (!toast) return; + const t = window.setTimeout(() => setToast(null), 4000); + return () => window.clearTimeout(t); + }, [toast]); + + const handleNewWindow = useCallback(async () => { + setOpen(false); + setError(null); + setBusy(true); + try { + const result = await window.ade.agentChat.codex.openInCli({ + sessionId, + mode: "new-window", + }); + if (result.copyThreadIdToClipboard) { + await navigator.clipboard.writeText(result.threadId).catch(() => undefined); + setToast("Thread ID copied — paste into Codex's Ctrl+R picker"); + } else { + setToast("Opened in a new terminal"); + } + } catch (e) { + setError(e instanceof Error ? e.message : String(e)); + } finally { + setBusy(false); + } + }, [sessionId]); + + const handleAdeTerminal = useCallback(async () => { + setOpen(false); + setError(null); + setBusy(true); + try { + const result = await window.ade.agentChat.codex.openInCli({ + sessionId, + mode: "ade-terminal", + }); + if (result.copyThreadIdToClipboard) { + await navigator.clipboard.writeText(result.threadId).catch(() => undefined); + setToast("Thread ID copied — paste into Codex's Ctrl+R picker"); + } + onUseAdeTerminal({ + binary: result.binary, + argv: result.argv, + cwd: result.cwd, + threadId: result.threadId, + copyThreadIdToClipboard: result.copyThreadIdToClipboard, + }); + } catch (e) { + setError(e instanceof Error ? e.message : String(e)); + } finally { + setBusy(false); + } + }, [sessionId, onUseAdeTerminal]); + + return ( +
+ + + {open ? ( +
+ +
+ +
+ ) : null} + + {toast ? ( +
+ {toast} +
+ ) : null} + {error ? ( +
+ {error} +
+ ) : null} +
+ ); +} + +export default CodexOpenInCliButton; diff --git a/apps/desktop/src/renderer/components/chat/codex/CodexPlanCard.tsx b/apps/desktop/src/renderer/components/chat/codex/CodexPlanCard.tsx new file mode 100644 index 000000000..be633ed9d --- /dev/null +++ b/apps/desktop/src/renderer/components/chat/codex/CodexPlanCard.tsx @@ -0,0 +1,124 @@ +import { useState } from "react"; +import { motion } from "motion/react"; +import type { AgentChatEvent, AgentChatPlanStep } from "../../../../shared/types"; +import { cn } from "../../ui/cn"; + +type PlanEvent = Extract; + +type CodexPlanCardProps = { + event: PlanEvent; +}; + +const VIOLET = "#A78BFA"; + +function PlanGlyph({ status }: { status: AgentChatPlanStep["status"] }) { + if (status === "in_progress") { + return ( + + {"◐"} + + ); + } + if (status === "completed") { + return ( + {"●"} + ); + } + if (status === "failed") { + return ( + {"✕"} + ); + } + return ( + {"○"} + ); +} + +export function CodexPlanCard({ event }: CodexPlanCardProps) { + const [liveOpen, setLiveOpen] = useState(false); + const hasStreaming = Boolean(event.streamingText && event.streamingText.trim().length); + const stateLabel = event.state === "complete" + ? "Plan ready" + : event.state === "active" || event.state === "delta" + ? "Planning" + : "Plan"; + const streamingTrimmed = (event.streamingText ?? "").trim(); + + return ( + +
+

+ {stateLabel} +

+ {event.steps.length ? ( + + {event.steps.filter((s) => s.status === "completed").length} + / + {event.steps.length} + + ) : null} +
+ + {event.explanation ? ( +

+ {event.explanation} +

+ ) : null} + + {event.steps.length ? ( +
    + {event.steps.map((step, idx) => { + const isActive = step.status === "in_progress"; + const isComplete = step.status === "completed"; + return ( +
  • + + + + {step.text} +
  • + ); + })} +
+ ) : !hasStreaming ? ( +
+ Drafting steps… +
+ ) : null} + + {hasStreaming ? ( +
+ +
+ ) : null} + + {hasStreaming && liveOpen ? ( +
+ {streamingTrimmed} +
+ ) : null} +
+ ); +} + +export default CodexPlanCard; diff --git a/apps/desktop/src/renderer/components/chat/codex/CodexTokenInline.tsx b/apps/desktop/src/renderer/components/chat/codex/CodexTokenInline.tsx new file mode 100644 index 000000000..e9ce58141 --- /dev/null +++ b/apps/desktop/src/renderer/components/chat/codex/CodexTokenInline.tsx @@ -0,0 +1,90 @@ +import type { CodexThreadTokenUsage } from "../../../../shared/types"; + +type CodexTokenInlineProps = { + usage: CodexThreadTokenUsage; +}; + +function formatTokens(value: number | null | undefined): string | null { + if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) return null; + if (value >= 1_000_000) return `${(value / 1_000_000).toFixed(1)}M`; + if (value >= 1_000) return `${(value / 1_000).toFixed(1)}k`; + return String(Math.round(value)); +} + +export function CodexTokenInline({ usage }: CodexTokenInlineProps) { + const total = usage.total; + const last = usage.last; + const contextWindow = usage.modelContextWindow ?? null; + const lastInputTokens = last?.inputTokens ?? null; + const lastOutputTokens = last?.outputTokens ?? null; + const usedTokens = lastInputTokens != null || lastOutputTokens != null + ? (lastInputTokens ?? 0) + (lastOutputTokens ?? 0) + : (total?.totalTokens ?? null); + const hasContextWindow = typeof contextWindow === "number" && contextWindow > 0; + const ratio = hasContextWindow && typeof usedTokens === "number" && usedTokens > 0 + ? Math.max(0, Math.min(1, usedTokens / contextWindow)) + : 0; + const cachedPortion = last?.cacheReadTokens ?? total?.cacheReadTokens ?? null; + const cacheRatio = hasContextWindow && typeof cachedPortion === "number" && cachedPortion > 0 + ? Math.max(0, Math.min(ratio, cachedPortion / contextWindow)) + : 0; + + const lastIn = formatTokens(lastInputTokens); + const lastOut = formatTokens(lastOutputTokens); + const lastCache = formatTokens(last?.cacheReadTokens); + const showLastTurn = Boolean(lastIn || lastOut || lastCache); + + if (!hasContextWindow && !showLastTurn) return null; + + return ( +
+ {hasContextWindow ? ( + + + {Math.round(ratio * 100)}% + + + + {cacheRatio > 0 ? ( + + ) : null} + + + ) : null} + + {showLastTurn ? ( + + {lastIn ? +{lastIn} in : null} + {lastOut ? ( + <> + {lastIn ? · : null} + {lastOut} out + + ) : null} + {lastCache ? ( + <> + {(lastIn || lastOut) ? · : null} + + {lastCache} {"✶"} + + + ) : null} + + ) : null} +
+ ); +} + +export default CodexTokenInline; diff --git a/apps/desktop/src/renderer/components/lanes/LanesPage.tsx b/apps/desktop/src/renderer/components/lanes/LanesPage.tsx index 021292237..1c4363a79 100644 --- a/apps/desktop/src/renderer/components/lanes/LanesPage.tsx +++ b/apps/desktop/src/renderer/components/lanes/LanesPage.tsx @@ -312,6 +312,7 @@ export function LanesPage() { const selectedLaneId = useAppStore((s) => s.selectedLaneId); const focusSession = useAppStore((s) => s.focusSession); const lanes = useAppStore((s) => s.lanes); + const lanesLoading = useAppStore((s) => s.lanesLoading); const urlLaneDeeplinks = useMemo(() => { const p = new URLSearchParams(location.search); @@ -3242,32 +3243,41 @@ export function LanesPage() { {visibleLaneIds.length === 0 ? (
{sortedLanes.length === 0 ? ( - -
- - -
-
+ lanesLoading ? ( + + + + ) : ( + +
+ + +
+
+ ) ) : ( s.selectedLaneId); + const lanesLoading = useAppStore((s) => s.lanesLoading); const selectLaneGlobal = useAppStore((s) => s.selectLane); const [selectedLaneId, setSelectedLaneId] = useState(() => { if (globallySelectedLaneId && lanes.some((lane) => lane.id === globallySelectedLaneId)) { @@ -180,9 +182,16 @@ export function WorkStartSurface({ return (
-
No lanes available
+ {lanesLoading ? ( + + ) : null} +
+ {lanesLoading ? "Loading lanes" : "No lanes available"} +
- Create or reopen a lane before starting work. + {lanesLoading + ? "Reading lane state for this project." + : "Create or reopen a lane before starting work."}
diff --git a/apps/desktop/src/renderer/state/appStore.test.ts b/apps/desktop/src/renderer/state/appStore.test.ts index 6b82cb4d6..bc2fbb4fa 100644 --- a/apps/desktop/src/renderer/state/appStore.test.ts +++ b/apps/desktop/src/renderer/state/appStore.test.ts @@ -51,6 +51,7 @@ function resetStore() { projectTransitionError: null, laneSnapshots: [], lanes: [], + lanesLoading: false, selectedLaneId: null, focusedSessionId: null, theme: "dark", @@ -300,6 +301,25 @@ describe("appStore", () => { expect(useAppStore.getState().lanes).toEqual(lanes); }); + it("refreshLanes tracks loading while lane state is pending", async () => { + const lanes = [{ id: "lane-loading", name: "Lane loading" }] as any[]; + let resolveLanes: (value: any[]) => void = () => {}; + const pendingLanes = new Promise((resolve) => { + resolveLanes = resolve; + }); + (window.ade.lanes.list as any).mockReturnValueOnce(pendingLanes); + + const refresh = useAppStore.getState().refreshLanes({ includeStatus: false }); + + expect(useAppStore.getState().lanesLoading).toBe(true); + + resolveLanes(lanes); + await refresh; + + expect(useAppStore.getState().lanes).toEqual(lanes); + expect(useAppStore.getState().lanesLoading).toBe(false); + }); + it("refreshLanes can skip conflict status for cheaper warmup snapshots", async () => { (window.ade.lanes.listSnapshots as any).mockResolvedValueOnce([]); diff --git a/apps/desktop/src/renderer/state/appStore.ts b/apps/desktop/src/renderer/state/appStore.ts index ba9c3e1ab..3b74f958e 100644 --- a/apps/desktop/src/renderer/state/appStore.ts +++ b/apps/desktop/src/renderer/state/appStore.ts @@ -534,6 +534,7 @@ type AppState = { isNewTabOpen: boolean; laneSnapshots: LaneListSnapshot[]; lanes: LaneSummary[]; + lanesLoading: boolean; selectedLaneId: string | null; focusedSessionId: string | null; projectRevision: number; @@ -642,6 +643,7 @@ let warmupTimer: number | null = null; * Slower responses whose token doesn't match the latest value are discarded. */ let laneRefreshVersion = 0; let laneRefreshInFlight: Promise | null = null; +let activeLaneRefreshProjectKey: string | null = null; let activeLaneRefreshRequest: LaneRefreshRequest | null = null; let pendingLaneRefreshRequest: LaneRefreshRequest | null = null; @@ -715,6 +717,7 @@ export const useAppStore = create((set, get) => ({ isNewTabOpen: false, laneSnapshots: [], lanes: [], + lanesLoading: false, selectedLaneId: null, focusedSessionId: null, projectRevision: 0, @@ -764,7 +767,7 @@ export const useAppStore = create((set, get) => ({ setProjectHydrated: (projectHydrated) => set({ projectHydrated }), setShowWelcome: (showWelcome) => set({ showWelcome }), clearProjectTransitionError: () => set({ projectTransitionError: null }), - setLanes: (lanes) => set({ lanes }), + setLanes: (lanes) => set({ lanes, lanesLoading: false }), selectLane: (laneId) => set({ selectedLaneId: laneId }), setLaneInspectorTab: (laneId, tab) => set((prev) => ({ @@ -950,6 +953,7 @@ export const useAppStore = create((set, get) => ({ const request = normalizeLaneRefreshRequest(options); const runRefresh = async (currentRequest: LaneRefreshRequest) => { const requestedProjectKey = normalizeProjectKey(get().project?.rootPath); + activeLaneRefreshProjectKey = requestedProjectKey; const token = ++laneRefreshVersion; const laneSnapshots = currentRequest.includeStatus ? await window.ade.lanes.listSnapshots({ @@ -1003,6 +1007,7 @@ export const useAppStore = create((set, get) => ({ return { laneSnapshots: nextSnapshots, lanes, + lanesLoading: false, selectedLaneId: nextSelected, laneInspectorTabs: nextTabs, laneWorkViewByScope: nextLaneWorkViews, @@ -1010,10 +1015,15 @@ export const useAppStore = create((set, get) => ({ }); }; + set({ lanesLoading: true }); + if (laneRefreshInFlight) { const activeRequest = activeLaneRefreshRequest; + const activeProjectKey = activeLaneRefreshProjectKey; + const requestProjectKey = normalizeProjectKey(get().project?.rootPath); const activeSatisfies = activeRequest != null + && activeProjectKey === requestProjectKey && (activeRequest.includeStatus || !request.includeStatus) && (activeRequest.includeConflictStatus || !request.includeConflictStatus) && (activeRequest.includeRebaseSuggestions || !request.includeRebaseSuggestions) @@ -1037,8 +1047,10 @@ export const useAppStore = create((set, get) => ({ } })().finally(() => { laneRefreshInFlight = null; + activeLaneRefreshProjectKey = null; activeLaneRefreshRequest = null; pendingLaneRefreshRequest = null; + set({ lanesLoading: false }); }); await laneRefreshInFlight; @@ -1098,7 +1110,7 @@ export const useAppStore = create((set, get) => ({ try { const project = await window.ade.project.openRepo(); if (!project) { - set({ projectTransition: null }); + set({ projectTransition: null, lanesLoading: false }); return null; } get().setProject(project); @@ -1110,6 +1122,7 @@ export const useAppStore = create((set, get) => ({ isNewTabOpen: false, laneSnapshots: [], lanes: [], + lanesLoading: true, selectedLaneId: null, focusedSessionId: null, laneInspectorTabs: {}, @@ -1129,6 +1142,7 @@ export const useAppStore = create((set, get) => ({ } catch (error) { set({ projectTransition: null, + lanesLoading: false, projectTransitionError: formatProjectTransitionError("opening", error), }); throw error; @@ -1160,6 +1174,7 @@ export const useAppStore = create((set, get) => ({ isNewTabOpen: false, laneSnapshots: [], lanes: [], + lanesLoading: true, selectedLaneId: null, focusedSessionId: null, laneInspectorTabs: {}, @@ -1205,6 +1220,7 @@ export const useAppStore = create((set, get) => ({ } catch (error) { set({ projectTransition: null, + lanesLoading: false, projectTransitionError: formatProjectTransitionError("switching", error), }); throw error; @@ -1238,6 +1254,7 @@ export const useAppStore = create((set, get) => ({ isNewTabOpen: false, laneSnapshots: [], lanes: [], + lanesLoading: true, selectedLaneId: null, focusedSessionId: null, laneInspectorTabs: {}, @@ -1249,6 +1266,7 @@ export const useAppStore = create((set, get) => ({ } catch (error) { set({ projectTransition: null, + lanesLoading: false, projectTransitionError: formatProjectTransitionError("switching", error), }); throw error; @@ -1257,6 +1275,7 @@ export const useAppStore = create((set, get) => ({ closeProject: async () => { const closingProjectRoot = get().project?.rootPath ?? null; + ++laneRefreshVersion; set({ projectTransition: { kind: "closing", @@ -1278,6 +1297,7 @@ export const useAppStore = create((set, get) => ({ isNewTabOpen: false, laneSnapshots: [], lanes: [], + lanesLoading: false, selectedLaneId: null, focusedSessionId: null, laneInspectorTabs: {}, @@ -1290,6 +1310,7 @@ export const useAppStore = create((set, get) => ({ } catch (error) { set({ projectTransition: null, + lanesLoading: false, projectTransitionError: formatProjectTransitionError("closing", error), }); throw error; diff --git a/apps/desktop/src/shared/ipc.ts b/apps/desktop/src/shared/ipc.ts index 7ce53391f..4ce1a2e91 100644 --- a/apps/desktop/src/shared/ipc.ts +++ b/apps/desktop/src/shared/ipc.ts @@ -205,6 +205,7 @@ export const IPC = { agentChatGetSessionCapabilities: "ade.agentChat.getSessionCapabilities", agentChatGetTurnFileDiff: "ade.agentChat.getTurnFileDiff", agentChatGetEventHistory: "ade.agentChat.getEventHistory", + agentChatCodexOpenInCli: "ade.agentChat.codex.openInCli", computerUseListArtifacts: "ade.computerUse.listArtifacts", computerUseGetOwnerSnapshot: "ade.computerUse.getOwnerSnapshot", computerUseRouteArtifact: "ade.computerUse.routeArtifact", diff --git a/apps/desktop/src/shared/types/chat.test.ts b/apps/desktop/src/shared/types/chat.test.ts index 9a3dc83bd..2909f2f62 100644 --- a/apps/desktop/src/shared/types/chat.test.ts +++ b/apps/desktop/src/shared/types/chat.test.ts @@ -108,4 +108,17 @@ describe("mergeAttachments", () => { const result = mergeAttachments(current, incoming); expect(result.map((a) => a.path)).toEqual(["/first.ts", "/second.ts", "/third.ts"]); }); + + it("deduplicates image URL attachments by URL path", () => { + const current: AgentChatFileRef[] = [ + { path: "https://example.com/old.png", type: "image-url", url: "https://example.com/old.png" }, + ]; + const incoming: AgentChatFileRef[] = [ + { path: "https://example.com/old.png", type: "image-url", url: "https://example.com/old.png?cache=1" }, + ]; + + const result = mergeAttachments(current, incoming); + expect(result).toHaveLength(1); + expect(result[0]).toEqual(incoming[0]); + }); }); diff --git a/apps/desktop/src/shared/types/chat.ts b/apps/desktop/src/shared/types/chat.ts index ea08447ee..a8400ef60 100644 --- a/apps/desktop/src/shared/types/chat.ts +++ b/apps/desktop/src/shared/types/chat.ts @@ -76,11 +76,19 @@ export type AgentChatNoticeDetail = { permissionModeTransition?: "entered_plan_mode" | "exited_plan_mode"; }; -export type AgentChatFileRef = { +export type AgentChatLocalFileRef = { path: string; type: "file" | "image"; }; +export type AgentChatImageUrlRef = { + path: string; + type: "image-url"; + url: string; +}; + +export type AgentChatFileRef = AgentChatLocalFileRef | AgentChatImageUrlRef; + export type AgentChatLinearIssueContextAttachment = { type: "linear_issue"; issue: LaneLinearIssue; @@ -97,7 +105,7 @@ export const PARALLEL_CHAT_MAX_ATTACHMENTS = 12; export function inferAttachmentType( filePath: string, mimeType?: string | null, -): AgentChatFileRef["type"] { +): AgentChatLocalFileRef["type"] { if (mimeType?.startsWith("image/")) return "image"; return /\.(png|jpe?g|gif|webp|bmp|svg|ico|tiff?)$/i.test(filePath) ? "image" : "file"; } @@ -121,6 +129,45 @@ export type AgentChatPlanStep = { status: "pending" | "in_progress" | "completed" | "failed"; }; +export type CodexPlanState = "active" | "delta" | "updated" | "complete"; + +export type CodexWebSearchAction = { + type: string; + status?: "pending" | "running" | "completed" | "failed"; + query?: string; + url?: string; + title?: string; + snippet?: string; +}; + +export type CodexTokenUsageBreakdown = { + inputTokens?: number; + outputTokens?: number; + cacheReadTokens?: number; + cacheWriteTokens?: number; + totalTokens?: number; +}; + +export type CodexThreadTokenUsage = { + threadId?: string | null; + turnId?: string | null; + total?: CodexTokenUsageBreakdown; + last?: CodexTokenUsageBreakdown; + modelContextWindow?: number | null; +}; + +export type CodexThreadGoalStatus = "active" | "paused" | "budget_limited" | "complete" | "cancelled" | "unknown"; + +export type CodexThreadGoal = { + objective?: string | null; + tokenBudget?: number | null; + status?: CodexThreadGoalStatus; + tokensUsed?: number | null; + timeUsedSeconds?: number | null; + createdAt?: string | null; + updatedAt?: string | null; +}; + export type AgentChatCompletionArtifact = { type: string; description: string; @@ -217,6 +264,9 @@ export type AgentChatEvent = steps: AgentChatPlanStep[]; turnId?: string; explanation?: string | null; + itemId?: string; + state?: CodexPlanState; + streamingText?: string; } | { type: "reasoning"; @@ -394,9 +444,15 @@ export type AgentChatEvent = preTokens?: number; turnId?: string; } + | { + type: "codex_context_compaction"; + turnId: string; + state: "started" | "completed"; + trigger: "manual" | "auto"; + } | { type: "system_notice"; - noticeKind: "auth" | "rate_limit" | "hook" | "file_persist" | "info" | "memory" | "provider_health" | "thread_error"; + noticeKind: "auth" | "rate_limit" | "hook" | "file_persist" | "info" | "memory" | "provider_health" | "thread_error" | "warning" | "error" | "config"; message: string; detail?: string | AgentChatNoticeDetail; steerId?: string; @@ -411,6 +467,7 @@ export type AgentChatEvent = type: "web_search"; query: string; action?: string; + actions?: CodexWebSearchAction[]; itemId: string; logicalItemId?: string; turnId?: string; @@ -430,10 +487,38 @@ export type AgentChatEvent = turnId?: string; } | { - type: "plan_text"; - text: string; + type: "codex_image_generation"; + itemId: string; + turnId?: string; + prompt?: string | null; + revisedPrompt?: string | null; + result?: string | null; + /** Local filesystem path if Codex saved the image to disk; null when the result is purely a URL/data URI. */ + savedPath?: string | null; + status: "running" | "completed" | "failed"; + } + | { + type: "codex_image_view"; + itemId: string; + turnId?: string; + path?: string | null; + url?: string | null; + title?: string | null; + status: "running" | "completed" | "failed"; + } + | { + type: "codex_token_usage"; + usage: CodexThreadTokenUsage; + turnId?: string; + } + | { + type: "codex_goal_updated"; + goal: CodexThreadGoal | null; + turnId?: string; + } + | { + type: "codex_goal_cleared"; turnId?: string; - itemId?: string; } | { type: "turn_diff_summary"; @@ -568,6 +653,9 @@ export type AgentChatSession = { automationRunId?: string | null; capabilityMode?: CtoCapabilityMode; completion?: AgentChatCompletionReport | null; + codexGoal?: CodexThreadGoal | null; + codexTokenUsage?: CodexThreadTokenUsage | null; + runtimeMode?: AgentChatRuntimeMode; status: AgentChatSessionStatus; idleSinceAt?: string | null; archivedAt?: string | null; @@ -610,6 +698,8 @@ export type AgentChatSessionSummary = { automationRunId?: string | null; capabilityMode?: CtoCapabilityMode; completion?: AgentChatCompletionReport | null; + codexGoal?: CodexThreadGoal | null; + codexTokenUsage?: CodexThreadTokenUsage | null; status: AgentChatSessionStatus; idleSinceAt?: string | null; startedAt: string; @@ -738,8 +828,11 @@ export type AgentChatCreateArgs = { automationRunId?: string | null; openInUi?: boolean; requestedCwd?: string; + runtimeMode?: AgentChatRuntimeMode; }; +export type AgentChatRuntimeMode = "interactive" | "print"; + export type AgentChatHandoffArgs = { sourceSessionId: string; targetModelId: ModelId; @@ -980,3 +1073,25 @@ export type AgentChatGetTurnFileDiffArgs = { }; export type AgentChatTurnFileDiff = FileDiff; + +export type AgentChatCodexOpenInCliMode = "ade-terminal" | "new-window"; + +export type AgentChatCodexOpenInCliArgs = { + sessionId: string; + mode: AgentChatCodexOpenInCliMode; +}; + +export type AgentChatCodexOpenInCliResult = { + /** Absolute path to the codex binary to invoke (the bundled one by default). */ + binary: string; + /** Argument vector to pass to the binary. Empty when no resume-flag exists. */ + argv: string[]; + /** Lane worktree path to `cd` into before invoking. */ + cwd: string; + /** Codex thread to resume. */ + threadId: string; + /** True when no `resume`/`--thread` form was detected; the renderer should copy threadId to clipboard and show a toast. */ + copyThreadIdToClipboard: boolean; + /** Set when mode === "new-window" and a terminal launcher was spawned. */ + spawnedNewWindow?: boolean; +}; diff --git a/docs/codex migration plan.md b/docs/codex migration plan.md new file mode 100644 index 000000000..5b1288c08 --- /dev/null +++ b/docs/codex migration plan.md @@ -0,0 +1,1197 @@ +# Codex `app-server` Migration Plan + +Linear: [ADE-32](https://linear.app/ade-linear/issue/ADE-32) · GitHub: [#278](https://github.com/arul28/ADE/issues/278) +Status: spec (proposed) +Date: 2026-05-11 +Target Codex release: `rust-v0.130.0` (latest stable; alpha track ignored) + +This document is the wire-level + UI-level migration spec for bringing ADE's bundled Codex `app-server` and its work-tab + TUI chat surfaces to feature parity with Codex CLI / Codex Desktop on the **chat UX layer** (Tier A in the planning conversation). Capability-layer additions (plugins UI, MCP-in-app, hooks UI, realtime voice, fs/process/command-exec RPCs, environments, dynamic tools, multi-agent UI, memory mode) are explicitly out of scope here — they are tracked separately and called out at the bottom. + +The spec assumes the prior plan at [`plans/ade-32-codex-v130-chat-parity.md`](../plans/ade-32-codex-v130-chat-parity.md) is approved. This document replaces and supersedes that plan with structural detail. + +--- + +## 1. Reference snapshot + +All Codex source citations are pinned to `openai/codex` `main` branch as of 2026-05-11. Every URL below resolves to a single immutable Rust file or markdown doc; we should re-verify these before starting implementation in case `main` has moved. + +### 1.1 Codex repo (`openai/codex/codex-rs/`) + +| Topic | URL | +|---|---| +| Wire registry (every method + notification name) | https://raw.githubusercontent.com/openai/codex/main/codex-rs/app-server-protocol/src/protocol/common.rs | +| JSON-RPC envelope | https://raw.githubusercontent.com/openai/codex/main/codex-rs/app-server-protocol/src/jsonrpc_lite.rs | +| Item enum (`ThreadItem`) and item-streaming notifications | https://raw.githubusercontent.com/openai/codex/main/codex-rs/app-server-protocol/src/protocol/v2/item.rs | +| Thread lifecycle, goals, compaction, token usage | https://raw.githubusercontent.com/openai/codex/main/codex-rs/app-server-protocol/src/protocol/v2/thread.rs | +| Turn lifecycle, `TurnPlanStep`, `UserInput` | https://raw.githubusercontent.com/openai/codex/main/codex-rs/app-server-protocol/src/protocol/v2/turn.rs | +| Top-level notifications (`error`, `warning`, `deprecationNotice`) | https://raw.githubusercontent.com/openai/codex/main/codex-rs/app-server-protocol/src/protocol/v2/notification.rs | +| App-server README — initialize, capabilities, subscription lifecycle | https://raw.githubusercontent.com/openai/codex/main/codex-rs/app-server/README.md | +| v1 → v2 migration doc | https://raw.githubusercontent.com/openai/codex/main/codex-rs/docs/protocol_v1.md | +| v0.130.0 release notes | https://github.com/openai/codex/releases/tag/rust-v0.130.0 | +| Slash command inventory | https://developers.openai.com/codex/cli/slash-commands | + +### 1.2 ADE codebase (current state, file:line refs) + +| Surface | File | Line range | +|---|---|---| +| Codex runtime (spawn, JSON-RPC, notification dispatch) | `apps/desktop/src/main/services/chat/agentChatService.ts` | spawn 11023-11068; readline transport 11070-11146; notification dispatch 10441-11021; `turn/start` 7770-7807 | +| Executable resolver | `apps/desktop/src/main/services/ai/codexExecutable.ts` | 1-50 (whole file) | +| Normalized event union (used by both surfaces) | `apps/desktop/src/shared/types/chat.ts` | `AgentChatEvent` 150-446; `Session` 551-553 | +| Desktop chat root | `apps/desktop/src/renderer/components/chat/AgentChatPane.tsx` | 1763-5919 | +| Desktop message list (event switch) | `apps/desktop/src/renderer/components/chat/AgentChatMessageList.tsx` | switch 2024-2915; `CollapsibleCard` 963-1021; `InlineDisclosureRow` 699-748 | +| Desktop plan card (existing for Claude) | `apps/desktop/src/renderer/components/chat/ChatProposedPlanCard.tsx` | whole file | +| Desktop composer | `apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx` | 1-800+ | +| Desktop slash/file palette | `apps/desktop/src/renderer/components/chat/ChatCommandMenu.tsx` | 76-160 | +| TUI ChatView formatter | `apps/ade-cli/src/tuiClient/format.ts` | switch 258-352 | +| TUI command registry | `apps/ade-cli/src/tuiClient/commands.ts` | 12-113 | +| TUI palettes | `apps/ade-cli/src/tuiClient/components/SlashPalette.tsx` 8-46; `MentionPalette.tsx` 13-34 | | +| TUI model status bar | `apps/ade-cli/src/tuiClient/components/ModelStatus.tsx` | 28-76 | +| TUI theme | `apps/ade-cli/src/tuiClient/theme.ts` | 55-78 | +| Release matrix | `.github/workflows/release-core.yml` | runtime matrix 215-332; mac signing 87-130; runtime sign+notarize 313-318 | +| Extra resources packaging | `apps/desktop/package.json` | 175-214 | +| Version stamping | `apps/desktop/scripts/set-release-version.mjs` | 13-34 | + +--- + +## 2. Scope, goals, non-goals + +### 2.1 Goals (Tier A) + +Each item below is a fully-defined deliverable, with both a desktop and a TUI implementation, that ships as part of this milestone: + +1. Bundle and pin `codex` `rust-v0.130.0` in the desktop installer and the `apps/ade-cli` npm package (replacing the current `PATH`-based resolution). +2. Cleanup of the existing handshake (remove triple-name `effort` shim; drop `--disable plugins --disable apps` flags; keep `--disable browser_use --disable computer_use`). +3. Structured plan-mode card (`/plan`): renders `turn/plan/updated` + `Plan` items + `plan/delta`. +4. Manual `/compact` slash command: calls `thread/compact/start`; renders the `ContextCompaction` item. +5. Goals (`/goal set | get | clear`): calls `thread/goal/{set,get,clear}`; renders `thread/goal/updated`/`thread/goal/cleared` as a pinned banner. +6. Image input parity: support `{ type: "image", url }` for clipboard / drag-dropped URLs (we already support `localImage`). +7. `imageGeneration` item rendering (thumbnail + revised prompt + path). +8. `imageView` tool-call item rendering. +9. Rich `webSearch` item: render every `WebSearchAction` variant (`search`, `openPage`, `findInPage`, plus an `other` catch-all). +10. Token-usage HUD: surface `thread/tokenUsage/updated` (both `total` cumulative and `last` per-turn) in the model status bar. +11. Thread history UX: `/resume` palette with filter+search; fork, unarchive, rollback actions. +12. Long-thread pagination: `thread/turns/list` with `itemsView: "summary"` on resume, lazy-load `"full"` on scroll. +13. `optOutNotificationMethods` plumbing for non-streaming consumers (TUI `--print`). + +### 2.2 Explicit non-goals (deferred to follow-ups) + +These will become separate Linear tickets after Tier A ships: + +- Plugin browser UI, marketplace add/remove/upgrade UI (the runtime is enabled by dropping `--disable plugins`; users configure plugins via the Codex CLI / `~/.codex/`). +- Apps / connectors UI (same: runtime enabled by dropping `--disable apps`). +- MCP-in-app UI (OAuth, server status, tool catalog). +- Hooks system (`hooks/list`, `hook/started`, `hook/completed`, `hookPrompt` item). +- Realtime voice (`thread/realtime/*`). +- `command/exec`, `process/spawn`, `fs/*` RPCs. +- Environments (`environment/add`, environment-routed `view_image`). +- Dynamic client tools (`item/tool/call` server-initiated, `dynamicToolCall` item). +- Multi-agent collaboration UI (`collabAgentToolCall` item, collaboration mode picker). +- Memory mode (`thread/memoryMode/set`, `memory/reset`). +- Attestation (`attestation/generate` server-initiated request). +- External agent import (`externalAgentConfig/{detect,import}`). +- Vim composer, `/keymap`, `/title`, `/statusline`, `/ide`, `Ctrl+R` reverse history search (TUI-only nice-to-haves). + +### 2.3 Architectural principle + +ADE's TUI does **not** speak Codex protocol. It consumes the normalized `AgentChatEvent` envelope (`apps/desktop/src/shared/types/chat.ts:150-446`) published by `agentChatService.ts` via the ADE RPC server. Every feature in this spec therefore threads through **three layers** in order: + +``` + [ codex app-server JSON-RPC ] + ↓ ↑ + [ agentChatService.ts ] ←—— receive Codex notification / send Codex request + ↓ + [ AgentChatEvent union (shared/types/chat.ts) ] ←—— add new variant + ↓ + ┌──────────────────────┬──────────────────────────┐ + │ desktop renderer │ TUI ChatView formatter │ + │ (AgentChatMessageList│ (format.ts) │ + │ .tsx switch) │ │ + └──────────────────────┴──────────────────────────┘ +``` + +The shared union is the contract. **Every phase below ships a desktop AND a TUI renderer for any new variant** (per the user's "parity in one pass" choice). + +--- + +## 3. Architecture decisions and their sources + +This section captures the load-bearing architectural calls with citations. If a future engineer reverses one, they should at least know what they're reversing. + +### 3.1 Pin to `rust-v0.130.0` stable, not the alpha track + +- **Decision:** bundle `rust-v0.130.0`. Skip `rust-v0.131.0-alpha.*`. +- **Source:** v0.130.0 is the latest stable per [github.com/openai/codex/releases/tag/rust-v0.130.0](https://github.com/openai/codex/releases/tag/rust-v0.130.0). 0.131 alphas are work-in-progress; nothing in Tier A requires them. +- **Reversibility:** trivial. We can re-pin in a single env var (`CODEX_VERSION`) plus a checksum update. + +### 3.2 Bundle the binary; do not rely on user's `codex` on PATH + +- **Decision:** ship the binary inside both the Electron installer (`extraResources` → `resources/codex-bin/{target}/codex`) and the `apps/ade-cli` npm package. +- **Source:** current `resolveCodexExecutable` (`apps/desktop/src/main/services/ai/codexExecutable.ts:18-42`) falls through to literal `"codex"` if nothing else resolves, which means users without `codex` on PATH get a confusing crash. The release matrix already does this for ADE's own runtime binaries (`.github/workflows/release-core.yml:215-332`); we extend the same pattern. +- **Reversibility:** keep `CODEX_EXECUTABLE` / `CODEX_EXECUTABLE_PATH` env overrides for dev. Bundled binary is just a higher-priority resolution step. + +### 3.3 Speak Codex v2 only (we already do) + +- **Decision:** continue to send v2 wire names (`thread/start`, `turn/start`, `item/*`) and ignore the deprecated v1 `codex/event` / `newConversation` / `sendUserMessage` surface. +- **Source:** verified ADE already speaks v2 at `agentChatService.ts:11349, 7793, 10441-11021`. v1 docs at [codex-rs/docs/protocol_v1.md](https://raw.githubusercontent.com/openai/codex/main/codex-rs/docs/protocol_v1.md) explicitly mark v1 as legacy. +- **Reversibility:** none; v1 is being removed upstream. + +### 3.4 Drop `--disable plugins --disable apps`; keep `--disable browser_use --disable computer_use` + +- **Decision:** at `agentChatService.ts:11059`, the launch line currently disables all four. Plugins and apps are configured via the Codex CLI / `~/.codex/` and benefit ADE for free; browser-use and computer-use conflict with ADE's own ai-tools layer and stay disabled. +- **Source:** plugin install flow is the `plugin/install` JSON-RPC method (`codex-rs/app-server-protocol/src/protocol/v2/plugin.rs`), invoked by users via `codex plugin install @`. ADE doesn't need a UI for this in Tier A — it just needs to stop disabling the runtime path. +- **Reversibility:** trivial. + +### 3.5 Normalize through `AgentChatEvent`, do not pass Codex types to renderers + +- **Decision:** every new Codex item gets its own `AgentChatEvent` variant; renderers never import Codex protocol types directly. +- **Source:** the shared union at `apps/desktop/src/shared/types/chat.ts:150-446` is already the single source of truth for both renderers (47 variants today). The TUI runs out of process and only receives JSON via ADE's RPC server — Codex types can't cross that boundary. +- **Reversibility:** none. This is structural to ADE. + +### 3.6 Use `experimentalApi: true` in `initialize` + +- **Decision:** opt in. +- **Source:** README, [codex-rs/app-server/README.md L1850](https://raw.githubusercontent.com/openai/codex/main/codex-rs/app-server/README.md): *"This setting is negotiated once at initialization time for the process lifetime."* Several Tier A surfaces are experimental-gated: `thread/turns/list`, `thread/goal/*`, `thread/start.permissions`, `turn/start.permissions`. Skipping the flag means those return `requires experimentalApi capability` errors. +- **Reversibility:** flip the boolean. + +### 3.7 Do not implement `requestAttestation` or any `chatgptAuthTokens` capability + +- **Decision:** do not set `capabilities.requestAttestation`; do not attempt to handle `attestation/generate` server-initiated requests or `account/chatgptAuthTokens/refresh`. +- **Source:** Codex desktop is the host that owns ChatGPT tokens in-memory for the VS Code / desktop extension flow. ADE uses the standard ChatGPT OAuth via the `codex` CLI's existing auth files in `~/.codex/`. No `requestChatgptAuthTokens` capability exists in the README on `main` (verified). Per the protocol research agent's gotcha #12: *"If the migration spec mentions this, the spec is wrong."* +- **Reversibility:** add later when ADE wants in-app ChatGPT OAuth. + +### 3.8 Render `WebSearchAction::Other` as a generic fallback + +- **Decision:** the Rust enum has `#[serde(other)]` `Other` — meaning any new action variant added upstream deserializes as `{ type: "other" }`. Our renderer must handle this, not crash on it. +- **Source:** `WebSearchAction` definition in `codex-rs/app-server-protocol/src/protocol/v2/item.rs`. +- **Reversibility:** none — this is forward-compat scaffolding. + +### 3.9 Pagination defaults to `itemsView: "summary"` on resume + +- **Decision:** when resuming a thread, fetch a summary view first, then lazily upgrade to `"full"` on user scroll. +- **Source:** README L405: *"omitted `itemsView` defaults to `"summary"`."* Resume + summary is the fastest path; fetching `"full"` upfront on multi-hundred-turn threads will block the UI. +- **Reversibility:** trivial. + +### 3.10 ADE never sends a `"jsonrpc": "2.0"` field + +- **Decision:** match Codex's non-strict envelope. +- **Source:** verbatim from `jsonrpc_lite.rs`: *"We do not do true JSON-RPC 2.0, as we neither send nor expect the `jsonrpc: 2.0` field."* +- **Reversibility:** none. + +--- + +## 4. Wire spec — exact shapes we'll handle + +The protocol research agent extracted the Rust struct definitions verbatim. Below are the TypeScript types we'll add to `apps/desktop/src/shared/types/chat.ts` (or a new sibling `apps/desktop/src/shared/types/codex.ts` — see §5.2). Every type maps one-to-one to a Rust struct in `codex-rs/app-server-protocol/src/protocol/v2/`. + +### 4.1 Items (subset we render) + +```ts +// codex-rs/app-server-protocol/src/protocol/v2/item.rs + +export type CodexPlanItem = { + type: "plan"; + id: string; + text: string; +}; + +export type CodexContextCompactionItem = { + type: "contextCompaction"; + id: string; +}; + +export type CodexWebSearchItem = { + type: "webSearch"; + id: string; + query: string; + action: CodexWebSearchAction | null; +}; + +export type CodexWebSearchAction = + | { type: "search"; query: string | null; queries: string[] | null } + | { type: "openPage"; url: string | null } + | { type: "findInPage"; url: string | null; pattern: string | null } + | { type: "other" }; + +export type CodexImageGenerationItem = { + type: "imageGeneration"; + id: string; + status: string; // free-form per upstream + revisedPrompt: string | null; + result: string; // URL or base64 ref + savedPath?: string; +}; + +export type CodexImageViewItem = { + type: "imageView"; + id: string; + path: string; +}; +``` + +### 4.2 Goal types + +```ts +// codex-rs/app-server-protocol/src/protocol/v2/thread.rs + +export type CodexThreadGoal = { + threadId: string; + objective: string; + status: "active" | "paused" | "budgetLimited" | "complete"; + tokenBudget: number | null; + tokensUsed: number; + timeUsedSeconds: number; + createdAt: number; + updatedAt: number; +}; + +export type CodexThreadGoalSetParams = { + threadId: string; + objective?: string | null; + status?: CodexThreadGoal["status"] | null; + // Double-Option: omit ⇒ unchanged; null ⇒ clear; number ⇒ set. + tokenBudget?: number | null; +}; + +export type CodexThreadGoalUpdatedNotification = { + threadId: string; + turnId: string | null; + goal: CodexThreadGoal; +}; + +export type CodexThreadGoalClearedNotification = { + threadId: string; +}; +``` + +### 4.3 Token usage + +```ts +// codex-rs/app-server-protocol/src/protocol/v2/thread.rs + +export type CodexTokenUsageBreakdown = { + totalTokens: number; + inputTokens: number; + cachedInputTokens: number; + outputTokens: number; + reasoningOutputTokens: number; +}; + +export type CodexTokenUsage = { + total: CodexTokenUsageBreakdown; // cumulative across thread + last: CodexTokenUsageBreakdown; // last turn only + modelContextWindow: number | null; +}; + +export type CodexThreadTokenUsageUpdatedNotification = { + threadId: string; + turnId: string; + tokenUsage: CodexTokenUsage; +}; +``` + +### 4.4 Plan steps (turn-scoped, structured) + +```ts +// codex-rs/app-server-protocol/src/protocol/v2/turn.rs + +export type CodexTurnPlanStep = { + step: string; + status: "pending" | "inProgress" | "completed"; +}; + +export type CodexTurnPlanUpdatedNotification = { + threadId: string; + turnId: string; + explanation: string | null; + plan: CodexTurnPlanStep[]; +}; + +export type CodexPlanDeltaNotification = { + threadId: string; + turnId: string; + itemId: string; + delta: string; +}; +``` + +### 4.5 Thread list / read / fork / unarchive / rollback + +```ts +// codex-rs/app-server-protocol/src/protocol/v2/thread.rs + +export type CodexThreadListParams = { + cursor?: string; + limit?: number; + sortKey?: "created_at" | "updated_at"; // SNAKE_CASE on wire (see §3 gotcha 5) + sortDirection?: "asc" | "desc"; // SNAKE_CASE on wire + modelProviders?: string[]; + sourceKinds?: CodexThreadSourceKind[]; + archived?: boolean; + cwd?: string | string[]; // untagged union (see §3 gotcha 6) + searchTerm?: string; +}; + +export type CodexThreadSourceKind = + | "cli" | "vscode" | "exec" | "appServer" + | "subAgent" | "subAgentReview" | "subAgentCompact" + | "subAgentThreadSpawn" | "subAgentOther" | "unknown"; + +export type CodexThreadListResponse = { + data: CodexThread[]; + nextCursor: string | null; + backwardsCursor: string | null; +}; + +export type CodexThreadForkParams = { + threadId: string; + ephemeral?: boolean; + // ... plus optional model/sandbox/permission overrides (see thread.rs) +}; + +export type CodexThreadRollbackParams = { + threadId: string; + numTurns: number; // must be >= 1 +}; + +export type CodexThreadTurnsListParams = { + threadId: string; + cursor?: string; + limit?: number; + sortDirection?: "asc" | "desc"; + itemsView?: "notLoaded" | "summary" | "full"; // defaults to "summary" +}; +``` + +### 4.6 User input (sent on `turn/start`) + +```ts +// codex-rs/app-server-protocol/src/protocol/v2/turn.rs + +export type CodexUserInput = + | { type: "text"; text: string; text_elements?: CodexTextElement[] } + | { type: "image"; url: string } // ← NEW for Phase 5 + | { type: "localImage"; path: string } // already implemented + | { type: "skill"; name: string; path: string } + | { type: "mention"; name: string; path: string }; // already implemented + +export type CodexTextElement = { + byteRange: { start: number; end: number }; + placeholder?: string | null; +}; +``` + +### 4.7 Initialize capabilities + +```ts +// codex-rs/app-server/README.md (verbatim shape) + +export type CodexInitializeParams = { + clientInfo: { name: string; title?: string; version: string }; + capabilities?: { + experimentalApi?: boolean; // we set true + optOutNotificationMethods?: string[]; // exact method names + // NOTE: do NOT set `requestAttestation` (see §3.7) + }; +}; +``` + +### 4.8 Gotchas captured from the protocol research + +These are subtle wire facts that will cost us hours if forgotten. Each maps to a specific check we have to enforce: + +1. **No `"jsonrpc": "2.0"` field** — neither sent nor expected (`jsonrpc_lite.rs`). +2. **`WebSearchAction.Other`** is a `#[serde(other)]` catch-all; our union must include `{ type: "other" }`. +3. **Token usage carries 5 fields** per breakdown, including `cachedInputTokens` and `reasoningOutputTokens` — not the 3-field shape from older protocols. +4. **Double-Option serialization** in goals, service tier, git info: distinguish "omit (unchanged)" from "null (cleared)". On our side that means `undefined` in TS request bodies must be elided, not serialized as `null`. +5. **`ThreadSortKey` and `SortDirection` use snake_case** (`"created_at"`, `"updated_at"`, `"asc"`, `"desc"`) — unlike every other enum. Don't camelCase these. +6. **`ThreadListParams.cwd` is `#[serde(untagged)]`** — accepts a string OR a string array. +7. **`UserInput::Image.url` not `imageUrl`** — v2 wire renames it. +8. **`developerInstructions` is thread-scoped only**, not on `TurnStartParams`. Don't try to override per-turn. +9. **`dynamicTools` is thread-scoped only** (same as `developerInstructions`). +10. **`thread/turns/items/list` returns unsupported-method** on `main`. Use `thread/turns/list` with `itemsView: "full"`. +11. **`ContextCompactedNotification` is deprecated**; use the `ContextCompaction` item. +12. **30-min idle eviction** of subscribed threads only after last subscriber leaves AND zero activity. New `thread/start`/`thread/fork` auto-subscribes. + +--- + +## 5. Migration phases + +Each phase is a self-contained deliverable. Phases are ordered by risk + dependency, not user value. Phase 0 must land first; phases 1-9 can be parallelized across people but the desktop and TUI legs of any single phase must land together (per the user's "parity in one pass" call). + +### Phase 0 — Bundle binary + handshake cleanup + +#### 0.1 Bundle `codex` v0.130.0 + +**What changes:** + +1. New env var `CODEX_VERSION=0.130.0` (canonical version source, mirrors `ADE_STATIC_NODE_VERSION`). +2. New script `apps/desktop/scripts/download-codex-binary.mjs` that: + - Reads `CODEX_VERSION` and target triple from CI matrix. + - Downloads from `https://github.com/openai/codex/releases/download/rust-v${CODEX_VERSION}/codex-${target}.tar.gz`. + - Verifies SHA256 against a checked-in manifest `apps/desktop/resources/codex-bin/checksums.json`. + - Extracts the `codex` binary to `apps/desktop/resources/codex-bin/${target}/codex`. +3. New release-workflow job `download-codex-binaries` in `.github/workflows/release-core.yml`, parallel to `build-runtime-binaries`, fanned across the same matrix (lines 219-228: `darwin-arm64`, `darwin-x64`, `linux-x64`, `linux-arm64`; Windows added at workflow level). +4. macOS notarization: codex binary inherits the app-bundle code signature when `notarize:mac:dmg` runs because it lives under `extraResources` (already-signed `hardenedRuntime` entitlements at `apps/desktop/build/entitlements.mac.plist` apply). If notarization rejects it, fall back to signing the binary independently with `apps/desktop/scripts/notarize-mac-dmg.mjs` extended to walk `resources/codex-bin/`. +5. `extraResources` entry added to `apps/desktop/package.json:175-214`: + ```json + { "from": "resources/codex-bin", "to": "codex-bin", "filter": ["**/*"] } + ``` +6. `apps/ade-cli/package.json`: add an optional dependency per platform (npm's standard binary-shipping pattern) — `@ade/codex-bin-darwin-arm64`, etc. Each is a thin npm package containing the binary. Alternative: a `postinstall` script that downloads the binary on user install. Per the release pipeline research (§3), ADE CLI is pure JS today; postinstall is the cleaner first step. + +**Sources for this approach:** +- ADE already does this fanned-matrix pattern for its own runtime binaries: `release-core.yml:215-332`. Sign+notarize step at L313-318 is the model for darwin codex binaries. +- `extraResources` shape: existing entries at `apps/desktop/package.json:175-214`. + +#### 0.2 Extend `resolveCodexExecutable` + +Current (`apps/desktop/src/main/services/ai/codexExecutable.ts:18-42`) resolves in this order: auth → env → known-dir → fallback `"codex"`. Insert a new step at priority 2 (after env vars, before known-dir search): + +```ts +// Pseudocode +const bundledPath = resolveBundledCodexPath(); // checks app.getAppPath()/Contents/Resources/codex-bin/{target}/codex on macOS +if (bundledPath && fs.existsSync(bundledPath)) { + return { path: bundledPath, source: "bundled" }; +} +``` + +Add `"bundled"` to the `CodexExecutableResolution.source` enum. + +#### 0.3 Drop `--disable plugins --disable apps` + +At `agentChatService.ts:11059`, change: + +```ts +appServerArgs.push("--disable", "plugins", "--disable", "apps", "--disable", "browser_use", "--disable", "computer_use"); +``` + +to: + +```ts +appServerArgs.push("--disable", "browser_use", "--disable", "computer_use"); +``` + +#### 0.4 Initialize handshake + +At `agentChatService.ts:11259` (where `initialize` is sent), set: + +```ts +{ + clientInfo: { name: "ade_desktop", title: "ADE Desktop", version: ADE_VERSION }, + capabilities: { + experimentalApi: true, + optOutNotificationMethods: [], // populated in Phase 9 + // requestAttestation intentionally omitted (§3.7) + }, +} +``` + +For the TUI runtime path (also through `agentChatService.ts`, since the TUI calls into the same service via ADE RPC), use `name: "ade_tui"` to differentiate. If the TUI runs `--print` (non-interactive), pass `optOutNotificationMethods` from §5.10. + +#### 0.5 Reasoning effort triple-name cleanup + +At `agentChatService.ts:7800-7802`, the current shim sends three keys for compat with older app-server builds: + +```ts +...(managed.session.reasoningEffort + ? { + effort: managed.session.reasoningEffort, + reasoningEffort: managed.session.reasoningEffort, + reasoning_effort: managed.session.reasoningEffort, + } + : {}), +``` + +v0.130 canonical key is `effort`. Replace with `{ effort: managed.session.reasoningEffort }` only. + +#### 0.6 Stub server-initiated requests we don't answer + +Codex may send these server→client requests; today we'd return JSON-RPC method-not-found. Wire them as explicit "capability not granted" responses so the server can degrade cleanly: + +- `attestation/generate` → `{ error: { code: -32601, message: "capability not granted" } }` +- `account/chatgptAuthTokens/refresh` → same +- `item/tool/call` (dynamic tools) → same + +**Files touched in Phase 0:** + +| File | Change | +|---|---| +| `apps/desktop/src/main/services/ai/codexExecutable.ts` | Add bundled-path resolution step | +| `apps/desktop/src/main/services/chat/agentChatService.ts` | L11059 disable list, L11259 initialize, L7800-7802 effort cleanup, new server→client request handlers | +| `apps/desktop/package.json` | Add `extraResources` entry for `codex-bin` | +| `apps/desktop/scripts/download-codex-binary.mjs` | New script | +| `apps/desktop/resources/codex-bin/checksums.json` | New manifest | +| `.github/workflows/release-core.yml` | New `download-codex-binaries` matrix job | +| `apps/ade-cli/package.json` | Postinstall + per-platform optional deps OR binary shipping mechanism | + +--- + +### Phase 1 — Structured plan-mode card + +**Wire surface:** `Plan` item (text only) + `plan/delta` (streaming text) + `turn/plan/updated` (structured `TurnPlanStep[]`). The streaming text is informational; the structured step array is authoritative. + +**Current state in ADE:** `agentChatService.ts:10952` handles `item/plan/delta` and accumulates into a text buffer; `10787` handles `turn/plan/updated` (logged only). No structured step rendering. There's an existing `ChatProposedPlanCard.tsx` (Claude-flavored) we'll generalize. + +**New `AgentChatEvent` variants:** + +```ts +| { + type: "codex_plan"; + turnId: string; + sessionId: string; + explanation: string | null; + steps: { step: string; status: "pending" | "inProgress" | "completed" }[]; + streamingText: string; // accumulated plan/delta tail (informational) + state: "active" | "complete"; + } +``` + +**`agentChatService.ts` changes:** + +1. On `item/started` where `item.type === "plan"`: emit `codex_plan` with empty `steps`, empty `streamingText`, `state: "active"`. +2. On `item/plan/delta`: append `delta` to `streamingText`. +3. On `turn/plan/updated`: replace `steps` and `explanation`. +4. On `item/completed` where `item.type === "plan"`: set `state: "complete"`. + +**Desktop UI design:** + +Insert a new case in the `AgentChatMessageList.tsx:2024-2915` switch, around line 2068 (replacing the current `plan` `InlineDisclosureRow`). The card uses the existing `CollapsibleCard` primitive (file:line 963-1021). Color: violet accent (matches the `#A78BFA` token in TUI theme), since plans are an assistant-generated artifact. + +ASCII mock (desktop card, ~600px wide): + +``` +┌─ Plan ────────────────────────────────────────────────── ▾ ─┐ +│ Refactor the auth middleware to split session token │ +│ storage from request validation, per the legal/compliance │ +│ ask. │ +│ │ +│ ◐ 1. Read the existing middleware and identify the │ +│ coupling point (in progress) │ +│ ○ 2. Extract session-storage interface │ +│ ○ 3. Implement file-based and Redis-based storage backs │ +│ ○ 4. Wire the middleware to take a storage backend via DI │ +│ ○ 5. Update tests for both backends │ +│ │ +│ ▸ Live thoughts (click to expand) │ +└──────────────────────────────────────────────────────────────┘ +``` + +Step glyphs match TUI for visual consistency. The "Live thoughts" disclosure reveals the streamed `plan/delta` text — only useful for debugging. + +**TUI UI design:** + +ChatView formatter at `format.ts:258-352` gets a new case. Render the structured plan inline (no border — ApprovalPrompt already uses borders, plans should feel calmer): + +``` +plan · 14:23 + Refactor the auth middleware to split session token storage from request validation. + + ◐ Read existing middleware and identify coupling point (in progress) + ○ Extract session-storage interface + ○ Implement file-based and Redis-based storage backends + ○ Wire the middleware to take a storage backend via DI + ○ Update tests for both backends +``` + +Use `theme.ts:TONE_COLORS.notice` (gray) for non-active steps, `TONE_COLORS.user` (`#A78BFA`) for the active step. Step glyphs in `theme.ts` need three new entries (`pending = ○`, `inProgress = ◐`, `completed = ●`). + +**Files touched:** + +| File | Change | +|---|---| +| `apps/desktop/src/shared/types/chat.ts` | Add `codex_plan` variant to `AgentChatEvent` | +| `apps/desktop/src/main/services/chat/agentChatService.ts` | Replace text-only plan handling at L10787, L10952; emit structured event | +| `apps/desktop/src/renderer/components/chat/AgentChatMessageList.tsx` | New switch case at L2068, replace `InlineDisclosureRow` with `CodexPlanCard` | +| `apps/desktop/src/renderer/components/chat/CodexPlanCard.tsx` | New component (modeled on `ChatProposedPlanCard.tsx`) | +| `apps/ade-cli/src/tuiClient/format.ts` | New case in switch at L258-352 | +| `apps/ade-cli/src/tuiClient/theme.ts` | Add `glyphFor(status)` helper | + +--- + +### Phase 2 — `/compact` slash command + `ContextCompaction` item + +**Wire surface:** client sends `thread/compact/start { threadId }`; server streams a `contextCompaction` item via standard `item/started` → `item/completed`. The legacy `thread/compacted` notification is deprecated. + +**Current state in ADE:** no compaction wire at all today. The Codex CLI's `/compact` is a slash command that calls `thread/compact/start`. + +**New `AgentChatEvent` variant:** + +```ts +| { + type: "codex_context_compaction"; + turnId: string; + sessionId: string; + state: "started" | "completed"; + } +``` + +**Slash-command wiring:** + +The slash registry is server-driven for desktop (filtered in `ChatCommandMenu.tsx:85-89`) and a mix of `BUILTIN_COMMANDS` + server commands for TUI (`commands.ts:12-113`). Since `/compact` is provider-specific, we: + +1. Add `/compact` to the Codex provider's slash list emitted from `agentChatService.ts` (the same place `skills/list` results are exposed). +2. On dispatch (desktop: `AgentChatComposer.tsx` slash dispatch; TUI: `commands.ts` `parseCommand`), route to a new IPC method `window.ade.codex.compact({ sessionId })` that calls `thread/compact/start`. + +**Desktop UI design:** + +Compaction is mid-stream — not a hero card. Inline subtle notice: + +``` + ┌──────────────┐ + │ ⟳ compacted │ + └──────────────┘ +``` + +The chip sits between the last message and the next. On hover, tooltip: "Context compacted at 14:31 — N tokens reclaimed" (we don't have the token count yet from this notification, but Phase 6's token HUD updates simultaneously). + +**TUI UI design:** + +``` +[notice] context compacted +``` + +Single dimmed line, `tone: "notice"`, no border. + +**Files touched:** + +| File | Change | +|---|---| +| `apps/desktop/src/shared/types/chat.ts` | New variant | +| `apps/desktop/src/main/services/chat/agentChatService.ts` | New handler for `item/started`/`item/completed` where `item.type === "contextCompaction"`; new public method `compact(sessionId)` | +| `apps/desktop/src/main/ipc/codexHandlers.ts` (new file or extend existing chat IPC) | Expose `compact` | +| `apps/desktop/src/preload/...` | Wire `window.ade.codex.compact` | +| `apps/desktop/src/renderer/components/chat/AgentChatMessageList.tsx` | New switch case for chip | +| `apps/ade-cli/src/tuiClient/format.ts` | New case → `tone: "notice"` | +| `apps/ade-cli/src/tuiClient/commands.ts` | Add `/compact` to `BUILTIN_COMMANDS` for codex provider | + +--- + +### Phase 3 — Goals (`/goal set | get | clear`) + +**Wire surface:** `thread/goal/set`, `thread/goal/get`, `thread/goal/clear` + `thread/goal/updated`, `thread/goal/cleared` notifications. Goal type at §4.2. + +**New `AgentChatEvent` variants:** + +```ts +| { + type: "codex_goal_updated"; + sessionId: string; + goal: CodexThreadGoal; + } +| { + type: "codex_goal_cleared"; + sessionId: string; + } +``` + +We also need to persist the active goal in `ChatSession` (`apps/desktop/src/shared/types/chat.ts:551-553` adds a `codexGoal: CodexThreadGoal | null` field). + +**Slash-command wiring:** + +- `/goal` (no args) — show current goal. +- `/goal ` — `thread/goal/set { objective }`. +- `/goal clear` — `thread/goal/clear`. +- `/goal status active|paused` — `thread/goal/set { status }`. +- `/goal budget ` — `thread/goal/set { tokenBudget: N }`. +- `/goal budget clear` — `thread/goal/set { tokenBudget: null }` (double-Option `null` ⇒ clear). + +**Desktop UI design:** + +A persistent slim banner above the message list when a goal is set. Click to edit (opens a small inline form). + +``` +┌───────────────────────────────────────────────────────────────────────────┐ +│ ◎ Goal: Refactor auth middleware for legal/compliance ask ✎ ✕ │ +│ ───────────────────────────────────────────── │ +│ 2,341 / 50,000 tokens · 4m 12s elapsed · status: active │ +└───────────────────────────────────────────────────────────────────────────┘ +[message list scrolls below this banner] +``` + +`◎` glyph (target). Color: amber-on-dim — important but not alarming. Token-budget progress bar uses the same `ContextMeter` style as the TUI footer. + +**TUI UI design:** + +Below the header, above the chat scrollback (uses the existing Drawer/RightPane layout primitives at `app.tsx`): + +``` +◎ Goal: Refactor auth middleware... 2.3k/50k · 4m · active +``` + +Single line, truncated to terminal width. Color: amber (`#F59E0B` already in theme as `warning` / `approval`). + +**Files touched:** + +| File | Change | +|---|---| +| `apps/desktop/src/shared/types/chat.ts` | New variants + `codexGoal` field on Session | +| `apps/desktop/src/main/services/chat/agentChatService.ts` | New handlers, new public methods `goalSet/Get/Clear` | +| `apps/desktop/src/renderer/components/chat/AgentChatPane.tsx` | Render `GoalBanner` above message list when `session.codexGoal != null` | +| `apps/desktop/src/renderer/components/chat/CodexGoalBanner.tsx` | New component | +| `apps/ade-cli/src/tuiClient/components/Header.tsx` | New goal line beneath title (or replace one of the existing lines) | +| `apps/ade-cli/src/tuiClient/commands.ts` | Add `/goal` builtin with subcommands | + +--- + +### Phase 4 — Image input parity (URL form) + +**Wire surface:** `UserInput::Image { url: string }` (§4.6). We already send `localImage` for clipboard-pasted / drag-dropped files; we need to also send `image` when the source is a URL (e.g. user pastes an image URL in the prompt, or drags an image from a browser tab — Chromium can give us a URL instead of bytes). + +**Current state:** `agentChatService.ts:7781-7789` only handles `localImage` and `mention`. The composer at `AgentChatComposer.tsx` has attachment plumbing but no URL form. + +**Changes:** + +- Composer paste handler: if clipboard contains a `text/uri-list` or a single image URL, append it as an attachment of new type `image-url` (in our `AgentChatFileRef` discriminator). +- `agentChatService.ts:7781-7789`: extend the for-loop to handle `attachment.type === "image-url"` → push `{ type: "image", url: attachment.url }`. + +**Files touched:** + +| File | Change | +|---|---| +| `apps/desktop/src/shared/types/chat.ts` | Extend `AgentChatFileRef` with `image-url` variant | +| `apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx` | Paste/drop handler emits `image-url` ref | +| `apps/desktop/src/main/services/chat/agentChatService.ts` | L7781-7789 handle new ref type → `image` UserInput | +| `apps/ade-cli/src/tuiClient/commands.ts` | Add `--image-url ` flag to send command | + +--- + +### Phase 5 — `imageGeneration` and `imageView` items + +**Wire surface:** + +```ts +type CodexImageGenerationItem = { + type: "imageGeneration"; id: string; status: string; + revisedPrompt: string | null; result: string; savedPath?: string; +}; +type CodexImageViewItem = { type: "imageView"; id: string; path: string }; +``` + +**New `AgentChatEvent` variants:** + +```ts +| { + type: "codex_image_generation"; + turnId: string; + sessionId: string; + status: string; + revisedPrompt: string | null; + result: string; + savedPath: string | null; + } +| { + type: "codex_image_view"; + turnId: string; + sessionId: string; + path: string; + } +``` + +**Desktop UI design:** + +Image generation card: + +``` +┌─ Image generated ──────────────────────────────────────────┐ +│ [200x200 thumbnail of the generated image] │ +│ │ +│ ▸ Revised prompt: "A serene mountain landscape with..." │ +│ Saved to: ~/Projects/.../assets/mountain.png ↗ open │ +└─────────────────────────────────────────────────────────────┘ +``` + +Image view (tool call): + +``` + ↳ Viewing image: assets/screenshot.png ↗ open +``` + +Inline single-line, indented to indicate it's a tool call. + +**TUI UI design:** + +``` +[tool] image generated → ~/Projects/.../mountain.png (h to open) + revised: A serene mountain landscape with... + +[tool] viewing image → assets/screenshot.png (h to open) +``` + +`h` key opens via system handler (`open ` on macOS, `xdg-open` on Linux, `start ""` on Windows). Wire through existing TUI key handling in `app.tsx`. + +**Files touched:** + +| File | Change | +|---|---| +| `apps/desktop/src/shared/types/chat.ts` | Two new variants | +| `apps/desktop/src/main/services/chat/agentChatService.ts` | Handle `item.type === "imageGeneration" / "imageView"` in `item/started` + `item/completed` | +| `apps/desktop/src/renderer/components/chat/AgentChatMessageList.tsx` | Two new switch cases | +| `apps/desktop/src/renderer/components/chat/CodexImageGenerationCard.tsx` | New | +| `apps/desktop/src/renderer/components/chat/CodexImageViewLine.tsx` | New (or inline) | +| `apps/ade-cli/src/tuiClient/format.ts` | Two new cases | +| `apps/ade-cli/src/tuiClient/app.tsx` | Wire `h` key to `open` system call for images | + +--- + +### Phase 6 — Rich `webSearch` item rendering + +**Wire surface:** `WebSearchAction` union with `search`, `openPage`, `findInPage`, `other` variants (§4.1). + +**Current state:** `agentChatService.ts:10878` logs `codex/event/web_search_begin` only. There's also a `web_search` variant in `AgentChatEvent` already at `AgentChatMessageList.tsx:2166-2227` for other providers' web search — we can reuse the visual treatment. + +**Change to existing `AgentChatEvent` web_search variant** (extend, don't replace, so other providers keep working): + +```ts +| { + type: "web_search"; + turnId: string; + sessionId: string; + query: string; + state: "running" | "completed" | "failed"; + actions?: CodexWebSearchAction[]; // ← NEW, only populated for Codex + } +``` + +Note: a single `webSearch` item carries one `action`. We accumulate them across `item/started` (initial action) and `item/completed` (final action) into `actions[]` for richer rendering. If multiple `webSearch` items fire in a turn, each gets its own event. + +**Desktop UI design:** + +``` +┌─ Web search ───────────────────────────────────────────── ▸ ─┐ +│ 🔍 "Codex app-server thread/turns/list pagination" │ +│ │ +│ • search: thread/turns/list pagination │ +│ • openPage: github.com/openai/codex/.../thread.rs │ +│ • findInPage: "items_view" in thread.rs │ +└────────────────────────────────────────────────────────────────┘ +``` + +Use the existing Motion card from `AgentChatMessageList.tsx:2166-2227`; just extend its body to render the action list when `actions[]` is present. + +**TUI UI design:** + +``` +🔍 web search · "Codex app-server thread/turns/list pagination" + search thread/turns/list pagination + openPage github.com/openai/codex/.../thread.rs + findInPage "items_view" in thread.rs +``` + +**Files touched:** + +| File | Change | +|---|---| +| `apps/desktop/src/shared/types/chat.ts` | Extend `web_search` variant with `actions[]` | +| `apps/desktop/src/main/services/chat/agentChatService.ts` | Replace L10878 stub with structured handling of `webSearch` items via `item/started` + `item/completed` | +| `apps/desktop/src/renderer/components/chat/AgentChatMessageList.tsx` | Extend existing case at L2166-2227 to render actions list when present | +| `apps/ade-cli/src/tuiClient/format.ts` | Extend `web_search` case to render actions | + +--- + +### Phase 7 — Token-usage HUD + +**Wire surface:** `thread/tokenUsage/updated` (§4.3) — carries both `total` (cumulative) and `last` (latest turn) breakdowns plus `modelContextWindow`. + +**New `AgentChatEvent` variant:** + +```ts +| { + type: "codex_token_usage"; + sessionId: string; + total: CodexTokenUsageBreakdown; + last: CodexTokenUsageBreakdown; + modelContextWindow: number | null; + } +``` + +We don't render this in the message stream — it updates the persistent footer/status bar. So `agentChatService.ts` stashes it in `ChatSession.codexTokenUsage` and emits a `session_updated` event for renderers to re-read. + +**Desktop UI design:** + +Extend the model status area (currently rendered inline in `status` events at `AgentChatMessageList.tsx:2915-2978`). Add a persistent footer: + +``` +┌────────────────────────────────────────────────────────────────────────────┐ +│ ◇ Codex · gpt-5 · medium effort · workspace-write │ +│ ▓▓▓▓▓▓▓░░░░░ 64% (128k / 200k) last turn: +2.3k in · 1.1k out (450 ✶) │ +└────────────────────────────────────────────────────────────────────────────┘ +``` + +`✶` denotes cached input tokens. Bar is `modelContextWindow`-relative. + +**TUI UI design:** + +Extend `ModelStatus.tsx:28-76` (right-side `ContextMeter`). Replace the existing `tokenSummary` string with a richer one: + +``` +◇ Codex · gpt-5 · medium · workspace-write ▓▓▓▓▓▓▓░░░ 64% +2.3k/1.1k (450✶) +``` + +**Files touched:** + +| File | Change | +|---|---| +| `apps/desktop/src/shared/types/chat.ts` | `CodexTokenUsage*` types; `codexTokenUsage` on Session; `codex_token_usage` event | +| `apps/desktop/src/main/services/chat/agentChatService.ts` | New handler | +| `apps/desktop/src/renderer/components/chat/AgentChatPane.tsx` | New footer component below message list when `session.codexTokenUsage != null` | +| `apps/desktop/src/renderer/components/chat/CodexTokenFooter.tsx` | New | +| `apps/ade-cli/src/tuiClient/components/ModelStatus.tsx` | Extend `ContextMeter` summary string | + +--- + +### Phase 8 — Thread history: list + read + fork + unarchive + rollback + +This is the biggest UX piece. Codex CLI's `/resume` flow is the target. + +**Wire surface:** `thread/list`, `thread/read`, `thread/fork`, `thread/unarchive`, `thread/rollback`. Shapes at §4.5. + +**Currently in ADE:** `thread/resume` and `thread/archive` are called; nothing else. + +**New IPC layer:** add a `CodexHistoryService` in main process that owns these requests. Two design choices, picking option A: + +- **Option A (picked):** reuse the active managed session's runtime if there is one open (cheaper, one process). +- **Option B:** spawn a transient `codex app-server` for history queries, shut down after. (Heavier, but useful for closed-app scenarios — defer to a follow-up.) + +Expose to renderer via `window.ade.codex.history.{list, read, fork, unarchive, rollback}`. + +**Desktop UI design — `/resume` modal:** + +``` +┌─ Codex history ─────────────────────────────────────────────────────────┐ +│ │ +│ [search threads...] │ +│ │ +│ [Active] [Archived] [Forks] cwd: [all ▾] provider: [codex ▾] │ +│ │ +│ ───────────────────────────────────────────────────────────────────── │ +│ thr_a1b2c3d4 "Refactor auth middleware" │ +│ 2026-05-11 14:23 · 12 turns · ~/Projects/auth-svc │ +│ [resume] [fork] [archive] [rollback ▾] │ +│ ───────────────────────────────────────────────────────────────────── │ +│ thr_e5f6g7h8 "Add SSO with Okta" │ +│ 2026-05-09 09:11 · 47 turns · ~/Projects/auth-svc │ +│ [resume] [fork] [archive] [rollback ▾] │ +│ ───────────────────────────────────────────────────────────────────── │ +│ ... │ +│ │ +│ Load more (next cursor: xyz...) │ +└──────────────────────────────────────────────────────────────────────────┘ +``` + +`[rollback ▾]` opens a small inline picker: "Rollback last 1 / 2 / 5 / 10 turns…". + +Modal pattern follows the existing `LinearIssueBrowser` modal precedent (`AgentChatPane.tsx:42`). No `Dialog` primitive exists; we use `createPortal` + a backdrop div. + +**TUI UI design — `ChatHistoryPalette`:** + +A new palette component modeled on `MentionPalette.tsx:13-34` / `SlashPalette.tsx:29-45`. Opened by `Ctrl+R` or `/resume`: + +``` +┌─ resume codex thread ────────────────────────────────────┐ +│ [search threads...] │ +│ │ +│ › thr_a1b2c3d4 Refactor auth middleware 12t │ +│ thr_e5f6g7h8 Add SSO with Okta 47t │ +│ thr_i9j0k1l2 Pipeline builder refactor 8t │ +│ ... │ +│ │ +│ ↵ resume f fork a archive r rollback ⎋ close │ +└──────────────────────────────────────────────────────────┘ +``` + +`r` opens an inline number prompt for "rollback N turns". + +**Files touched:** + +| File | Change | +|---|---| +| `apps/desktop/src/shared/types/chat.ts` | `CodexThread*` types | +| `apps/desktop/src/main/services/chat/codexHistoryService.ts` | New file — wraps the 5 RPCs against the active runtime | +| `apps/desktop/src/main/services/chat/agentChatService.ts` | Expose `getRuntime(sessionId)` helper for the history service | +| `apps/desktop/src/main/ipc/codexHandlers.ts` | New IPC channels | +| `apps/desktop/src/preload/...` | `window.ade.codex.history.*` | +| `apps/desktop/src/renderer/components/chat/CodexHistoryModal.tsx` | New modal | +| `apps/desktop/src/renderer/components/chat/AgentChatPane.tsx` | Mount modal; wire `/resume` slash to open it | +| `apps/ade-cli/src/tuiClient/components/ChatHistoryPalette.tsx` | New TUI palette | +| `apps/ade-cli/src/tuiClient/app.tsx` | Wire `Ctrl+R` keybind and `/resume` to open palette | +| `apps/ade-cli/src/tuiClient/commands.ts` | Add `/resume` builtin | + +--- + +### Phase 9 — Long-thread pagination + +**Wire surface:** `thread/turns/list { threadId, itemsView, cursor, limit, sortDirection }`. Defaults `itemsView: "summary"`. + +**Migration plan:** + +1. When `thread/resume` returns, ADE currently expects the full history in the response. Instead: + - Issue `thread/resume` (still required to subscribe to live events). + - Immediately issue `thread/turns/list { threadId, itemsView: "summary", limit: 50 }`. + - Render summary cards as turn boundaries with a "Load full turn" disclosure. +2. On scroll-up past the top of currently-loaded turns: + - Issue `thread/turns/list { threadId, cursor: nextCursor, itemsView: "summary", limit: 50 }`. +3. On user expanding a specific turn's full content: + - Issue `thread/turns/list { threadId, cursor: , itemsView: "full", limit: 1 }` and replace the summary view for that single turn. + +**Subtlety:** Codex `Turn` carries an `itemsView` field that tells us what's already there. README says the default for `thread/turns/list` is `"summary"`. We must not assume `"full"` is always populated. + +**Desktop UI design:** the existing message list already scrolls; we add: + +- A "Load older turns" button at the top of the scrollback when `nextCursor != null`. +- A `[ Show full turn ▾ ]` button at the top of each summary-rendered turn. + +**TUI UI design:** when scrolling up past the loaded window, the bottom-status bar shows `[older turns: press Ctrl+G to load]`. Pressing Ctrl+G fetches the next 50. + +**Files touched:** + +| File | Change | +|---|---| +| `apps/desktop/src/main/services/chat/agentChatService.ts` | Issue `thread/turns/list` after `thread/resume`; expose `loadOlderTurns(sessionId, cursor)` | +| `apps/desktop/src/shared/types/chat.ts` | Add `loadCursor: string \| null` and `itemsViewByTurnId: Record` to Session | +| `apps/desktop/src/renderer/components/chat/AgentChatPane.tsx` | "Load older" UI; "Show full turn" button | +| `apps/ade-cli/src/tuiClient/app.tsx` | Ctrl+G keybind | + +--- + +### Phase 10 — `optOutNotificationMethods` for perf + +For renderers that don't need streaming (TUI non-interactive `ade chat send --print`), opt out of high-volume deltas: + +```ts +optOutNotificationMethods: [ + "item/agentMessage/delta", + "item/reasoning/summaryTextDelta", + "item/reasoning/textDelta", + "item/commandExecution/outputDelta", +] +``` + +Desktop chat keeps all deltas. The TUI interactive mode also keeps all deltas. Only the `--print` path opts out. + +**File:** `agentChatService.ts:11259` — pass `optOutNotificationMethods` conditionally based on `ChatSession.runtimeMode`. + +--- + +## 6. Migration sequencing & dependencies + +``` +Phase 0 (binary + handshake) + ├─→ Phase 1 (plan card) + ├─→ Phase 2 (compact) + ├─→ Phase 3 (goal) + ├─→ Phase 4 (image input URL) + ├─→ Phase 5 (image gen + view) + ├─→ Phase 6 (web search) + ├─→ Phase 7 (token HUD) + └─→ Phase 8 (thread history) + └─→ Phase 9 (pagination) + └─→ Phase 10 (opt-out) +``` + +Phases 1-7 are independent of each other after Phase 0 lands. Phase 9 depends on Phase 8 (it relies on the history service infrastructure). Phase 10 depends on Phase 9 because that's when we start having streams large enough to want to opt out of. + +**Suggested cadence:** Phase 0 first (1 PR). Phases 1, 2, 3, 7 next as a "chat affordances" batch (one PR each, parallelizable). Phases 4, 5, 6 next as an "image + search" batch. Phase 8 as a standalone PR (largest). Phases 9 + 10 as a single follow-up PR. + +--- + +## 7. Where to use parallel agents + +This is the user's explicit ask: where do parallel subagents accelerate this? The boundaries below are the natural seams. + +### 7.1 Code-generation parallelism + +The phases factor cleanly across the three layers — wire handler, shared type, renderer — and within each phase the desktop and TUI renderers don't touch each other's files. Recommended pattern per phase: + +**For each Phase 1-7 (small phases), spawn 2 agents in parallel:** + +- **Agent A** ("wire + types"): adds the `AgentChatEvent` variant in `apps/desktop/src/shared/types/chat.ts`, wires the new request/notification in `agentChatService.ts`, and emits the new event. Touches main-process code only. +- **Agent B** ("renderers"): once Agent A has merged the type variant, branches off to add the new switch case in `AgentChatMessageList.tsx` AND the new case in TUI `format.ts`. Touches renderer code only. + +Reason this works: the union type is the API contract between them. Agent A finishing the type definition unblocks Agent B even before A's IPC is fully wired (B can stub the event with a dev-tools button). + +**For Phase 8 (thread history), spawn 3 agents in parallel after the IPC layer is sketched:** + +- **Agent A:** `codexHistoryService.ts` + IPC handlers + preload bindings. +- **Agent B:** `CodexHistoryModal.tsx` + `AgentChatPane` mount + `/resume` slash routing on desktop. +- **Agent C:** `ChatHistoryPalette.tsx` + `app.tsx` keybind wiring + `commands.ts` builtin on TUI. + +### 7.2 Research parallelism (during ongoing implementation) + +Once we're in execution, certain research tasks block specific phases. Spawn these as research agents the first time the phase begins: + +- **Phase 0 binary bundling:** an agent to verify SHA256 manifest building works against `github.com/openai/codex/releases/download/rust-v0.130.0/*` for each target triple. The agent fetches each tarball, computes sha256, writes the manifest. +- **Phase 8 history modal:** an agent to UX-prototype the modal layout in a sandbox (the user has a strong preference against generic AI aesthetics — see `feedback_design.md` memory — so we want an explicit design pass before locking in the look). +- **Phase 7 token HUD:** an agent to validate that `thread/tokenUsage/updated` actually fires on every turn boundary (vs. only at completion) by running a fake stream against a real `codex app-server` v0.130 — this affects whether the HUD updates feel real-time or laggy. + +### 7.3 Test-generation parallelism + +After each phase's implementation lands, spawn a single agent per phase to add tests in parallel with the next phase's implementation. The test agent's prompt should be: *"For [phase] in [PR #], add unit tests in `agentChatService.test.ts` that exercise the new wire handler against a fake app-server (use the existing fixture harness). Do NOT add brittle render-tree tests."* This obeys the existing memory `feedback_testing_quality.md`. + +### 7.4 Cross-phase agents + +Once 3+ phases have landed, spawn an **audit agent** with the prompt: *"Walk every new `AgentChatEvent` variant added since [start commit]; confirm desktop and TUI both have a renderer case; confirm there is a corresponding `agentChatService.ts` handler; confirm tests exist."* This catches drift between the three layers without a human re-checking each phase. + +--- + +## 8. Testing strategy + +Per the project's testing memory (`feedback_testing_quality.md`, `feedback_test_scoping.md`, `feedback_test_sharding.md`): + +1. **Real-value tests only.** No brittle DOM snapshot tests, no fragile render assertions. +2. **Scope to changed files.** Run `pnpm test apps/desktop/src/main/services/chat/agentChatService.test.ts` and the codex-specific TUI fixture only — never the full suite per change. +3. **Always shard.** Use the existing shard configuration. + +### 8.1 Per-phase test deliverables + +- **Phase 0:** integration test that spawns the bundled binary, completes an `initialize` handshake, sends one trivial `turn/start`, asserts a `turn/completed` notification. This proves the bundled binary works end-to-end. +- **Phase 1:** fake-app-server fixture that emits a `turn/plan/updated` + `Plan` item + `plan/delta` sequence; assert the emitted `AgentChatEvent` matches snapshot. +- **Phase 2:** fixture that runs `thread/compact/start`, fake server emits `contextCompaction` item; assert event. +- **Phase 3:** `/goal set Foo` → assert `thread/goal/set` request body shape matches §4.2 (esp. double-Option semantics for `tokenBudget`). +- **Phase 4-5:** assert URL-form image goes through as `{ type: "image", url }`; assert imageGeneration/imageView items become the right events. +- **Phase 6:** fixture emits one `webSearch` item with each `WebSearchAction` variant including `{ type: "other" }`; assert no crash and a renderable event. +- **Phase 7:** fixture emits `thread/tokenUsage/updated` with the 5-field breakdown; assert footer model gets all 5 fields. +- **Phase 8:** `thread/list` returns paginated data with `nextCursor`; assert the modal renders and `[Load more]` issues another request with that cursor. Test snake_case wire encoding of `sortKey` (gotcha 5). +- **Phase 9:** fixture returns a thread with 100 turns; assert only 50 summary items load on resume, full only on expand. +- **Phase 10:** `--print` mode skips emitting delta events. + +### 8.2 Manual smoke checklist + +For each phase, end with a manual smoke pass against the dev Electron build: + +- Open the work tab, start a chat, exercise the new feature, capture screenshot. +- Open `ade` TUI in another terminal, attach to the same lane, exercise the same feature. +- Compare event ordering against `git log` of `agentChatService.ts` debug output. + +--- + +## 9. Rollout & migration plan + +1. **Pre-merge:** all phase PRs target a feature branch `feature/codex-v130`, not `main`. +2. **First release with new binary:** ship in a beta channel build. The channel-isolated profile work already landed (commit `5de5f054 — Isolate desktop profiles by channel`) means beta users get a clean profile so a broken Codex session can't poison prod profiles. +3. **Rollback plan:** keep the `CODEX_EXECUTABLE` env override functional. If v0.130 has a regression, users can drop to a known-good local install with `export CODEX_EXECUTABLE=/usr/local/bin/codex` and restart. +4. **Telemetry to add:** on every Codex JSON-RPC method call, log method + duration + error code. Surface in the existing ADE telemetry pipeline. Especially important: `thread/turns/list` p95 (Phase 9 hinges on it staying < 500ms). + +--- + +## 10. Open questions / risks + +1. **Binary signing on Windows.** Codex binaries are unsigned upstream. macOS notarization will pick up the binary via the app bundle's hardened-runtime entitlements (verified — there's a `disable-library-validation` entitlement at `apps/desktop/build/entitlements.mac.plist:4-9`). Windows is different — `ADE_REQUIRE_WIN_SIGNING` (env var at `apps/desktop/scripts/validate-win-artifacts.mjs:34`) currently fails the build if shipped binaries are unsigned. We may need to re-sign the codex binary with our cert or disable the check for the codex bin specifically. Confirm with whoever owns the Windows release. +2. **Bundle size.** Codex binary is ~50MB per platform. Universal mac DMG = arm64 + x64 = 100MB extra. Windows installer = 50MB extra. We already ship runtime binaries the same way, so this is a known cost. +3. **Plugin/app misconfig noise.** Once `--disable plugins --disable apps` is dropped, users with broken plugin configs in `~/.codex/` will hit `configWarning` notifications. We should surface these as a subtle dimmed line in the chat (one-line addition; out of scope for Phase 0). +4. **Double-Option for token budget.** TS doesn't distinguish `undefined` from `null` natively in `JSON.stringify` if you set the field. We need explicit serialization helpers; consider a small `omitUndefined()` wrapper before sending `thread/goal/set` requests, and prefer `null` only when the user means "clear". +5. **`developerInstructions` is thread-scoped only.** If we ever want per-turn override (e.g. "for this prompt only, be terse"), we'll need a `thread/fork` + `thread/start` pattern instead. Document this in the slash command help. +6. **`thread/turns/items/list` returns unsupported-method.** We rely on `thread/turns/list` with `itemsView: "full"` instead. If a future Codex version implements per-item pagination, we can swap. +7. **Goal banner real estate in TUI.** The TUI already has a Header + ModelStatus + FooterControls stack. One more line is fine, but check it doesn't push the chat scrollback into a too-small region on 24-line terminals. Test with `COLUMNS=80 LINES=24`. + +--- + +## 11. Definition of done + +- `codex` v0.130.0 binary ships in macOS arm64, macOS x64, Windows x64, Linux x64, Linux arm64 builds — verified by running the smoke handshake test in CI. +- `--disable plugins --disable apps` is gone from the spawn line; `--disable browser_use --disable computer_use` remains. +- `/plan`, `/compact`, `/goal`, `/resume` are wired in both desktop slash registry and TUI `commands.ts`. +- Plan, compaction, goal banner, image-gen card, image-view line, webSearch card all have dedicated visual treatment (no `text` fallback). +- Token usage shows the 5-field breakdown in the desktop footer and the TUI `ModelStatus` line; `modelContextWindow`-relative progress bar is visible in both surfaces. +- Resume picker opens via `/resume` (and `Ctrl+R` in TUI); search, fork, unarchive, rollback all work. +- Resuming a 100-turn thread loads in < 1s thanks to `itemsView: "summary"`; "Show full turn" expands a single turn lazily. +- No regression in approval flow, command execution rendering, file diff rendering, reasoning streaming, /review slash command. +- All new tests pass under sharded `pnpm test`; manual smoke checklist signed off. diff --git a/docs/codex-cli-passthrough-audit.md b/docs/codex-cli-passthrough-audit.md new file mode 100644 index 000000000..ec8b6954f --- /dev/null +++ b/docs/codex-cli-passthrough-audit.md @@ -0,0 +1,102 @@ +# Codex CLI slash-command pass-through audit + +Date: 2026-05-12 +Scope: every Codex slash command listed in +`apps/desktop/src/main/services/chat/agentChatService.ts:498–533` and the matching +Claude slash list at `:535–577`. Goal: find dead-listed commands (advertised in +the palette but with no working ADE handler, no Codex SDK route, or both) so we +can either wire them properly or remove them from the palette in a follow-up. + +## How the pass-through works + +The Codex provider keeps almost no slash-command logic in ADE. Only `/fast`, +`/plan`, `/compact`, and `/goal` have explicit local handlers +(`agentChatService.ts:7874–8021`); every other registry entry is forwarded +verbatim to the Codex app server through the `sendMessage` path. The Codex +app server then either resolves the command itself or no-ops. + +Because the Codex CLI was authored as a terminal UI, several of its slash +commands operate on TUI concerns (`/keymap`, `/statusline`, `/title`, +`/personality`, `/vim`, `/theme`) that ADE renders differently. Those entries +work in the upstream `codex` binary but produce no useful effect in ADE. + +Reference: + +## Codex registry (`agentChatService.ts:498–533`) + +| Command | Listing | Local handler | Codex SDK behavior in ADE | Recommendation | +|---|---|---|---|---| +| `/permissions` | sdk | — | Codex sends a `permissions/configure` notification; ADE has no UI consumer so it lands as a `system_notice` row. | Wire to a renderer surface in a follow-up, or document the no-UI behavior. | +| `/sandbox-add-read-dir` | sdk | — | Forwarded; Codex applies the change in-thread, ADE only sees a confirmation `system_notice`. | Keep — works end-to-end. | +| `/agent` | sdk | — | Used to switch between Codex agent threads (sub-agent identity). ADE collapses agents into a single chat session, so the response is informational only. | Keep but document the limitation; revisit when sub-agent UI lands. | +| `/apps` | sdk | — | Opens an in-CLI "apps" browser. ADE has no equivalent; Codex returns an informational message but no usable UI. | Dead-listed for ADE — remove from palette OR wire a proper modal (post-Tier-A). | +| `/plugins` | sdk | — | Same problem as `/apps`. | Dead-listed — remove or wire UI. | +| `/clear` | sdk | TUI-side `/clear` (ADE Code) shadows | Codex app server's `/clear` resets thread state. ADE Code's TUI overrides it locally to clear the viewport. Desktop renderer has no local override — it forwards. | OK for now; desktop UX may want an explicit "Clear viewport" affordance separate from Codex's destructive `/clear`. | +| `/compact` | sdk | yes (`thread/compact/start`) | Direct wire call; emits `codex_context_compaction`. | Keep — works end-to-end. | +| `/copy` | sdk | — | Codex CLI copies the latest output to its TUI buffer. In ADE there is no such buffer; the request is silently dropped. | Dead-listed for ADE. Either implement `/copy` locally (mirror of TUI `Ctrl+O`) or remove from palette. | +| `/diff` | sdk | — | Codex CLI prints a git diff to stdout. ADE has a richer git pane elsewhere; Codex emits text the renderer displays as an assistant message. | Keep (the textual diff still reads). Consider routing to ADE's diff viewer in a follow-up. | +| `/exit` | sdk | — | Tells Codex CLI to exit. In ADE this terminates the app-server process, which the runtime then auto-restarts — surprising side-effect. | Remove from palette (ADE has `/end` and `/quit` for this). | +| `/experimental` | sdk | — | Codex toggles experimental flags. Works in ADE. | Keep. | +| `/feedback` | sdk | — | Codex queues logs for upload. Works through the SDK. | Keep. | +| `/init` | sdk | — | Generates an `AGENTS.md` scaffold via Codex. Works. | Keep. | +| `/goal` | sdk | yes (`thread/goal/*`) | Local handler covers `set`, `clear`, `status`, `budget`, `pause`/`resume`. | Keep — works end-to-end. | +| `/logout` | sdk | — | Forwarded. Codex clears credentials. | Keep. | +| `/mcp` | sdk | — | Codex lists configured MCP servers as text. ADE has no MCP browser yet, so the text reply is the entire UX. | Keep but note the limitation; MCP UI is on the roadmap. | +| `/mention` | sdk | — | Codex CLI's mention UI is keyboard-driven; in ADE we have `@`-mentions in the composer that fulfill this need without `/mention`. | Remove from palette to avoid duplicating the composer affordance. | +| `/model` | sdk | shadowed by `/model` in ADE Code (right pane) | ADE owns model selection via right pane; the Codex SDK reply is redundant text. | Remove the Codex `/model` palette entry (ADE owns model selection). | +| `/fast` | sdk | yes | Local handler. | Keep. | +| `/plan` | sdk | yes | Local handler. | Keep. | +| `/personality` | sdk | — | Codex switches its persona; the change applies inside the Codex thread. Works, but discoverability is low. | Keep. | +| `/ps` | sdk | — | Codex lists background terminals. ADE doesn't expose Codex background terminals; the reply is text-only. | Dead-listed — remove or expose Codex BG terminals. | +| `/stop` | sdk | — | Stops all Codex background terminals. Same coverage gap as `/ps`. | Dead-listed — pair with `/ps` decision. | +| `/fork` | sdk | — | After §A.6, the IPC method `forkCodexThread` is gone but the slash remains in the registry. Sending `/fork` now forwards to Codex SDK; Codex responds with a thread-fork notification that ADE no longer renders. | **Remove from palette.** Audit hand-off: §A.6 was supposed to remove this entry. | +| `/rollback` | local | gone | `rollbackCodexThread` IPC was removed in §A.6; sending `/rollback` from chat just forwards plain text to Codex SDK (no rollback). | **Remove from palette.** §A.6 leftover. | +| `/resume` | sdk | gone | `listCodexThreads`/`resumeCodexThread` IPC was removed in §A.6; ResumePalette is gone in §C.1. The slash now forwards to Codex SDK and Codex responds with a thread-resume UI that ADE never surfaces. | **Remove from palette.** §A.6 leftover. ADE uses its chat sidebar instead. | +| `/unarchive` | local | gone | `unarchiveCodexThread` IPC was removed in §A.6; the slash is now inert. | **Remove from palette.** §A.6 leftover. | +| `/new` | sdk | — | Codex starts a new thread. Conflicts with ADE's own `/new chat` / `/new lane`. | Remove the bare `/new` to avoid clashing with ADE's multi-word commands. | +| `/quit` | sdk | TUI `/quit` shadows | TUI handles it inline (exit the CLI). Desktop renderer forwards to Codex SDK which terminates the app-server. | Keep TUI handling; remove from desktop palette where it has destructive side effects. | +| `/review` | sdk | — | Codex starts a review (`review/start { type: "prompt" }`). §F.4 expands this with `diff`/`branch` variants. | Keep. | +| `/status` | sdk | shadowed by ADE Code right-pane `/status` | TUI overrides; desktop forwards to Codex's text status reply. | Keep, but expect duplicate behavior in desktop. | +| `/debug-config` | sdk | — | Codex prints config diagnostics. Works. | Keep. | +| `/statusline` | sdk | — | Configures the Codex CLI status line. No equivalent in ADE; the change applies in the upstream Codex CLI binary but ADE never displays it. | Remove from palette — TUI-only feature. | +| `/title` | sdk | — | Configures the terminal window/tab title. ADE owns its own window chrome. | Remove from palette — TUI-only feature. | + +Commands listed in the task scope (Section E.1) but **not** in the Codex +registry: `/keymap`, `/vim`, `/agents`, `/apps` (already covered), `/plugins` +(already covered), `/hooks`, `/ide`. These either belong to the Claude registry +(`/agents`, `/hooks`, `/ide`) or simply don't exist as Codex slashes +(`/keymap`, `/vim`). The audit task's wording suggests they're cross-listed — +they aren't. + +## Claude registry (`agentChatService.ts:535–577`) — short pass + +| Command | Notes | +|---|---| +| `/agents`, `/hooks`, `/mcp`, `/permissions`, `/ide`, `/statusline` | Claude Agent SDK owns these; ADE forwards. UX is text-only, no modal. Same "dead UI" pattern as Codex's `/apps`. Worth noting but the immediate Tier-A scope keeps them. | +| `/keymap`, `/vim` | Not in either registry. The task description listed them as ADE-advertised but they aren't. No action needed. | +| `/clear`, `/compact`, `/copy`, `/diff`, `/feedback`, `/init`, `/model`, `/quit`, `/review`, `/status`, `/title`, `/resume` | Same observations as the Codex versions — works end-to-end via the SDK, modulo the same TUI-only caveats. | +| `/skills`, `/security-review`, `/simplify`, `/tasks`, `/theme`, `/usage` | Claude-specific surfaces; out of scope for this audit. | + +## Summary — recommended palette cleanup (Codex) + +Removing the dead-listed entries below tightens the palette and removes the +"I see a command but nothing happens" failure mode. None of these need any +behavior change in this PR — just delete the rows from +`CODEX_BUILT_IN_SLASH_COMMANDS`: + +- `/fork`, `/resume`, `/rollback`, `/unarchive` — §A.6 was supposed to remove + these; they were missed during the wire pass. **High priority.** +- `/apps`, `/plugins`, `/ps`, `/stop` — Codex-CLI-only surfaces with no ADE + consumer. +- `/mention`, `/new` — duplicate ADE's own composer and `/new chat` flows. +- `/statusline`, `/title` — TUI-only configuration; no effect inside ADE. +- `/exit` — destructive side effect on ADE's runtime; ADE owns `/end` and + `/quit`. + +Optional but nice: deduplicate `/model`, `/status`, `/quit`, `/clear` once the +desktop renderer adopts the same shadow-list rules ADE Code's TUI already uses. + +## Not done in this task + +Per the task scope, this audit is documentation only. No palette entries are +deleted in this PR. The cleanup above is a follow-up PR (or §A.6 fix-forward). diff --git a/plans/ade-32-codex-followup.md b/plans/ade-32-codex-followup.md new file mode 100644 index 000000000..f0d093ddd --- /dev/null +++ b/plans/ade-32-codex-followup.md @@ -0,0 +1,542 @@ +# ADE-32 — Codex migration followup (Tier A finish + design rework + cheap parity) + +Status: proposed +Date: 2026-05-12 +Predecessors: [`docs/codex migration plan.md`](../docs/codex%20migration%20plan.md), [`plans/ade-32-codex-v130-chat-parity.md`](./ade-32-codex-v130-chat-parity.md) + +## Why this doc exists + +Tier A landed end-to-end on `ade-32-update-codex-app-server-to-bring-new-codex-features`. An audit (4 agents covering wire, desktop renderer, TUI, and beyond-plan research) surfaced one critical wire bug, multiple design deviations from the original plan, two phases that didn't fully land (9 + 10), 15 silently-dropped event variants in the TUI formatter, and a meaningful "everything looks the same" design rot in the desktop renderer. This doc covers everything we'll finish before merging the migration. + +The user also overrode plan §3.4 (drop the `--disable browser_use --disable computer_use` flags everywhere — they should be first-class) and §5.8 (kill the dedicated Codex history surfaces in both TUI and desktop — ADE's existing chat sidebar is the single source of truth). + +## Scope at a glance + +| Section | What | Effort | +|---|---|---| +| **A** | Wire fixes (browser/computer-use, plan-start event, compaction variant, chatTextBatching regression, Phase 10 opt-out) | S | +| **B** | Renderer extraction to dedicated `Codex*.tsx` files + real visual design pass | M-L | +| **C** | TUI rework (kill ResumePalette, fix 15 missing format cases, pin goal banner, 5-field token meter, plan glyphs, chat-budget fix) | M | +| **D** | Open-in-Codex-CLI button (desktop only, chooser popover) | S | +| **E** | Cheap CLI parity verification + `/side` + `Ctrl+O/L` | S | +| **F** | Cheap Tier B (deprecation channels, `thread/inject_items`, `completion_report`/`turn_diff_summary` rendering, `/review diff` variant) | S-M | +| **G** | Tests for everything new | S | + +**Dropped from Tier A**: ResumePalette (TUI), HistoryModal (desktop), all user-facing thread-list UI, all `/fork` / `/rollback` / `/unarchive` / `/resume` slash commands. Auto-resume already works via `agentChatService.ts:16661-16728`; ADE's chat sidebar is the picker. The wire methods (`thread/list/read/fork/rollback/unarchive`) get removed from the service in this PR — they can come back in a future PR with a real UI consumer. + +**Deferred to other branches / future PRs**: subagent rendering rewrite (separate branch in flight), MCP-in-app, Hooks UI, Plugins/Apps browser, realtime voice, environments, dynamic tools, memory mode. + +--- + +## Section A — Wire fixes + +### A.1 Drop `--disable browser_use --disable computer_use` everywhere +**File**: `apps/desktop/src/main/services/chat/agentChatService.ts:11611-11614` + +Today these flags are nested inside the `if (missionCodexHome)` block. Mission sessions disable them; normal sessions don't (an accidental inversion). User's directive: drop both flags entirely. Users should never be locked out of Codex's first-class tools. + +```diff +- if (missionCodexHome) { +- appServerArgs.push("--disable", "browser_use", "--disable", "computer_use"); +- } +``` + +Update the test that codifies the regression: `agentChatService.test.ts:2125-2135` should assert **neither flag is present** in `appServerArgs`. + +### A.2 Plan event on `item/started` +**File**: `agentChatService.ts:10589-10609` + +Today the `Plan` item is only normalized on `eventKind === "completed"`. Plan §5.1 says emit a `plan` event with `state: "active"` on `item/started` too, so the renderer's plan card mounts immediately. Fix: add an `item/started` branch that emits `{ type: "plan", state: "active", explanation: null, steps: [], streamingText: "" }`. + +Also align state literals to plan-spec `"active" | "complete"` (currently `"started" | "updated" | "completed"`). Pick one set; update `chat.ts` union and renderers. + +### A.3 Distinct `codex_context_compaction` variant +**Files**: `apps/desktop/src/shared/types/chat.ts`, `agentChatService.ts:10579-10585` + +Current implementation reuses the existing `context_compact` event with `trigger: "auto"` hardcoded — loses the start-of-compaction boundary and the manual-vs-auto distinction. Add a distinct `codex_context_compaction` variant per plan §5.2: + +```ts +| { + type: "codex_context_compaction"; + turnId: string; + state: "started" | "completed"; + trigger: "manual" | "auto"; // 'manual' when triggered by /compact slash + } +``` + +`thread/compact/start` → set `trigger: "manual"` on the started event. Other compaction triggers → `trigger: "auto"`. + +### A.4 `chatTextBatching` plan no-flush regression +**File**: `apps/desktop/src/main/services/chat/chatTextBatching.ts:56-67` + +The removed `case "plan_text"` no-flush branch was not re-added for the new `plan` events carrying `streamingText`. Plan deltas interleaved with assistant text now force-flush. Fix: add `case "plan"` to the no-flush set when `streamingText` is present. + +### A.5 Phase 10 — `optOutNotificationMethods` for `--print` +**Files**: `agentChatService.ts:11820`, `apps/ade-cli/src/cli.ts:5132-5150` + +Add `--print` flag to `ade chat send`. Thread `runtimeMode: "interactive" | "print"` through into the initialize handshake. When `print`, populate `optOutNotificationMethods` with the four delta methods from plan §5.10: + +```ts +optOutNotificationMethods: [ + "item/agentMessage/delta", + "item/reasoning/summaryTextDelta", + "item/reasoning/textDelta", + "item/commandExecution/outputDelta", +] +``` + +### A.6 Remove unused thread-list wire methods + slash routes +**File**: `agentChatService.ts:7981-8166`, `12082-12092` + +Tear out: +- `/resume`, `/fork`, `/rollback`, `/unarchive` slash handlers +- `listCodexThreads`, `readCodexThread`, `forkCodexThread`, `rollbackCodexThread`, `unarchiveCodexThread` IPC handlers +- `adeActions/registry.ts` entries for the above +- `preload.ts` exposures +- `AgentChatCodexThread*` types in `chat.ts` (these become unused) +- `emitCodexTurnsPageNotice` (Phase 9 stub that never materialized) + +`thread/resume` stays (used internally by auto-resume on session reopen at `agentChatService.ts:16661-16728`). + +### A.7 Plan card streaming vs structured updates +**File**: `agentChatService.ts:11295-11305` + +When both `item/plan/delta` (streaming text) and `turn/plan/updated` (structured steps) fire, the renderer can't tell them apart in the current event shape. Pass a discriminator: `state: "delta" | "updated"` so the renderer knows whether to append to `streamingText` or replace `steps[]`. + +--- + +## Section B — Renderer extraction + visual rework (desktop) + +Every Codex card from Tier A was inlined into `AgentChatPane.tsx` (+458 LOC) and `AgentChatMessageList.tsx` (+125 LOC). Extract them into dedicated files in `apps/desktop/src/renderer/components/chat/codex/`: + +``` +chat/codex/ + CodexPlanCard.tsx + CodexGoalBanner.tsx + CodexTokenFooter.tsx + CodexImageGenerationCard.tsx + CodexImageViewLine.tsx + CodexContextCompactionChip.tsx + CodexOpenInCliButton.tsx (new — see §D) + Dialog.tsx (new reusable primitive for any future modals) +``` + +### B.1 `CodexPlanCard.tsx` +**Replaces inline JSX at `AgentChatMessageList.tsx:2068-2118`.** + +Visual: +- Violet `#A78BFA` accent on the card chrome (border-left or top rule). +- Unicode step glyphs `◐` (active) / `○` (pending) / `●` (complete) — match TUI exactly. +- Plain "Plan" header in sans-serif. **No uppercase mono caption.** +- Prose explanation tight above the steps (1.4 line-height). +- "Live thoughts" disclosure **collapsed by default**, shown as a small `▸ live` link in the bottom-right corner — not as a prominent block. The deltas are debug signal only. + +``` +┌── Plan ────────────────────────────────── ▾ ──┐ +│ Refactor the auth middleware to split │ +│ session token storage from request │ +│ validation, per compliance ask. │ +│ │ +│ ◐ Read existing middleware │ +│ ○ Extract storage interface │ +│ ○ Implement file-based + redis backends │ +│ ○ Wire middleware via DI │ +│ ○ Update tests for both backends │ +│ ▸ live │ +└────────────────────────────────────────────────┘ +``` + +Active step gets violet accent on its glyph **and** text. Other steps stay at `text-fg/70`. + +### B.2 `CodexGoalBanner.tsx` +**Replaces inline JSX at `AgentChatPane.tsx:6564-6580`.** Also delete the inline duplicate at `AgentChatMessageList.tsx:2294-2307` and add `codex_goal_updated`/`codex_goal_cleared` to `HiddenTranscriptEvent` in `chatTranscriptRows.ts` — the banner is the truth, transcript rows are duplicate noise. + +Visual: +- Amber `#F59E0B` accent (not emerald — emerald is overloaded for "completed"). +- `◎` glyph (target) for the goal indicator. +- Objective text + truncation with **full-text tooltip** on hover. +- Token-budget progress bar: `ContextMeter`-style, sky base, amber overlay when `tokensUsed > tokenBudget * 0.85`. +- Right side: `tokensUsed / tokenBudget · timeUsedSeconds elapsed · status pill (active/paused/budget-limited/complete)`. +- Inline `✎ edit` / `✕ clear` icon buttons on the right. +- Clicking the objective turns it into an `` for inline edit. + +``` +┌─────────────────────────────────────────────────────────────────────┐ +│ ◎ Refactor auth middleware for compliance ✎ ✕ │ +│ ▓▓▓░░░░░░░░ 2,341 / 50,000 · 4m 12s · active │ +└─────────────────────────────────────────────────────────────────────┘ +``` + +No `font-mono uppercase tracking` caption anywhere on this banner. + +### B.3 `CodexTokenFooter.tsx` +**Replaces inline JSX at `AgentChatPane.tsx:6603-6619`.** Also delete the inline duplicate at `AgentChatMessageList.tsx:2309-2322`; add `codex_token_usage` to `HiddenTranscriptEvent`. + +Visual: +- Render at the **bottom** of the chat column, after sub-panels (currently sandwiched between message list and file-changes — `AgentChatPane.tsx:6603-6650` reorder). +- Real context-window progress bar with cache-portion overlay (`cachedInputTokens / inputTokens`). +- `64%` numeric leads; bar follows. +- Last-turn breakdown: `+2.3k in · 1.1k out · 450 ✶` where `✶` = cached tokens. +- Same line shows `model · effort · sandbox-mode`. +- Sans-serif label (no uppercase mono). + +``` +┌─────────────────────────────────────────────────────────────────────┐ +│ ◇ gpt-5 · medium · workspace-write │ +│ 64% ▓▓▓▓▓▓▓░░░░░ 128k / 200k +2.3k in · 1.1k out · 450 ✶ │ +└─────────────────────────────────────────────────────────────────────┘ +``` + +### B.4 `CodexImageGenerationCard.tsx` +**Replaces inline JSX at `AgentChatMessageList.tsx:2257-2292`.** Splits image-generation from image-view (currently merged). + +Wire fix: add `savedPath: string | null` field to the `codex_image_generation` AgentChatEvent variant (`chat.ts:484-491`). Today `path` collapses into `result`; surface it separately so the Open button has somewhere to point. + +Visual: +- Thumbnail (max 240×240, lazy-loaded) when `result` is `http(s)` / `data:` URL. +- File-path-with-icon when `result` is a local fs path (no `file://` prefix; renderer must handle this). +- "Open" button (`window.ade.shell.openPath(savedPath)`) when `savedPath` is set. +- Revised prompt as collapsed disclosure (default closed; opens with `▸ revised prompt`). +- Card chrome: fuchsia accent (existing — keep), no uppercase mono caption. + +### B.5 `CodexImageViewLine.tsx` +**Replaces inline JSX at `AgentChatMessageList.tsx:2257-2292` (the imageView branch).** + +Plan §5.5 calls for "inline single-line indented". Current implementation is a full bordered card. Fix: render as ` ↳ Viewing image: ↗ open` — single line, indented to signal it's a tool call, with an open action on the right. Click opens via `openPath` for local files or `openExternal` for URLs. + +### B.6 `CodexContextCompactionChip.tsx` +**Replaces inline JSX at `AgentChatMessageList.tsx:2505-2534`.** + +Current implementation is closer to a hero card with horizontal rules. Plan §5.2 calls for "subtle notice". Pull to a small inline chip: + +``` + ┌──────────────────┐ + │ ⟳ compacted │ + └──────────────────┘ +``` + +Tooltip on hover: `"Context compacted at {time} · trigger: {manual|auto}"`. No horizontal rules, no caption. + +### B.7 `Dialog.tsx` primitive +**New file.** + +Reusable portal-based modal: backdrop, Esc handler, outside-click close, focus trap. We don't have one today — `LinearIssueBrowser` rolled its own. Build a minimal one now so future modals (MCP browser, hooks list, plugins UI) don't each reinvent it. + +API: +```ts + + ... + ... + +``` + +Single use this PR: nothing. (HistoryModal got cut.) Building it now is forward-looking; if we want to defer, that's fine — but the audit flagged that we lack the primitive. + +### B.8 Web search action list +**File**: `AgentChatMessageList.tsx:2235-2250` + +- Remove the `slice(0, 4)` silent truncation. Show all actions; if `> 8`, render a "+N more" disclosure. +- Bump chip readability: drop the `text-[length:calc(var(--chat-font-size)*9/14)]` 9px font and `text-fg/45` 45% opacity. Use 12px at 70% opacity minimum. + +### B.9 Composer paste interception +**File**: `AgentChatComposer.tsx:2280-2282` + +Today: any pasted text ending in `.png/.jpg/...` gets silently converted to an image-url attachment, swallowing the paste. Fix: only intercept when: +1. The clipboard payload is **exactly** a URL (no other text before/after). +2. Show a small toast / inline notice: "Image URL attached" with an undo affordance. + +### B.10 Image-URL attachment thumbnail +**File**: `ChatAttachmentTray.tsx:357-389` + +Image-URL chips today are `Globe + hostname` text. For an image URL, render a real `` thumbnail (32×32, lazy-loaded, `onerror` falls back to icon). Truncate URL underneath. + +--- + +## Section C — TUI rework + +### C.1 Delete ResumePalette +- Remove `apps/ade-cli/src/tuiClient/components/ResumePalette.tsx` entirely. +- Remove ResumePalette imports, state, render, keybinds (`Ctrl+R`, `Ctrl+G`, `Ctrl+F`, `Ctrl+U`, `Tab`-to-resume) from `app.tsx:2687-3020`. +- Remove `listCodexResumeThreads`, `resumeCodexThread`, `forkCodexThread`, `rollbackCodexThread`, `unarchiveCodexThread` methods from `adeApi.ts:336-391`. +- Remove `/resume` from `BUILTIN_COMMANDS` (`commands.ts`). +- Ctrl+R remains free for future use (reverse-search nice-to-have). + +### C.2 Add 15 missing `format.ts` cases +**File**: `apps/ade-cli/src/tuiClient/format.ts:272-427` + +The shared union has 32 variants; current formatter handles 17. Add minimal cases for the rest so events stop falling through silently: + +- `status` → `[status] {turnStatus}` line, tone by status +- `error` → `[error] {message}` line, `tone: "error"` +- `done` → end-of-turn marker line with usage summary +- `activity` → activity indicator inline pill +- `tokens` → routes to `latestTokenStats` (same as `codex_token_usage`) +- `cloud_artifact` / `cloud_status` → notice lines +- `step_boundary` → horizontal rule with step label +- `todo_update` → checklist diff +- `subagent_started` / `subagent_progress` / `subagent_result` → **minimal one-line** rendering; the real subagent UX lands in the parallel branch. For now: `[agent] {description} ({status})`. Prevents the "subagents are invisible" failure mode. +- `structured_question` → routed to ApprovalPrompt-style overlay +- `tool_use_summary` → notice line +- `completion_report` → `[done] turn summary: {summary}` notice +- `auto_approval_review` → notice line +- `prompt_suggestion` → notice line with copy hint +- `turn_diff_summary` → `[diff] +{add}/-{del} across {files} files` +- `pending_input_resolved` → silent (suppressed) +- `delegation_state` → notice line + +Add `system_notice` case `continue;` statement (latent bug noted in TUI audit). + +### C.3 `/compact` and `/goal` builtins +**File**: `apps/ade-cli/src/tuiClient/commands.ts` + +Add to `BUILTIN_COMMANDS`: +- `/compact` — invokes `thread/compact/start` via existing slash-routing +- `/goal` with subcommand parser: + - `/goal` (no args) → show current goal + - `/goal ` → set objective + - `/goal clear` → clear + - `/goal status active|paused` → set status + - `/goal budget ` → set token budget (number) + - `/goal budget clear` → clear budget (uses double-Option null semantics) + +### C.4 Pin goal as amber banner beneath Header +**Files**: `apps/ade-cli/src/tuiClient/components/Header.tsx` (or new `GoalLine.tsx`), `app.tsx` + +Today goal is rendered as a transient chat row at `format.ts:376-385` with `tone: "notice"` (gray). Fix: +- Add `latestGoal: CodexThreadGoal | null` to TUI session state (mirror of desktop `session.codexGoal`). +- Render a single amber line beneath Header when goal is set: + ``` + ◎ Refactor auth middleware... 2.3k/50k · 4m · active + ``` +- Use `theme.color.warning` (amber `#F59E0B`). +- Truncate to terminal width. +- Remove the `format.ts` chat-row case for `codex_goal_updated`/`codex_goal_cleared` — the banner is the truth. + +### C.5 Extended ContextMeter (5-field token breakdown + cached) +**File**: `apps/ade-cli/src/tuiClient/components/ModelStatus.tsx:14-26` + +Current `ContextMeter` only shows context-window %. Extend to show last-turn input/output + cached marker: + +``` +◇ Codex · gpt-5 · medium · workspace-write ▓▓▓▓▓▓▓░░░ 64% +2.3k/1.1k (450✶) +``` + +Read `cachedInputTokens` / `cacheReadTokens` from `tokens` and `codex_token_usage` events in `adeApi.ts:347-391` (currently dropped — see TUI audit). + +Drop the `codex_token_usage` chat-row case (`format.ts:386-398`) — footer is the truth. + +### C.6 Plan card glyphs + active-step accent +**Files**: `apps/ade-cli/src/tuiClient/theme.ts`, `format.ts:338-352` + +- Add `glyphFor(status)` helper in `theme.ts` returning `◐ / ○ / ●` for `inProgress / pending / completed`. +- Replace ASCII `> ✓ x -` glyphs. +- Active step uses `theme.color.accent` (`#A78BFA`); other steps `theme.color.notice` (gray). +- Match desktop's `CodexPlanCard` exactly for cross-surface consistency. + +### C.7 Web search per-line action list +**File**: `format.ts:354-362` + +Today: `actions.slice(0, 3).map(...).join(" · ")` — inline collapse destroys the structural distinction between action types. Fix: render each action on its own indented line per plan §5.6: + +``` +🔍 web search · "thread/turns/list pagination" + search thread/turns/list pagination + openPage github.com/openai/codex/.../thread.rs + findInPage "items_view" in thread.rs +``` + +### C.8 `h` key opens images +**File**: `app.tsx` + +When the selected/most-recent chat line is `codex_image_generation` or `codex_image_view` (and `path`/`result` is a local file), pressing `h` spawns `open` / `xdg-open` / `start` for the path. Add the binding alongside existing PR-URL-open patterns at `app.tsx:2179`. + +### C.9 Chat row budget fix +**File**: `app.tsx:3199-3218` + +`chatRowBudget = rows - 12 - statusRows`. With `streaming=true`, budget drops to 11 on `LINES=24`. Resume palette is gone (saves 2 rows), but we should also: +- Move the standalone "streaming" Text row (`app.tsx:3216-3218`) into the prompt-box border as a small `· streaming` annotation. Saves 1 row. +- Drop the constant from 12 to 10 (recount overhead: Header 1 + ModelStatus 1 + FooterControls 1 + prompt-box border 3 + flex padding 2 = 8; mention/slash palettes add their own rows when active). + +Target: 14-16 rows of chat scrollback on `LINES=24`. + +### C.10 `--image-url ` and `--print` flags +**File**: `apps/ade-cli/src/cli.ts:5132-5150` + +Add to `ade chat send`: +- `--image-url ` — appends an `image-url` attachment to the message +- `--print` — non-interactive mode; triggers Phase 10 opt-out (see §A.5) + +--- + +## Section D — Open in Codex CLI (desktop only) + +### D.1 Behavior + +A button in the Codex chat header/toolbar. Visible when: +- Provider = Codex +- Session is **not** a mission (mission CODEX_HOME is ephemeral) +- `managed.session.threadId` is set + +On click, a small popover with two options: +1. **In ADE terminal** — opens `ChatTerminalDrawer` (already exists per-chat), `cd`s to the lane worktree, runs the resume command in the lane's terminal pane. +2. **In new window** — spawns the user's default terminal app (`open -a Terminal` on macOS, `gnome-terminal` / `xdg-terminal` on Linux, `start cmd` on Windows) with the resume command. + +### D.2 Resume command + +Default to ADE's **bundled** `codex` binary (`resolveCodexExecutable()` returns the bundled path). Guarantees version match with the app-server that owns the thread. + +Need to verify what flag form Codex CLI uses to resume a specific thread: +- `codex resume ` (likely) +- `codex --thread ` +- `codex` and rely on interactive `Ctrl+R` picker + +**Implementation TODO**: probe `codex --help` at build time; spawn a one-shot subprocess and check stdout for the right flag. If neither flag exists, fall back to launching `codex` interactively and copying the thread ID to clipboard with a toast: "Thread ID copied — paste into Ctrl+R picker". + +### D.3 Files + +- New: `apps/desktop/src/renderer/components/chat/codex/CodexOpenInCliButton.tsx` +- New: `apps/desktop/src/main/services/chat/codexCliLauncher.ts` — handles cross-platform terminal spawn +- `apps/desktop/src/main/services/ipc/registerIpc.ts` — IPC handler `codex:openInCli` with `{ sessionId, mode: "ade-terminal" | "new-window" }` +- `apps/desktop/src/preload/preload.ts` — expose `window.ade.codex.openInCli` +- `apps/desktop/src/renderer/components/chat/AgentChatPane.tsx` — mount the button in the chat header + +--- + +## Section E — Cheap CLI parity verification + +Most Codex CLI commands are SDK pass-throughs (`agentChatService.ts:509-562`): ADE lists them in the slash palette, user types them, Codex SDK handles them. This audit step confirms each pass-through actually works end-to-end. The risk: dead-listed commands where ADE shows them in the palette but Codex SDK ignores them (or vice versa). + +### E.1 Verify each pass-through + +Walk through every command in the registry — `/copy`, `/diff`, `/feedback`, `/init`, `/personality`, `/title`, `/clear`, `/fork`, `/keymap`, `/statusline`, `/vim`, `/experimental`, `/permissions`, `/agents`, `/apps`, `/plugins`, `/hooks`, `/mcp`, `/ide` — and run an integration smoke test (TUI + desktop): +- Type the command, hit enter. +- Capture any response (notification, item, error). +- Confirm the user-visible UX is reasonable (or document where it's not). + +### E.2 TUI hotkeys +**File**: `app.tsx` + +- `Ctrl+O` — copy latest assistant message to clipboard (uses `clipboardy` or `child_process.exec("pbcopy"...)`). +- `Ctrl+L` — clear viewport (clears the chat scrollback rendering, **does not** start a new thread). + +--- + +## Section F — Cheap Tier B + +These add no new UI pages but light up real Codex surfaces. + +### F.1 Deprecation / warning notification channels +**File**: `agentChatService.ts` (new handlers in the notification dispatch block 10441-11021) + +Codex emits four warning-channel notifications today: +- `deprecationNotice` — feature being removed +- `warning` — general warning +- `guardianWarning` — sandbox guardian flagged something +- `configWarning` — config issue + +ADE silently logs these. Surface as `system_notice` rows: +- `deprecationNotice` → tone `warning`, prefix "⚠ deprecated:" +- `warning` → tone `warning`, prefix "⚠" +- `guardianWarning` → tone `error`, prefix "🛡 guardian:" +- `configWarning` → tone `notice`, prefix "⚙ config:" + +Renderers already handle `system_notice` — zero new UI. + +### F.2 `thread/inject_items` mid-session context +**File**: `agentChatService.ts` (new slash handler) + +Adds a `/inject` slash that takes a multiline message and pushes it into thread history as a user item. Useful for "remember this for the rest of the thread" patterns. Renders as a small notice row: `[injected] {first line preview}`. + +Composer: optionally add a small `+` menu entry "Inject context" that opens a textarea modal. + +### F.3 `completion_report` and `turn_diff_summary` renderers +**File**: `AgentChatMessageList.tsx` + +Both event variants exist in the union but have no renderer cases. Add: +- `completion_report` → quiet "turn summary" card at the end of long autonomy runs. Plain prose + report breakdown. +- `turn_diff_summary` → small chip showing `+{additions}/-{deletions} across N files` with click-to-open-diff. + +### F.4 `/review` working-tree variant +**File**: `agentChatService.ts:7877` + +Today only `review/start { target: { type: "prompt", ... } }` is wired. Extend to support `target: { type: "diff" }` (review the current working-tree diff) and `target: { type: "branch", name }` (review a branch). + +Slash: `/review` (working tree) / `/review branch ` / `/review prompt `. + +--- + +## Section G — Tests + +### G.1 Wire-shape tests +New in `agentChatService.test.ts`: +- `WebSearchAction::Other` catch-all renders without crashing. +- `image-url` attachment → `{ type: "image", url }` UserInput wire shape. +- `imageGeneration` + `imageView` item handlers emit correct events. +- Token-usage normalizer handles snake/camel aliases + 5-field breakdown including `cachedInputTokens` and `reasoningOutputTokens`. +- `thread/compact/start` request shape. +- `thread/goal/updated` and `thread/goal/cleared` notification handling. +- **Double-Option omit semantics**: `/goal pause` does NOT serialize `tokenBudget` field at all (use `expect(Object.keys(params).includes("tokenBudget")).toBe(false)`). +- Server-initiated request stubs return well-formed `-32601` errors. +- `optOutNotificationMethods` populated when `runtimeMode === "print"`. +- `--disable browser_use --disable computer_use` flags NOT present in spawn args (mission + normal). + +### G.2 `codexExecutable.test.ts` +- `ADE_DISABLE_BUNDLED_CODEX=1` env disable flag falls through. +- `resourcesPath` walk (packaged Electron app). +- `app.asar.unpacked/node_modules/@openai` walk. + +### G.3 TUI tests +**File**: `apps/ade-cli/src/tuiClient/__tests__/format.test.ts` + +- New cases for each of the 15 added event variants (assert body substring + tone color). +- `system_notice` `continue;` regression. +- `latestTokenStats` reads `cachedInputTokens` / `cacheReadTokens`. + +### G.4 No render-tree snapshot tests +Per `feedback_testing_quality.md`: real-value tests only. Visual changes don't get DOM snapshot tests — they're verified by manual smoke + screenshots. + +--- + +## Sequencing + +Phases are largely independent. Recommended order: +1. **A** (wire fixes) — unblocks everything else. ~1 day. +2. **B** + **C** in parallel — renderer extraction can happen alongside TUI rework (no shared files). ~2 days each, parallelizable. +3. **D** (open-in-CLI) — small standalone feature. ~half day. +4. **E** (CLI parity verification) — half day testing, half day fixing whatever's broken. +5. **F** (cheap Tier B) — ~1 day. +6. **G** (tests) — interleaved throughout, finalized at the end. + +Estimated total: **4-5 days** focused work. Estimated total with the inevitable surprises: 6-7 days. + +--- + +## Definition of done + +- Critical disable-flag bug fixed; `browser_use` / `computer_use` available everywhere. +- Every `Codex*.tsx` component extracted to its own file under `chat/codex/`. +- Goal banner is amber, has edit/clear, persists above message list (not scrolled). +- Token footer has progress bar, last-turn breakdown, cache marker; renders at chat-column bottom. +- Plan card has violet accent, `◐/○/●` glyphs, active-step highlighted. +- Image generation card has Open button + `savedPath`. +- Image view is single-line inline. +- Web search shows all actions on separate lines. +- Composer paste interception is non-silent. +- Image-URL attachment shows a thumbnail. +- TUI: ResumePalette gone; 15 missing format cases added; `/compact` and `/goal` builtins; goal pinned as amber banner; ContextMeter shows 5-field breakdown + cache marker; plan glyphs match desktop; web search per-line; `h` opens images; chat budget ≥ 14 rows at LINES=24; `--image-url` and `--print` flags. +- Open-in-CLI button works for normal Codex sessions (hidden for missions). +- `/side` slash works; `Ctrl+O` / `Ctrl+L` hotkeys. +- Deprecation/warning channels surface as notices. +- `thread/inject_items` + `/inject` slash. +- `completion_report` / `turn_diff_summary` render. +- `/review diff` and `/review branch` variants. +- All §G tests pass under sharded `pnpm test`. +- Manual smoke checklist signed off in dev Electron build + dev TUI build. + +--- + +## Open questions + +1. **Codex CLI resume flag form** — does `codex resume ` exist? Need to probe at implementation start; fall back to interactive picker + clipboard if not. (§D.2) +2. **Bundled-binary version on user's PATH** — if the user has an older `codex` on PATH and ADE bundles `0.130.0`, the spawned `codex resume` could use the wrong binary. Defaulting to ADE's bundled path avoids this but means we always run our version even when the user explicitly wanted theirs. (§D.2) — leaving as: default to bundled, with a `CODEX_EXECUTABLE` env var escape. +3. **Channel-isolated profiles** (commit `5de5f054`) — does normal-chat `CODEX_HOME` actually shift based on channel (beta vs stable)? If so, "open in CLI" from beta ADE writes to `~/.codex-beta/` and user's stable `codex` doesn't see it. Need to verify and decide how to handle. diff --git a/plans/ade-32-codex-v130-chat-parity.md b/plans/ade-32-codex-v130-chat-parity.md new file mode 100644 index 000000000..1aee065ce --- /dev/null +++ b/plans/ade-32-codex-v130-chat-parity.md @@ -0,0 +1,228 @@ +# ADE-32 — Codex app-server v0.130 chat parity (Tier A) + +Status: proposed +Date: 2026-05-11 +Linear: https://linear.app/ade-linear/issue/ADE-32 +GitHub issue: https://github.com/arul28/ADE/issues/278 + +## Goal + +Bump ADE's bundled Codex `app-server` to `rust-v0.130.0` and bring the work-tab chat (Electron) and the ADE TUI to user-visible parity with Codex's own desktop app and Codex CLI for **chat UX surfaces** — plans, goals, compaction, image input/output, web search rendering, image view, persistent history with search/fork/rollback, long-thread pagination, and a token-usage HUD. + +Out of scope for this pass (deferred to a follow-up if/when needed): +- Hook system, plugin/marketplace browser, apps/connectors UI, MCP-in-app UI (the **runtime** for these is enabled by removing two `--disable` flags below; users configure them via the `codex` CLI / `~/.codex/`). +- Realtime voice, fs/process/command-exec RPCs, environments, dynamic client tools, multi-agent collaboration UI, memory mode, attestation, ChatGPT token refresh. + +## Why now + +The bundled binary currently floats to whatever `codex` is on the user's PATH. The v2 wire is stable and we already speak it, but the chat UI is shaped around the v1 mental model in several places (no plan card, no compaction event, no rich web search rendering, etc.). Codex CLI users on v0.13x see features we don't render, which is a visible quality gap when those same users sit down in ADE's work tab. + +## Sources + +Primary protocol sources (canonical — read these before coding each phase): + +- Method + notification macro registry: https://raw.githubusercontent.com/openai/codex/main/codex-rs/app-server-protocol/src/protocol/common.rs +- v2 module index: https://raw.githubusercontent.com/openai/codex/main/codex-rs/app-server-protocol/src/protocol/v2/mod.rs +- Item enum (every `ThreadItem` variant): https://raw.githubusercontent.com/openai/codex/main/codex-rs/app-server-protocol/src/protocol/v2/item.rs +- Thread RPCs: `…/v2/thread.rs`, `…/v2/thread_data.rs` +- Turn RPCs: `…/v2/turn.rs` +- Compaction + tokens: `…/v2/notification.rs` +- App-server README (human-readable protocol guide): https://raw.githubusercontent.com/openai/codex/main/codex-rs/app-server/README.md +- v1 → v2 migration doc: https://raw.githubusercontent.com/openai/codex/main/codex-rs/docs/protocol_v1.md +- Slash commands inventory: https://developers.openai.com/codex/cli/slash-commands + +Reference (do not implement now, but cite when scope grows): + +- Realtime: `…/v2/realtime.rs` +- fs RPCs: `…/v2/fs.rs` +- command/exec: `…/v2/command_exec.rs` + +## ADE current state (audit) + +ADE talks Codex v2 over stdio. Authoritative client lives at `apps/desktop/src/main/services/chat/agentChatService.ts` (18,847 lines). The TUI does **not** speak Codex protocol — it consumes the normalized `AgentChatEvent` envelope defined at `apps/desktop/src/shared/types/chat.ts:150-446` via the ADE RPC server. Any new Codex item therefore threads through three layers: + +1. `agentChatService.ts` — handle the new Codex notification / send the new request. +2. `apps/desktop/src/shared/types/chat.ts` — add the new `AgentChatEvent` variant (47 today). +3. Both renderers — desktop component in `apps/desktop/src/renderer/components/chat/` AND TUI formatter in `apps/ade-cli/src/tuiClient/components/ChatView.tsx`. + +Methods we already implement (verified at `agentChatService.ts:7793, 11259, 11349, 11381, 12142, 16319, 16699, 17796, 18486` etc.): + +`initialize`, `thread/start`, `thread/resume`, `thread/archive`, `thread/name/set`, `turn/start`, `turn/steer`, `turn/interrupt`, `skills/list`, `collaborationMode/list`, `account/rateLimits/read`, `model/list`, `review/start`, `fuzzyFileSearch`. + +Notifications handled (at `agentChatService.ts:10441-11021`): + +`turn/started`, `turn/completed`, `turn/aborted`, `item/agentMessage/delta`, `item/reasoning/summaryTextDelta`, `item/reasoning/textDelta`, `item/reasoning/summaryPartAdded`, `item/commandExecution/outputDelta`, `item/fileChange/outputDelta`, `item/plan/delta`, `turn/plan/updated`, `item/started`, `item/completed`, `codex/event/web_search_begin`, `thread/status/changed`, `error`, `account/rateLimits/updated`, `account/updated`, `account/login/completed`, `item/autoApprovalReview/started`, `item/autoApprovalReview/completed`, `thread/name/updated`, `thread/updated`. + +Server→client requests answered: `item/commandExecution/requestApproval`, `item/fileChange/requestApproval`, `item/permissions/requestApproval`, `item/tool/requestUserInput`. + +Gates in place that this plan touches: + +- `agentChatService.ts:11059` — currently passes `--disable plugins --disable apps --disable browser_use --disable computer_use`. **Plan: drop `plugins` and `apps` from this list** so users with plugins/apps configured via the Codex CLI get them in ADE. Keep `browser_use` and `computer_use` disabled (ADE owns these via its own ai-tools layer). +- `agentChatService.ts:7800-7802` — triple-named reasoning effort fallback (`effort` / `reasoningEffort` / `reasoning_effort`). v0.130 standardizes on `effort`. Clean up. +- `experimentalRawEvents: false` (line 11059) stays off; Tier C content only. + +## Implementation plan — phased, surface-by-surface + +The user picked **parity in one pass** for desktop + TUI. Every numbered phase below ships both renderers together. + +### Phase 0 — Bundle the binary and stabilize the handshake + +1. **Pin and bundle `codex` v0.130.0** in the desktop installer and `apps/ade-cli` npm package. Files to change: + - Release workflows: `.github/workflows/*release*.yml` — add a download step that fetches the `codex` binary for each target platform (macOS x64+arm64, Windows x64, Linux x64+arm64) from `https://github.com/openai/codex/releases/download/rust-v0.130.0/...`, verifies sha256, and stages it inside the app bundle. + - Desktop packaging: extend `apps/desktop/build/` config to ship the binary under `resources/codex/` and resolve from there first. + - CLI packaging: ship in `apps/ade-cli/bin/codex/` and resolve via `resolveCodexExecutable` at `apps/desktop/src/main/services/ai/codexExecutable.ts:18-42` — extend this resolver to check bundled paths before falling through to env / PATH. + - Keep `CODEX_EXECUTABLE` / `CODEX_EXECUTABLE_PATH` env overrides for dev. +2. **Initialize handshake cleanup**: + - Set `clientInfo.name = "ade_desktop"` (or `"ade_tui"` from the CLI), `clientInfo.version = ade package version`. + - Set `capabilities.experimentalApi = true`. + - Set `capabilities.optOutNotificationMethods = []` initially; later phases populate it for perf. + - Stub server→client requests we don't answer yet: `attestation/generate` → return capability error; `account/chatgptAuthTokens/refresh` → return capability error; `item/tool/call` → return capability error. These cannot crash the runtime. +3. **Drop the `plugins` and `apps` `--disable` flags** at `agentChatService.ts:11059`. Keep `browser_use` and `computer_use`. Smoke-test that plugin-installed skills appear in `skills/list` for users who configured them via Codex CLI. +4. **Clean up the triple-named reasoning effort** at `agentChatService.ts:7800-7802` to only send `effort`. v0.130 canonical key is `effort`. +5. **Smoke test**: existing chats, approvals, reasoning, file diffs, command exec, /review still work end-to-end against the bundled binary. + +### Phase 1 — Plan-mode card + +What Codex emits today: `plan` item with `item/started` (with the structured plan steps), `item/plan/delta` for streaming explanation, `item/completed`. Also `turn/plan/updated { explanation, plan: [{ description, status: pending|inProgress|completed }] }`. + +What ADE has: `turn/plan/updated` handled (logged) and `item/plan/delta` handled (mutates a `proposed-plan` text buffer). No structured rendering. There's an existing `ChatProposedPlanCard.tsx` for the Claude path that's a good visual template. + +Changes: + +1. Add `AgentChatEvent` variants: + - `{ type: "plan_update", planId, steps: [{ id, description, status }], explanation, turnId, sessionId }` + - Extend the existing in-place plan-delta accumulator to attach to the structured plan. +2. `agentChatService.ts` — replace the text-only plan handler with structured handling that: + - On `item/started` for a `plan` item, emit `plan_update` with all steps in `pending`. + - On `turn/plan/updated`, replace step list. + - On `item/plan/delta`, append to `explanation` (markdown). + - On `item/completed` for the plan item, finalize and mark inactive. +3. Desktop: rename / generalize `ChatProposedPlanCard.tsx` so it renders the structured plan with step checkboxes, current step highlighted, explanation panel underneath. +4. TUI: extend `ChatView.tsx` with a `plan` block renderer — bordered box, checkbox glyphs (`◉` / `◐` / `○`), explanation as dimmed text below. + +### Phase 2 — Manual `/compact` and contextCompaction item + +What Codex emits: `contextCompaction` item streamed via `item/started` → `item/completed`. Triggered by client via `thread/compact/start`. The old `thread/compacted` notification is deprecated. + +Changes: + +1. Wire `thread/compact/start` request behind a `/compact` slash command in both surfaces (already routed through ADE's slash registry — add a Codex-only entry). +2. Add `AgentChatEvent` variant `{ type: "context_compaction", state: "started" | "completed", summary?, turnId, sessionId }`. +3. Desktop renderer: subtle inline "context compacted — N tokens reclaimed" chip. +4. TUI: dimmed notice line with same content. + +### Phase 3 — Goals (`/goal`) + +Methods: `thread/goal/set`, `thread/goal/get`, `thread/goal/clear`. Notifications: `thread/goal/updated`, `thread/goal/cleared`. + +Changes: + +1. Add `AgentChatEvent` variant `{ type: "goal", state: "set" | "cleared", goal?: string, sessionId }`. +2. Slash commands `/goal ` and `/goal clear` in desktop composer and TUI composer routes. +3. Persist active goal in `ChatSession` so we can render a pinned banner above the message list ("Goal: X"). +4. Desktop: persistent slim banner at the top of the chat pane, click-to-edit. +5. TUI: status line above prompt, same content. + +### Phase 4 — Image input + +Today ADE supports `localImage` attachments only (`agentChatService.ts:7784`). Codex also accepts `image: { url }` for clipboard-pasted or drag-dropped URLs. + +Changes: + +1. Composer detects image URLs / clipboard image data; for URL form, send `{ type: "image", url }`; for paste/file, stage to tmp dir and send `{ type: "localImage", path }` (current path). +2. TUI: support image paths via `@file.png` mention syntax that already exists; add a `--image ` flag for `ade chat send`. + +### Phase 5 — imageGeneration + imageView items + +Codex now emits `imageGeneration` items (when the model produces images) with `{ savedPath, revisedPrompt, result }`, and `imageView` items (when the model views an image as a tool call). + +Changes: + +1. Add `AgentChatEvent` variants: + - `{ type: "image_generation", path, revisedPrompt, status, turnId, sessionId }` + - `{ type: "image_view", path, source, turnId, sessionId }` +2. Desktop: image thumbnail in the message stream, click-to-zoom modal that already exists for attachment previews. +3. TUI: inline notice with path + `↗ open` keybind that calls `open ` via the system handler. + +### Phase 6 — Rich `webSearch` item rendering + +Today ADE logs `codex/event/web_search_begin` but doesn't render the structured `webSearch` item. The item carries a `query` and a `WebSearchAction` discriminated union: `search` (initial query), `open_page` (URL visited), `find_in_page` (in-page find). + +Changes: + +1. Add `AgentChatEvent` variant `{ type: "web_search", query, actions: WebSearchAction[], status, turnId, sessionId }` where `WebSearchAction` mirrors the Codex shape. +2. Desktop: collapsible "Web search: " card listing each action (icons for search / open / find), URL hyperlinked. +3. TUI: nested list under "🔍 web search" header. + +### Phase 7 — Token-usage HUD + +`thread/tokenUsage/updated` is emitted on every turn boundary and resume. ADE already receives this — needs surfacing. + +Changes: + +1. Add `AgentChatEvent` variant `{ type: "token_usage", input, output, cacheRead, cacheWrite, total, sessionId }`. +2. Desktop: extend `ModelStatus` / footer area to show `1.2k in / 4.5k out (cached 800)` per turn, plus a running session total. +3. TUI: extend `ModelStatus.tsx` with the same numbers under the model name. + +### Phase 8 — Thread history: resume picker + fork + unarchive + rollback + +This is the biggest UX piece. Codex CLI's `/resume` UX is what we're matching. + +Methods we'll add: + +- `thread/list { searchTerm?, cursor?, sortKey?, sortDirection?, cwd?, sourceKinds?, archived?, modelProviders? }` returns `{ items: [...], nextCursor, backwardsCursor }`. Items include name, lastActivityAt, turn count, cwd, archived. +- `thread/read { threadId, includeTurns: boolean }` returns metadata + (optionally) all turns. Used for read-only preview before resuming. +- `thread/fork { threadId, ephemeral?: boolean }` — branch into new thread. +- `thread/unarchive { threadId }` — restore. +- `thread/rollback { threadId, lastN }` — drop trailing turns. + +Changes: + +1. Add a new `CodexHistoryService` wrapper in `agentChatService.ts` that owns these requests (they don't need to be tied to a running session — can be called on a one-shot app-server instance, but easier to reuse an existing managed session's runtime if one is open, else spawn a transient `codex app-server` and shut down). +2. IPC: expose `window.ade.codex.history.{list,read,fork,unarchive,rollback}` to the renderer. +3. Desktop: new "Codex history" picker (modal or right-pane drawer) — list with search box, cwd filter, archived toggle, per-row actions (resume / fork / unarchive / rollback). Triggered by `/resume` slash command and by a new toolbar entry in the work-tab chat header. +4. TUI: a new `ChatHistoryPalette` component (modeled on `MentionPalette.tsx`), opened by `Ctrl+R` or `/resume`. Same list + actions, keyboard-only. + +### Phase 9 — Long-thread pagination + +When resuming a long thread today, `thread/resume` returns everything. Codex now supports `thread/turns/list { threadId, cursor?, itemsView: "summary" | "full" | "notLoaded" }` so we can lazy-load. + +Changes: + +1. `thread/resume` continues to be the entry point but switch to `itemsView: "summary"` for the initial load (returns turn metadata + first/last item per turn). +2. Renderer requests `itemsView: "full"` on scroll-up for the next page of turns. +3. Persist scroll-restore state so users land where they left off. +4. TUI: same pagination via keyboard scrollback. + +### Phase 10 — Notification opt-out for perf + +Add `optOutNotificationMethods` to the `initialize` request based on which renderer is consuming: +- Desktop: keep deltas (visual streaming matters). +- TUI in non-interactive mode (`ade chat send --print`): opt out of `item/agentMessage/delta`, `item/reasoning/summaryTextDelta`, `item/commandExecution/outputDelta` because we only print final state. + +### Phase 11 — Verification + tests + +Per repo testing memory: **only real-value tests, no brittle UI/render tests, shard test runs, run only related tests after focused changes.** + +1. Protocol round-trip tests for each new request/notification in `agentChatService.test.ts` (we have an existing fixture harness; extend it with a fake app-server that emits the new notifications). +2. End-to-end smoke per phase: scripted `codex app-server` against a real bundled v0.130 binary in CI, run a turn through that exercises plan / compact / goal / image / web_search / pagination, assert the resulting `AgentChatEvent` envelope. +3. Manual: open the work-tab chat against each phase's feature, capture a screenshot, confirm rendering. For UI changes I'll start the dev server and exercise the flow in-browser before reporting done. + +## Risk / open questions + +1. **Binary size for the bundle.** `codex` is ~50MB per platform; bundling 5 platforms inflates installer size. Mitigation: only bundle the current platform per build target (already what electron-builder does for native modules), but we'll need to teach the workflow to download per-target. +2. **`thread/list` performance** with very large `~/.codex/sessions` dirs. Codex itself paginates. We must always pass `limit` (default 20) and use cursor pagination — never list all. +3. **Goal persistence vs ADE's per-lane session model.** Goals are per-thread in Codex; ADE's chat session is per-lane. Map 1:1. +4. **`thread/fork` ephemerality semantics** — when `ephemeral: true`, the forked thread isn't persisted to disk. Useful for "try this variation" UX, but we need to surface this in the fork UI so users know. +5. **Plugin / app discovery flake** — once we drop `--disable plugins --disable apps`, a misconfigured plugin in `~/.codex/` could log warnings. We should surface `configWarning` notifications in the chat as a subtle notice (one-line addition). + +## Definition of done + +- Bundled `codex` is `rust-v0.130.0`, verified on macOS arm64 and Windows x64 ADE builds. +- `--disable plugins` and `--disable apps` removed from the spawn invocation. +- Every Tier A feature renders in both desktop work-tab chat and TUI ChatView. +- `/resume`, `/compact`, `/goal`, `/plan` slash commands wired in both surfaces. +- Token usage visible in the model status footer of both surfaces. +- Plan, compaction, goal, image-gen, image-view, web-search items have dedicated visual treatment (not "text" fallback). +- New unit tests pass via `pnpm test --filter codex` (sharded). +- Manual smoke checklist run on a fresh worktree: see Phase 11. diff --git a/scripts/dev-code.mjs b/scripts/dev-code.mjs index a19ca32a0..f365d5fb4 100644 --- a/scripts/dev-code.mjs +++ b/scripts/dev-code.mjs @@ -8,6 +8,7 @@ import { ensureRuntime, resolveDevSocketPath, resolveProjectRoot, + resolveWorkspaceRoot, run, } from "./dev-shared.mjs"; @@ -21,7 +22,8 @@ function usage() { "Options:", " --auto Start dev runtime if missing. Default.", " --attach Require an existing dev runtime.", - " --project-root Project root. Defaults to this checkout.", + " --project-root ADE project data root. Defaults to the primary checkout for ADE worktrees.", + " --workspace-root Source checkout/workspace root. Defaults to this checkout.", " --socket Dev runtime socket. Defaults to /tmp/ade-runtime-dev.sock.", " --skip-runtime-build Launch without rebuilding apps/ade-cli.", " -h, --help Show this help.", @@ -32,6 +34,7 @@ function parseArgs(argv) { const options = { mode: "auto", projectRoot: null, + workspaceRoot: null, socketPath: null, skipRuntimeBuild: false, rest: [], @@ -63,6 +66,15 @@ function parseArgs(argv) { options.projectRoot = arg.slice("--project-root=".length); continue; } + if (arg === "--workspace-root") { + options.workspaceRoot = argv[++i] ?? null; + if (!options.workspaceRoot) throw new Error("--workspace-root requires a path."); + continue; + } + if (arg.startsWith("--workspace-root=")) { + options.workspaceRoot = arg.slice("--workspace-root=".length); + continue; + } if (arg === "--socket") { options.socketPath = argv[++i] ?? null; if (!options.socketPath) throw new Error("--socket requires a path."); @@ -81,6 +93,7 @@ function parseArgs(argv) { return { ...options, projectRoot: resolveProjectRoot(options.projectRoot), + workspaceRoot: resolveWorkspaceRoot(options.workspaceRoot), socketPath: resolveDevSocketPath(options.socketPath), }; } @@ -89,6 +102,7 @@ async function main() { const options = parseArgs(process.argv.slice(2)); process.stdout.write(`[ade] code mode: ${options.mode}\n`); process.stdout.write(`[ade] project root: ${options.projectRoot}\n`); + process.stdout.write(`[ade] workspace root: ${options.workspaceRoot}\n`); process.stdout.write(`[ade] runtime socket: ${options.socketPath}\n`); await buildRuntimeCli(options.skipRuntimeBuild); if (options.mode === "attach") { @@ -106,7 +120,7 @@ async function main() { "--project-root", options.projectRoot, "--workspace-root", - options.projectRoot, + options.workspaceRoot, "--socket", options.socketPath, "--require-socket", diff --git a/scripts/dev-desktop.mjs b/scripts/dev-desktop.mjs index 5f36c805f..feac5b712 100644 --- a/scripts/dev-desktop.mjs +++ b/scripts/dev-desktop.mjs @@ -21,7 +21,7 @@ function usage() { "Options:", " --auto Use dev socket and let desktop create runtime if missing. Default.", " --attach Require an existing dev runtime before launching desktop.", - " --project-root Project to auto-open. Defaults to this checkout.", + " --project-root Project to auto-open. Defaults to the primary checkout for ADE worktrees.", " --socket Dev runtime socket. Defaults to /tmp/ade-runtime-dev.sock.", " --clean Use desktop dev:clean instead of dev.", " --skip-runtime-build Launch without rebuilding apps/ade-cli.", diff --git a/scripts/dev-shared.mjs b/scripts/dev-shared.mjs index d5d4c2ef1..9186aaf21 100644 --- a/scripts/dev-shared.mjs +++ b/scripts/dev-shared.mjs @@ -1,4 +1,5 @@ import { spawn } from "node:child_process"; +import fs from "node:fs"; import net from "node:net"; import os from "node:os"; import path from "node:path"; @@ -19,14 +20,47 @@ export function resolveDevSocketPath(rawSocketPath = null) { return candidate.startsWith("tcp://") ? candidate : path.resolve(candidate); } +export function resolvePrimaryProjectRoot(candidateRoot = repoRoot) { + const resolved = path.resolve(candidateRoot); + const parts = resolved.split(path.sep); + for (let i = parts.length - 1; i >= 0; i -= 1) { + if (parts[i] !== ".ade" || parts[i + 1] !== "worktrees" || !parts[i + 2]) continue; + const rootParts = parts.slice(0, i); + const projectRoot = rootParts.length === 0 ? path.sep : rootParts.join(path.sep); + return path.resolve(projectRoot); + } + return resolved; +} + export function resolveProjectRoot(rawProjectRoot = null) { - return path.resolve(rawProjectRoot?.trim() || process.env.ADE_PROJECT_ROOT?.trim() || repoRoot); + const explicit = rawProjectRoot?.trim() || process.env.ADE_PROJECT_ROOT?.trim(); + if (explicit) return path.resolve(explicit); + return resolvePrimaryProjectRoot(repoRoot); +} + +export function resolveWorkspaceRoot(rawWorkspaceRoot = null) { + return path.resolve(rawWorkspaceRoot?.trim() || process.env.ADE_WORKSPACE_ROOT?.trim() || repoRoot); } export function cliPath() { return path.join(repoRoot, "apps", "ade-cli", "dist", "cli.cjs"); } +export function resolveDevAppVersion() { + const override = process.env.ADE_CLI_VERSION?.trim(); + if (override) return override; + try { + const packageJson = JSON.parse( + fs.readFileSync(path.join(repoRoot, "apps", "desktop", "package.json"), "utf8"), + ); + const version = typeof packageJson.version === "string" ? packageJson.version.trim() : ""; + if (version) return version; + } catch { + // Fall through to the placeholder used by source-only CLI builds. + } + return "0.0.0"; +} + export function run(command, args, extraEnv = {}) { return new Promise((resolve, reject) => { const child = spawn(command, args, { @@ -52,7 +86,9 @@ export function run(command, args, extraEnv = {}) { export async function buildRuntimeCli(skipRuntimeBuild = false) { if (skipRuntimeBuild) return; process.stdout.write("[ade] building runtime CLI\n"); - await run(npmCommand, ["--prefix", "apps/ade-cli", "run", "build"]); + await run(npmCommand, ["--prefix", "apps/ade-cli", "run", "build"], { + ADE_CLI_VERSION: resolveDevAppVersion(), + }); } function createSocket(socketPath) { @@ -101,6 +137,7 @@ export async function ensureRuntime(socketPath) { cwd: repoRoot, env: { ...process.env, + ADE_CLI_VERSION: resolveDevAppVersion(), ADE_DEV_RUNTIME_SOCKET_PATH: socketPath, ADE_RUNTIME_SOCKET_PATH: socketPath, ADE_RPC_SOCKET_PATH: socketPath, @@ -116,6 +153,7 @@ export async function ensureRuntime(socketPath) { export function devRuntimeEnv(socketPath, projectRoot) { return { + ADE_CLI_VERSION: resolveDevAppVersion(), ADE_DEV_RUNTIME_SOCKET_PATH: socketPath, ADE_RUNTIME_SOCKET_PATH: socketPath, ADE_RPC_SOCKET_PATH: socketPath, From 11898ea73227618f2b6d21f0fd64f54f65881b84 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Tue, 12 May 2026 05:51:51 -0400 Subject: [PATCH 02/18] ship: address review feedback --- apps/ade-cli/src/cli.test.ts | 11 +++ apps/ade-cli/src/cli.ts | 3 +- .../src/tuiClient/__tests__/adeApi.test.ts | 13 ++++ apps/ade-cli/src/tuiClient/adeApi.ts | 20 +++--- apps/ade-cli/src/tuiClient/app.tsx | 42 +++++++---- apps/desktop/pnpm-workspace.yaml | 20 +++--- apps/desktop/src/main/packagedRuntimeSmoke.ts | 1 + .../services/chat/agentChatService.test.ts | 6 +- .../main/services/chat/agentChatService.ts | 15 ++-- .../services/chat/codexCliLauncher.test.ts | 44 ++++++++++-- .../main/services/chat/codexCliLauncher.ts | 72 ++++++++++++++----- .../src/main/services/ipc/registerIpc.ts | 7 +- apps/desktop/src/renderer/browserMock.ts | 2 +- .../components/chat/AgentChatComposer.tsx | 3 +- .../components/chat/AgentChatPane.tsx | 54 ++++++++++---- .../chat/codex/CodexGoalBanner.test.tsx | 1 + .../components/chat/codex/CodexGoalBanner.tsx | 16 +++-- .../chat/codex/CodexImageGenerationCard.tsx | 7 +- .../chat/codex/CodexImageViewLine.tsx | 15 ++-- .../chat/codex/CodexOpenInCliButton.tsx | 6 ++ apps/desktop/src/shared/types/chat.test.ts | 2 +- plans/ade-32-codex-followup.md | 2 +- plans/ade-32-codex-v130-chat-parity.md | 2 +- scripts/dev-code.mjs | 8 ++- 24 files changed, 272 insertions(+), 100 deletions(-) diff --git a/apps/ade-cli/src/cli.test.ts b/apps/ade-cli/src/cli.test.ts index 633bf5633..2fb9df651 100644 --- a/apps/ade-cli/src/cli.test.ts +++ b/apps/ade-cli/src/cli.test.ts @@ -893,6 +893,17 @@ describe("ADE CLI", () => { }); }); + it("rejects --print=value on chat send", () => { + expect(() => buildCliPlan([ + "chat", + "send", + "chat-1", + "--print=true", + "--text", + "Hello", + ])).toThrow(/--print must be set at session creation time/); + }); + it("builds chat show/status as positional session summary calls", () => { const show = buildCliPlan(["chat", "show", "chat-1"]); expect(show.kind).toBe("execute"); diff --git a/apps/ade-cli/src/cli.ts b/apps/ade-cli/src/cli.ts index 15d32165b..f6b5f7871 100644 --- a/apps/ade-cli/src/cli.ts +++ b/apps/ade-cli/src/cli.ts @@ -5143,7 +5143,8 @@ function buildChatPlan(args: string[]): CliPlan { // initialize handshake runs once per session, so setting it per-message // would be a silent no-op. Reject explicitly so users move it to // `ade chat create --print`. - if (readFlag(args, ["--print"])) { + const hasPrintFlag = args.some((token) => token === "--print" || token.startsWith("--print=")); + if (hasPrintFlag) { throw new CliUsageError( "--print must be set at session creation time. Use `ade chat create --print ...`.", ); diff --git a/apps/ade-cli/src/tuiClient/__tests__/adeApi.test.ts b/apps/ade-cli/src/tuiClient/__tests__/adeApi.test.ts index 359ff864e..25bb0246a 100644 --- a/apps/ade-cli/src/tuiClient/__tests__/adeApi.test.ts +++ b/apps/ade-cli/src/tuiClient/__tests__/adeApi.test.ts @@ -120,6 +120,19 @@ describe("latestTokenStats", () => { expect(stats.inputTokens).toBe(2_300); expect(stats.outputTokens).toBe(1_100); }); + + it("reads cachedInputTokens from done usage events", () => { + const events = [ + envelope(1, { + type: "done", + turnId: "turn-1", + status: "completed", + usage: { inputTokens: 1_000, outputTokens: 200, cachedInputTokens: 350 }, + } as AgentChatEventEnvelope["event"]), + ]; + const stats = latestTokenStats(events); + expect(stats.cachedInputTokens).toBe(350); + }); }); describe("latestGoal", () => { diff --git a/apps/ade-cli/src/tuiClient/adeApi.ts b/apps/ade-cli/src/tuiClient/adeApi.ts index bb69fb25d..7827b41aa 100644 --- a/apps/ade-cli/src/tuiClient/adeApi.ts +++ b/apps/ade-cli/src/tuiClient/adeApi.ts @@ -297,6 +297,14 @@ export function latestTokenStats( let cachedInputTokens: number | null = null; let costUsd: number | null = null; let eventLimit: number | null = null; + const readCachedInputTokens = (bucket: Record | null): number | null => { + if (!bucket) return null; + if (typeof bucket.cacheReadTokens === "number") return bucket.cacheReadTokens; + if (typeof (bucket as { cachedInputTokens?: unknown }).cachedInputTokens === "number") { + return (bucket as { cachedInputTokens: number }).cachedInputTokens; + } + return null; + }; for (const envelope of events) { const event = envelope.event as Record; if (event.type === "status" && event.turnStatus === "started") streaming = true; @@ -320,22 +328,14 @@ export function latestTokenStats( // Codex passes cached read tokens as either cacheReadTokens (camelCase) or // cachedInputTokens (snake-cased upstream variant aliased through). Prefer // last-turn reading over total. - const readFromBucket = (bucket: Record | null): number | null => { - if (!bucket) return null; - if (typeof bucket.cacheReadTokens === "number") return bucket.cacheReadTokens; - if (typeof (bucket as { cachedInputTokens?: unknown }).cachedInputTokens === "number") { - return (bucket as { cachedInputTokens: number }).cachedInputTokens; - } - return null; - }; - cachedInputTokens = readFromBucket(last) ?? readFromBucket(total) ?? cachedInputTokens; + cachedInputTokens = readCachedInputTokens(last) ?? readCachedInputTokens(total) ?? cachedInputTokens; if (typeof usage?.modelContextWindow === "number") eventLimit = usage.modelContextWindow; } if (event.type === "done") { const usage = event.usage && typeof event.usage === "object" ? event.usage as Record : null; inputTokens = typeof usage?.inputTokens === "number" ? usage.inputTokens : inputTokens; outputTokens = typeof usage?.outputTokens === "number" ? usage.outputTokens : outputTokens; - if (typeof usage?.cacheReadTokens === "number") cachedInputTokens = usage.cacheReadTokens; + cachedInputTokens = readCachedInputTokens(usage) ?? cachedInputTokens; costUsd = typeof event.costUsd === "number" ? event.costUsd : costUsd; } } diff --git a/apps/ade-cli/src/tuiClient/app.tsx b/apps/ade-cli/src/tuiClient/app.tsx index 1c130b41a..616edac30 100644 --- a/apps/ade-cli/src/tuiClient/app.tsx +++ b/apps/ade-cli/src/tuiClient/app.tsx @@ -679,7 +679,7 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } const [formFieldIndex, setFormFieldIndex] = useState(0); const [rightSelectionIndex, setRightSelectionIndex] = useState(0); const [drawerOpen, setDrawerOpen] = useState(true); - const [rightOpen, setRightOpen] = useState(true); + const [rightOpen, setRightOpen] = useState(() => columns >= 110); const [activePane, setActivePane] = useState("chat"); const [prompt, setPrompt] = useState(""); const [error, setError] = useState(null); @@ -2232,17 +2232,27 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } const openLatestImage = useCallback(() => { let target: string | null = null; - for (const envelope of events) { - const event = envelope.event as Record; + for (let index = events.length - 1; index >= 0; index -= 1) { + const event = events[index]?.event as Record | undefined; + if (!event) continue; if (event.type === "codex_image_generation") { const candidate = (event as { result?: unknown }).result; - if (typeof candidate === "string" && candidate && !/^https?:|^data:/i.test(candidate)) { + if (typeof candidate === "string" && candidate && !/^data:/i.test(candidate)) { target = candidate; + break; } } if (event.type === "codex_image_view") { const local = (event as { path?: unknown }).path; - if (typeof local === "string" && local) target = local; + const remote = (event as { url?: unknown }).url; + if (typeof local === "string" && local) { + target = local; + break; + } + if (typeof remote === "string" && remote && !/^data:/i.test(remote)) { + target = remote; + break; + } } } if (!target) { @@ -2250,14 +2260,18 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } return; } try { - if (process.platform === "darwin") { - spawn("open", [target], { stdio: "ignore", detached: true }).unref(); - } else if (process.platform === "win32") { - spawn("cmd", ["/c", "start", "", target], { stdio: "ignore", detached: true }).unref(); - } else { - spawn("xdg-open", [target], { stdio: "ignore", detached: true }).unref(); - } - addNotice(`Opening ${path.basename(target)}…`, "info"); + const child = process.platform === "darwin" + ? spawn("open", [target], { stdio: "ignore", detached: true }) + : process.platform === "win32" + ? spawn("cmd", ["/c", "start", "", target], { stdio: "ignore", detached: true }) + : spawn("xdg-open", [target], { stdio: "ignore", detached: true }); + child.once("error", (err) => { + addNotice(err instanceof Error ? err.message : String(err), "error"); + }); + child.once("spawn", () => { + addNotice(`Opening ${path.basename(target)}…`, "info"); + }); + child.unref(); } catch (err) { addNotice(err instanceof Error ? err.message : String(err), "error"); } @@ -3019,7 +3033,7 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } && !pendingApproval && rightPane.kind !== "form" && !slashRows.length - && !key.ctrl + && key.ctrl && !key.meta && input === "h" ) { diff --git a/apps/desktop/pnpm-workspace.yaml b/apps/desktop/pnpm-workspace.yaml index ed30c53fb..6f6785f45 100644 --- a/apps/desktop/pnpm-workspace.yaml +++ b/apps/desktop/pnpm-workspace.yaml @@ -1,11 +1,11 @@ allowBuilds: - cpu-features: set this to true or false - electron: set this to true or false - electron-winstaller: set this to true or false - esbuild: set this to true or false - node-pty: set this to true or false - onnxruntime-node: set this to true or false - protobufjs: set this to true or false - sharp: set this to true or false - sqlite3: set this to true or false - ssh2: set this to true or false + cpu-features: true + electron: true + electron-winstaller: true + esbuild: true + node-pty: true + onnxruntime-node: true + protobufjs: true + sharp: true + sqlite3: true + ssh2: true diff --git a/apps/desktop/src/main/packagedRuntimeSmoke.ts b/apps/desktop/src/main/packagedRuntimeSmoke.ts index 4a6f75a92..0d21736da 100644 --- a/apps/desktop/src/main/packagedRuntimeSmoke.ts +++ b/apps/desktop/src/main/packagedRuntimeSmoke.ts @@ -114,6 +114,7 @@ async function main(): Promise { claudeExecutablePath: claudeExecutable.path, claudeExecutableSource: claudeExecutable.source, claudeStartup, + codexExecutable: typeof resolveCodexExecutable, codexExecutablePath: codexExecutable.path, codexExecutableSource: codexExecutable.source, ptyProbe, diff --git a/apps/desktop/src/main/services/chat/agentChatService.test.ts b/apps/desktop/src/main/services/chat/agentChatService.test.ts index 66b708066..f4eb47ff0 100644 --- a/apps/desktop/src/main/services/chat/agentChatService.test.ts +++ b/apps/desktop/src/main/services/chat/agentChatService.test.ts @@ -7128,12 +7128,14 @@ describe("createAgentChatService", () => { const initializeRequest = mockState.codexRequestPayloads.find((payload) => payload.method === "initialize"); const capabilities = (initializeRequest?.params as { capabilities?: { optOutNotificationMethods?: string[] } }) ?.capabilities; - expect(capabilities?.optOutNotificationMethods).toEqual([ + const expectedOptOut = [ "item/agentMessage/delta", "item/reasoning/summaryTextDelta", "item/reasoning/textDelta", "item/commandExecution/outputDelta", - ]); + ]; + expect(capabilities?.optOutNotificationMethods).toEqual(expect.arrayContaining(expectedOptOut)); + expect(capabilities?.optOutNotificationMethods).toHaveLength(expectedOptOut.length); }); it("sends an empty optOutNotificationMethods list when runtimeMode is undefined (default interactive)", async () => { diff --git a/apps/desktop/src/main/services/chat/agentChatService.ts b/apps/desktop/src/main/services/chat/agentChatService.ts index 977c9d6f2..0472dec48 100644 --- a/apps/desktop/src/main/services/chat/agentChatService.ts +++ b/apps/desktop/src/main/services/chat/agentChatService.ts @@ -8007,10 +8007,17 @@ export function createAgentChatService(args: { if (/^\/compact(?:\s|$)/i.test(slashText)) { runtime.manualCompactionPending = true; - await runtime.request("thread/compact/start", { - threadId: managed.session.threadId, - }); - completeInlineCodexSlash("Codex context compaction started."); + try { + await runtime.request("thread/compact/start", { + threadId: managed.session.threadId, + }); + completeInlineCodexSlash("Codex context compaction started."); + } catch (error) { + runtime.manualCompactionPending = false; + completeInlineCodexSlash( + `Codex context compaction failed: ${error instanceof Error ? error.message : String(error)}`, + ); + } return; } diff --git a/apps/desktop/src/main/services/chat/codexCliLauncher.test.ts b/apps/desktop/src/main/services/chat/codexCliLauncher.test.ts index 56d570ee8..28463297e 100644 --- a/apps/desktop/src/main/services/chat/codexCliLauncher.test.ts +++ b/apps/desktop/src/main/services/chat/codexCliLauncher.test.ts @@ -81,10 +81,11 @@ describe("codexCliLauncher", () => { }); describe("shellQuote", () => { - it("wraps in double quotes and escapes embedded quotes", () => { - expect(shellQuote("simple")).toBe("\"simple\""); - expect(shellQuote("with space")).toBe("\"with space\""); - expect(shellQuote("it's \"tricky\"")).toBe("\"it's \\\"tricky\\\"\""); + it("wraps in single quotes and escapes embedded quotes", () => { + expect(shellQuote("simple")).toBe("'simple'"); + expect(shellQuote("with space")).toBe("'with space'"); + expect(shellQuote("it's \"tricky\"")).toBe("'it'\\''s \"tricky\"'"); + expect(shellQuote("$(touch x)`boom`!")).toBe("'$(touch x)`boom`!'"); }); }); @@ -92,7 +93,7 @@ describe("codexCliLauncher", () => { it("uses osascript on darwin", () => { const spawnMock = mocked.__spawnMock; spawnMock.mockReset(); - const fakeChild = { unref: vi.fn() }; + const fakeChild = { once: vi.fn(), unref: vi.fn() }; spawnMock.mockReturnValue(fakeChild as never); spawnInNewTerminalWindow({ @@ -117,7 +118,7 @@ describe("codexCliLauncher", () => { it("uses cmd /C start cmd /K on win32", () => { const spawnMock = mocked.__spawnMock; spawnMock.mockReset(); - const fakeChild = { unref: vi.fn() }; + const fakeChild = { once: vi.fn(), unref: vi.fn() }; spawnMock.mockReturnValue(fakeChild as never); spawnInNewTerminalWindow({ @@ -140,7 +141,7 @@ describe("codexCliLauncher", () => { it("falls through to gnome-terminal on linux", () => { const spawnMock = mocked.__spawnMock; spawnMock.mockReset(); - const fakeChild = { unref: vi.fn() }; + const fakeChild = { once: vi.fn(), unref: vi.fn() }; spawnMock.mockReturnValue(fakeChild as never); spawnInNewTerminalWindow({ @@ -148,10 +149,39 @@ describe("codexCliLauncher", () => { argv: ["resume", "abc-123"], cwd: "/tmp/lane", platform: "linux", + isExecutableOnPath: (binary) => binary === "gnome-terminal", }); const [bin] = spawnMock.mock.calls[0]!; expect(bin).toBe("gnome-terminal"); }); + + it("skips unavailable linux terminal candidates", () => { + const spawnMock = mocked.__spawnMock; + spawnMock.mockReset(); + const fakeChild = { once: vi.fn(), unref: vi.fn() }; + spawnMock.mockReturnValue(fakeChild as never); + + spawnInNewTerminalWindow({ + binary: "/usr/local/bin/codex", + argv: ["resume", "abc-123"], + cwd: "/tmp/lane", + platform: "linux", + isExecutableOnPath: (binary) => binary === "xterm", + }); + + const [bin] = spawnMock.mock.calls[0]!; + expect(bin).toBe("xterm"); + }); + + it("throws when no linux terminal candidate is available", () => { + expect(() => spawnInNewTerminalWindow({ + binary: "/usr/local/bin/codex", + argv: ["resume", "abc-123"], + cwd: "/tmp/lane", + platform: "linux", + isExecutableOnPath: () => false, + })).toThrow(/no supported terminal emulator/); + }); }); }); diff --git a/apps/desktop/src/main/services/chat/codexCliLauncher.ts b/apps/desktop/src/main/services/chat/codexCliLauncher.ts index 92ca0620d..5116a90de 100644 --- a/apps/desktop/src/main/services/chat/codexCliLauncher.ts +++ b/apps/desktop/src/main/services/chat/codexCliLauncher.ts @@ -1,4 +1,6 @@ import { execFile, spawn } from "node:child_process"; +import { accessSync, constants } from "node:fs"; +import path from "node:path"; function execFileAsync( binary: string, @@ -84,11 +86,15 @@ export function buildResumeArgv(strategy: CodexResumeStrategy, threadId: string) return strategy.flagForm.argv(threadId); } -/** Quote a single arg for an interactive shell command. Wraps in double-quotes - * and escapes embedded backslashes/double-quotes. Suitable for `cmd /K` on - * Windows and POSIX shells alike. */ +/** Quote a single arg for an interactive shell command. */ export function shellQuote(arg: string): string { - return `"${arg.replace(/\\/g, "\\\\").replace(/"/g, "\\\"")}"`; + return `'${arg.replace(/'/g, "'\\''")}'`; +} + +function cmdQuote(arg: string): string { + return `"${arg + .replace(/(["^&|<>])/g, "^$1") + .replace(/%/g, "%%")}"`; } export type SpawnNewTerminalOptions = { @@ -96,30 +102,59 @@ export type SpawnNewTerminalOptions = { argv: string[]; cwd: string; platform?: NodeJS.Platform; + isExecutableOnPath?: (binary: string) => boolean; }; +function isExecutableOnPath(binary: string): boolean { + if (binary.includes("/") || binary.includes("\\")) { + try { + accessSync(binary, constants.X_OK); + return true; + } catch { + return false; + } + } + + for (const dir of (process.env.PATH ?? "").split(path.delimiter)) { + if (!dir) continue; + try { + accessSync(path.join(dir, binary), constants.X_OK); + return true; + } catch { + // keep scanning PATH + } + } + return false; +} + +function spawnDetached(binary: string, args: string[], options: Parameters[2]): void { + const child = spawn(binary, args, options); + child.once("error", () => undefined); + child.unref(); +} + /** * Launch the user's default terminal with `codex ` running inside, cd'd * to `cwd`. Returns once the launcher process has been spawned (detached). */ export function spawnInNewTerminalWindow(options: SpawnNewTerminalOptions): void { const platform = options.platform ?? process.platform; - const command = [options.binary, ...options.argv].map(shellQuote).join(" "); - const cdCommand = `cd ${shellQuote(options.cwd)}`; + const quote = platform === "win32" ? cmdQuote : shellQuote; + const command = [options.binary, ...options.argv].map(quote).join(" "); + const cdCommand = platform === "win32" ? `cd /d ${quote(options.cwd)}` : `cd ${quote(options.cwd)}`; + const executableAvailable = options.isExecutableOnPath ?? isExecutableOnPath; if (platform === "darwin") { // Use osascript so we can set cwd cleanly and `do script` runs an interactive shell. const script = `tell application "Terminal" to do script "${cdCommand.replace(/"/g, "\\\"")} && ${command.replace(/"/g, "\\\"")}"`; - const child = spawn("osascript", ["-e", script], { detached: true, stdio: "ignore" }); - child.unref(); + spawnDetached("osascript", ["-e", script], { detached: true, stdio: "ignore" }); return; } if (platform === "win32") { // `start cmd /K " && "` opens a new console window that stays open after the command exits. const inner = `${cdCommand} && ${command}`; - const child = spawn("cmd.exe", ["/C", "start", "cmd", "/K", inner], { detached: true, stdio: "ignore", windowsHide: false }); - child.unref(); + spawnDetached("cmd.exe", ["/C", "start", "cmd", "/K", inner], { detached: true, stdio: "ignore", windowsHide: false }); return; } @@ -132,15 +167,14 @@ export function spawnInNewTerminalWindow(options: SpawnNewTerminalOptions): void ]; const innerScript = `${cdCommand} && ${command}; exec bash`; for (const candidate of candidates) { - try { - const child = spawn(candidate.bin, candidate.argv(innerScript), { detached: true, stdio: "ignore" }); - child.unref(); - return; - } catch { - // try next - } + if (!executableAvailable(candidate.bin)) continue; + spawnDetached(candidate.bin, candidate.argv(innerScript), { detached: true, stdio: "ignore" }); + return; } // Last resort: xdg-terminal (often a shim on modern desktops). - const child = spawn("xdg-terminal", [`${cdCommand} && ${command}`], { detached: true, stdio: "ignore" }); - child.unref(); + if (executableAvailable("xdg-terminal")) { + spawnDetached("xdg-terminal", [`${cdCommand} && ${command}`], { detached: true, stdio: "ignore" }); + return; + } + throw new Error("Failed to spawn terminal: no supported terminal emulator found"); } diff --git a/apps/desktop/src/main/services/ipc/registerIpc.ts b/apps/desktop/src/main/services/ipc/registerIpc.ts index 5f037a533..1554c7030 100644 --- a/apps/desktop/src/main/services/ipc/registerIpc.ts +++ b/apps/desktop/src/main/services/ipc/registerIpc.ts @@ -6518,9 +6518,14 @@ export function registerIpc({ }); ipcMain.handle(IPC.agentChatCodexOpenInCli, async ( - _event, + event, arg: AgentChatCodexOpenInCliArgs, ): Promise => { + assertTrustedAppControlSender(event, IPC.agentChatCodexOpenInCli); + if (arg?.mode === "new-window") { + assertAppControlRateLimit(event, IPC.agentChatCodexOpenInCli, { windowMs: 10_000, max: 10 }); + } + const ctx = getCtx(); const sessionId = typeof arg?.sessionId === "string" ? arg.sessionId.trim() : ""; const mode = arg?.mode === "new-window" ? "new-window" : "ade-terminal"; diff --git a/apps/desktop/src/renderer/browserMock.ts b/apps/desktop/src/renderer/browserMock.ts index a6b86e3eb..58f3990b0 100644 --- a/apps/desktop/src/renderer/browserMock.ts +++ b/apps/desktop/src/renderer/browserMock.ts @@ -4482,7 +4482,7 @@ if (typeof window !== "undefined" && shouldInstallBrowserMock(window)) { }), saveTempAttachment: resolvedArg({ path: "/tmp/browser-mock-attachment" }), codex: { - openInCli: async () => ({ + openInCli: async (_args: any) => ({ binary: "/usr/local/bin/codex", argv: [] as string[], cwd: "/tmp/browser-mock-lane", diff --git a/apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx b/apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx index 8d7660a26..263a82ebb 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx @@ -2313,8 +2313,7 @@ export function AgentChatComposer({ }; const handleDragOver = (event: React.DragEvent) => { - const hasImageUrl = event.dataTransfer.types.includes("text/uri-list") - || event.dataTransfer.types.includes("text/plain"); + const hasImageUrl = event.dataTransfer.types.includes("text/uri-list"); if (!canAttach || (!event.dataTransfer.files.length && !hasImageUrl)) return; event.preventDefault(); setDragActive(true); diff --git a/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx b/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx index 8c4a8e3b3..94a7e90ed 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx @@ -2107,21 +2107,32 @@ export function AgentChatPane({ return [...displayEvents.slice(0, insertAt), synthetic, ...displayEvents.slice(insertAt)]; }, [optimisticOutgoingMessage, presentation?.mode, presentation?.rewriteMissionControlTextTools, selectedEvents, selectedSession?.cursorCloudAgentId, selectedSession?.cursorPromotedTurnId, selectedSessionId]); const selectedCodexGoal = useMemo(() => { - let goal = selectedSession?.codexGoal ?? null; + let goalFromEvents: CodexThreadGoal | null = null; + let sawGoalEvent = false; for (const envelope of selectedEventsForDisplay) { const event = envelope.event; - if (event.type === "codex_goal_updated") goal = event.goal; - if (event.type === "codex_goal_cleared") goal = null; + if (event.type === "codex_goal_updated") { + goalFromEvents = event.goal; + sawGoalEvent = true; + } + if (event.type === "codex_goal_cleared") { + goalFromEvents = null; + sawGoalEvent = true; + } } - return goal; + return sawGoalEvent ? goalFromEvents : (selectedSession?.codexGoal ?? null); }, [selectedEventsForDisplay, selectedSession?.codexGoal]); const selectedCodexTokenUsage = useMemo(() => { - let usage = selectedSession?.codexTokenUsage ?? null; + let usageFromEvents: CodexThreadTokenUsage | null = null; + let sawUsageEvent = false; for (const envelope of selectedEventsForDisplay) { const event = envelope.event; - if (event.type === "codex_token_usage") usage = event.usage; + if (event.type === "codex_token_usage") { + usageFromEvents = event.usage; + sawUsageEvent = true; + } } - return usage; + return sawUsageEvent ? usageFromEvents : (selectedSession?.codexTokenUsage ?? null); }, [selectedEventsForDisplay, selectedSession?.codexTokenUsage]); const selectedSubagentSnapshots = useMemo(() => deriveChatSubagentSnapshots(selectedEvents), [selectedEvents]); const selectedTurnDiffSummaries = useMemo(() => deriveTurnDiffSummaries(selectedEvents), [selectedEvents]); @@ -2129,6 +2140,27 @@ export function AgentChatPane({ const pendingInput = selectedSessionId ? (pendingInputsBySession[selectedSessionId]?.[0] ?? null) : null; const selectedSessionAwaitingInput = Boolean(pendingInput) || selectedSession?.awaitingInput === true; const turnActive = selectedSessionId ? (turnActiveBySession[selectedSessionId] ?? false) : false; + const sendCodexControlMessage = useCallback(async (sessionId: string, text: string) => { + try { + if (turnActiveBySession[sessionId]) { + await window.ade.agentChat.steer({ sessionId, text }); + return; + } + + try { + await window.ade.agentChat.send({ sessionId, text }); + } catch (sendError) { + const message = sendError instanceof Error ? sendError.message : String(sendError); + if (/turn is already active|already active/i.test(message)) { + await window.ade.agentChat.steer({ sessionId, text }); + return; + } + throw sendError; + } + } catch (controlError) { + setError(controlError instanceof Error ? controlError.message : String(controlError)); + } + }, [turnActiveBySession]); const persistParallelLaunchState = useCallback(async (state: AgentChatParallelLaunchState | null) => { if (!projectRoot || !laneId) return; @@ -6207,14 +6239,10 @@ export function AgentChatPane({ { - void window.ade.agentChat - .send({ sessionId: selectedSessionId, text: `/goal ${next}` }) - .catch(() => undefined); + void sendCodexControlMessage(selectedSessionId, `/goal ${next}`); }} onClear={() => { - void window.ade.agentChat - .send({ sessionId: selectedSessionId, text: "/goal clear" }) - .catch(() => undefined); + void sendCodexControlMessage(selectedSessionId, "/goal clear"); }} /> ) : null} diff --git a/apps/desktop/src/renderer/components/chat/codex/CodexGoalBanner.test.tsx b/apps/desktop/src/renderer/components/chat/codex/CodexGoalBanner.test.tsx index 4844d724b..4a544ec56 100644 --- a/apps/desktop/src/renderer/components/chat/codex/CodexGoalBanner.test.tsx +++ b/apps/desktop/src/renderer/components/chat/codex/CodexGoalBanner.test.tsx @@ -24,6 +24,7 @@ describe("CodexGoalBanner", () => { fireEvent.keyDown(input, { key: "Enter" }); expect(onEdit).toHaveBeenCalledWith("Refactor auth for compliance"); + expect(onEdit).toHaveBeenCalledTimes(1); }); it("invokes onClear when the clear button is clicked", () => { diff --git a/apps/desktop/src/renderer/components/chat/codex/CodexGoalBanner.tsx b/apps/desktop/src/renderer/components/chat/codex/CodexGoalBanner.tsx index fd124f9a8..d55968422 100644 --- a/apps/desktop/src/renderer/components/chat/codex/CodexGoalBanner.tsx +++ b/apps/desktop/src/renderer/components/chat/codex/CodexGoalBanner.tsx @@ -121,18 +121,22 @@ export function CodexGoalBanner({ goal, onEdit, onClear }: CodexGoalBannerProps) className="min-w-0 flex-1 rounded border border-amber-400/30 bg-amber-950/30 px-2 py-0.5 text-[length:calc(var(--chat-font-size)*12/14)] font-medium leading-tight text-amber-50 outline-none focus:border-amber-300/60" aria-label="Edit goal objective" /> - ) : ( + ) : onEdit ? ( + ) : ( + + {objective} + )} { diff --git a/apps/desktop/src/renderer/components/chat/codex/CodexOpenInCliButton.tsx b/apps/desktop/src/renderer/components/chat/codex/CodexOpenInCliButton.tsx index 4fd570664..45f6fd51e 100644 --- a/apps/desktop/src/renderer/components/chat/codex/CodexOpenInCliButton.tsx +++ b/apps/desktop/src/renderer/components/chat/codex/CodexOpenInCliButton.tsx @@ -50,6 +50,12 @@ export function CodexOpenInCliButton({ sessionId, onUseAdeTerminal }: CodexOpenI return () => window.clearTimeout(t); }, [toast]); + useEffect(() => { + if (!error) return; + const t = window.setTimeout(() => setError(null), 4000); + return () => window.clearTimeout(t); + }, [error]); + const handleNewWindow = useCallback(async () => { setOpen(false); setError(null); diff --git a/apps/desktop/src/shared/types/chat.test.ts b/apps/desktop/src/shared/types/chat.test.ts index 2909f2f62..717bec779 100644 --- a/apps/desktop/src/shared/types/chat.test.ts +++ b/apps/desktop/src/shared/types/chat.test.ts @@ -109,7 +109,7 @@ describe("mergeAttachments", () => { expect(result.map((a) => a.path)).toEqual(["/first.ts", "/second.ts", "/third.ts"]); }); - it("deduplicates image URL attachments by URL path", () => { + it("deduplicates image-url attachments by path field", () => { const current: AgentChatFileRef[] = [ { path: "https://example.com/old.png", type: "image-url", url: "https://example.com/old.png" }, ]; diff --git a/plans/ade-32-codex-followup.md b/plans/ade-32-codex-followup.md index f0d093ddd..1fff56a79 100644 --- a/plans/ade-32-codex-followup.md +++ b/plans/ade-32-codex-followup.md @@ -48,7 +48,7 @@ Update the test that codifies the regression: `agentChatService.test.ts:2125-213 Today the `Plan` item is only normalized on `eventKind === "completed"`. Plan §5.1 says emit a `plan` event with `state: "active"` on `item/started` too, so the renderer's plan card mounts immediately. Fix: add an `item/started` branch that emits `{ type: "plan", state: "active", explanation: null, steps: [], streamingText: "" }`. -Also align state literals to plan-spec `"active" | "complete"` (currently `"started" | "updated" | "completed"`). Pick one set; update `chat.ts` union and renderers. +Also align state literals. The implemented union in `chat.ts` uses `"active" | "delta" | "updated" | "complete"` to distinguish streaming deltas from structured updates; keep renderers exhaustive across all four states, or narrow the union only if Codex no longer needs those intermediate states. ### A.3 Distinct `codex_context_compaction` variant **Files**: `apps/desktop/src/shared/types/chat.ts`, `agentChatService.ts:10579-10585` diff --git a/plans/ade-32-codex-v130-chat-parity.md b/plans/ade-32-codex-v130-chat-parity.md index 1aee065ce..f2a087a50 100644 --- a/plans/ade-32-codex-v130-chat-parity.md +++ b/plans/ade-32-codex-v130-chat-parity.md @@ -170,7 +170,7 @@ This is the biggest UX piece. Codex CLI's `/resume` UX is what we're matching. Methods we'll add: -- `thread/list { searchTerm?, cursor?, sortKey?, sortDirection?, cwd?, sourceKinds?, archived?, modelProviders? }` returns `{ items: [...], nextCursor, backwardsCursor }`. Items include name, lastActivityAt, turn count, cwd, archived. +- `thread/list { searchTerm?, cursor?, limit?, sortKey?, sortDirection?, cwd?, sourceKinds?, archived?, modelProviders? }` returns `{ items: [...], nextCursor, backwardsCursor }`. Items include name, lastActivityAt, turn count, cwd, archived. - `thread/read { threadId, includeTurns: boolean }` returns metadata + (optionally) all turns. Used for read-only preview before resuming. - `thread/fork { threadId, ephemeral?: boolean }` — branch into new thread. - `thread/unarchive { threadId }` — restore. diff --git a/scripts/dev-code.mjs b/scripts/dev-code.mjs index f365d5fb4..1123b7477 100644 --- a/scripts/dev-code.mjs +++ b/scripts/dev-code.mjs @@ -63,7 +63,9 @@ function parseArgs(argv) { continue; } if (arg.startsWith("--project-root=")) { - options.projectRoot = arg.slice("--project-root=".length); + const value = arg.slice("--project-root=".length).trim(); + if (!value) throw new Error("--project-root requires a path."); + options.projectRoot = value; continue; } if (arg === "--workspace-root") { @@ -72,7 +74,9 @@ function parseArgs(argv) { continue; } if (arg.startsWith("--workspace-root=")) { - options.workspaceRoot = arg.slice("--workspace-root=".length); + const value = arg.slice("--workspace-root=".length).trim(); + if (!value) throw new Error("--workspace-root requires a path."); + options.workspaceRoot = value; continue; } if (arg === "--socket") { From ee7e5c3c35b2b53f798583662f7c05b5d4d698ff Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Tue, 12 May 2026 06:19:04 -0400 Subject: [PATCH 03/18] fix: close codex slash failures --- .../services/chat/agentChatService.test.ts | 94 +++++++++++++++++++ .../main/services/chat/agentChatService.ts | 53 +++++++---- 2 files changed, 131 insertions(+), 16 deletions(-) diff --git a/apps/desktop/src/main/services/chat/agentChatService.test.ts b/apps/desktop/src/main/services/chat/agentChatService.test.ts index f4eb47ff0..ace890988 100644 --- a/apps/desktop/src/main/services/chat/agentChatService.test.ts +++ b/apps/desktop/src/main/services/chat/agentChatService.test.ts @@ -6863,6 +6863,53 @@ describe("createAgentChatService", () => { expect(mockState.codexRequestPayloads.some((payload) => payload.method === "turn/start")).toBe(false); }); + it("completes Codex /goal slash commands when the app-server RPC fails", async () => { + mockState.delayedCodexMethods.add("thread/goal/set"); + const events: AgentChatEventEnvelope[] = []; + const { service } = createService({ + onEvent: (event: AgentChatEventEnvelope) => { + events.push(event); + }, + }); + const session = await service.createSession({ + laneId: "lane-1", + provider: "codex", + model: "gpt-5.5", + }); + + const sendPromise = service.sendMessage({ + sessionId: session.id, + text: "/goal budget 5000", + }, { awaitDispatch: true }); + + await vi.waitFor(() => { + expect(mockState.codexRequestPayloads.some((payload) => payload.method === "thread/goal/set")).toBe(true); + }); + const goalRequest = mockState.codexRequestPayloads.find((payload) => payload.method === "thread/goal/set"); + expect(goalRequest?.id).toBeTruthy(); + + mockState.emitCodexPayload({ + jsonrpc: "2.0", + id: goalRequest?.id, + error: { code: -32001, message: "goal RPC failed" }, + }); + await sendPromise; + + expect(mockState.codexRequestPayloads.some((payload) => payload.method === "turn/start")).toBe(false); + expect(events.some((event) => + event.event.type === "system_notice" + && event.event.message === "Codex goal command failed: goal RPC failed" + )).toBe(true); + expect(events.some((event) => + event.event.type === "status" + && event.event.turnStatus === "completed" + )).toBe(true); + expect(events.some((event) => + event.event.type === "done" + && event.event.status === "completed" + )).toBe(true); + }); + it("routes Codex /inject to thread/inject_items and emits a notice", async () => { mockState.codexResponseOverrides.set("thread/inject_items", () => ({})); const onEvent = vi.fn(); @@ -6900,6 +6947,53 @@ describe("createAgentChatService", () => { expect(injectedNotice?.event.message).toContain("Remember this for the rest of the thread."); }); + it("completes Codex /inject when the app-server RPC fails", async () => { + mockState.delayedCodexMethods.add("thread/inject_items"); + const events: AgentChatEventEnvelope[] = []; + const { service } = createService({ + onEvent: (event: AgentChatEventEnvelope) => { + events.push(event); + }, + }); + const session = await service.createSession({ + laneId: "lane-1", + provider: "codex", + model: "gpt-5.5", + }); + + const sendPromise = service.sendMessage({ + sessionId: session.id, + text: "/inject Save this context.", + }, { awaitDispatch: true }); + + await vi.waitFor(() => { + expect(mockState.codexRequestPayloads.some((payload) => payload.method === "thread/inject_items")).toBe(true); + }); + const injectRequest = mockState.codexRequestPayloads.find((payload) => payload.method === "thread/inject_items"); + expect(injectRequest?.id).toBeTruthy(); + + mockState.emitCodexPayload({ + jsonrpc: "2.0", + id: injectRequest?.id, + error: { code: -32001, message: "inject RPC failed" }, + }); + await sendPromise; + + expect(mockState.codexRequestPayloads.some((payload) => payload.method === "turn/start")).toBe(false); + expect(events.some((event) => + event.event.type === "system_notice" + && event.event.message === "Codex context injection failed: inject RPC failed" + )).toBe(true); + expect(events.some((event) => + event.event.type === "status" + && event.event.turnStatus === "completed" + )).toBe(true); + expect(events.some((event) => + event.event.type === "done" + && event.event.status === "completed" + )).toBe(true); + }); + it("rejects /inject without context body", async () => { const { service } = createService(); const session = await service.createSession({ diff --git a/apps/desktop/src/main/services/chat/agentChatService.ts b/apps/desktop/src/main/services/chat/agentChatService.ts index 0472dec48..18a69909f 100644 --- a/apps/desktop/src/main/services/chat/agentChatService.ts +++ b/apps/desktop/src/main/services/chat/agentChatService.ts @@ -7948,6 +7948,21 @@ export function createAgentChatService(args: { }); persistChatState(managed); }; + const completeFailedInlineCodexSlash = (prefix: string, error: unknown) => { + completeInlineCodexSlash(`${prefix}: ${error instanceof Error ? error.message : String(error)}`); + }; + const requestInlineCodexSlash = async ( + method: string, + params: Record, + failurePrefix: string, + ): Promise<{ ok: true; result: T } | { ok: false }> => { + try { + return { ok: true, result: await runtime.request(method, params) }; + } catch (error) { + completeFailedInlineCodexSlash(failurePrefix, error); + return { ok: false }; + } + }; const slashText = args.promptText.trim(); let effectivePromptText = args.promptText; @@ -8032,7 +8047,7 @@ export function createAgentChatService(args: { // (ResponseItem::Message → `{ type: "message", role, content: [ContentItem::InputText] }`), // not a `{ type: "user_message", text }` shape. Mirror the wire shape used by // codex-rs/tui/src/app/side.rs::side_boundary_prompt_item. - await runtime.request("thread/inject_items", { + const injectResult = await requestInlineCodexSlash("thread/inject_items", { threadId: managed.session.threadId, items: [ { @@ -8041,7 +8056,8 @@ export function createAgentChatService(args: { content: [{ type: "input_text", text: trimmed }], }, ], - }); + }, "Codex context injection failed"); + if (!injectResult.ok) return; const firstLine = trimmed.split(/\r?\n/)[0] ?? trimmed; const preview = firstLine.length > 80 ? `${firstLine.slice(0, 77)}...` : firstLine; emitChatEvent(managed, { @@ -8056,10 +8072,11 @@ export function createAgentChatService(args: { if (/^\/goal(?:\s|$)/i.test(slashText)) { const goalArgs = slashText.replace(/^\/goal(?:\s+|$)/i, "").trim(); if (!goalArgs || /^show$/i.test(goalArgs) || /^status$/i.test(goalArgs)) { - const response = await runtime.request<{ goal?: unknown }>("thread/goal/get", { + const response = await requestInlineCodexSlash<{ goal?: unknown }>("thread/goal/get", { threadId: managed.session.threadId, - }); - const goal = normalizeCodexGoalPayload(response); + }, "Codex goal command failed"); + if (!response.ok) return; + const goal = normalizeCodexGoalPayload(response.result); managed.session.codexGoal = goal; emitChatEvent(managed, { type: "codex_goal_updated", @@ -8069,9 +8086,10 @@ export function createAgentChatService(args: { return; } if (/^(clear|reset|none)$/i.test(goalArgs)) { - await runtime.request("thread/goal/clear", { + const response = await requestInlineCodexSlash("thread/goal/clear", { threadId: managed.session.threadId, - }); + }, "Codex goal command failed"); + if (!response.ok) return; managed.session.codexGoal = null; emitChatEvent(managed, { type: "codex_goal_cleared" }); completeInlineCodexSlash("Codex goal cleared."); @@ -8086,11 +8104,12 @@ export function createAgentChatService(args: { if (statusMatch || pauseResumeMatch) { const rawStatus = (statusMatch?.[1] ?? pauseResumeMatch?.[1] ?? "active").toLowerCase(); const status = rawStatus === "pause" ? "paused" : rawStatus === "resume" ? "active" : rawStatus; - const response = await runtime.request<{ goal?: unknown }>("thread/goal/set", { + const response = await requestInlineCodexSlash<{ goal?: unknown }>("thread/goal/set", { threadId: managed.session.threadId, status, - }); - const goal = normalizeCodexGoalPayload(response); + }, "Codex goal command failed"); + if (!response.ok) return; + const goal = normalizeCodexGoalPayload(response.result); managed.session.codexGoal = goal; emitChatEvent(managed, { type: "codex_goal_updated", @@ -8116,11 +8135,12 @@ export function createAgentChatService(args: { completeInlineCodexSlash("Usage: /goal budget |clear."); return; } - const response = await runtime.request<{ goal?: unknown }>("thread/goal/set", { + const response = await requestInlineCodexSlash<{ goal?: unknown }>("thread/goal/set", { threadId: managed.session.threadId, tokenBudget, - }); - const goal = normalizeCodexGoalPayload(response); + }, "Codex goal command failed"); + if (!response.ok) return; + const goal = normalizeCodexGoalPayload(response.result); managed.session.codexGoal = goal; emitChatEvent(managed, { type: "codex_goal_updated", @@ -8134,11 +8154,12 @@ export function createAgentChatService(args: { completeInlineCodexSlash("No Codex goal text was provided."); return; } - const response = await runtime.request<{ goal?: unknown }>("thread/goal/set", { + const response = await requestInlineCodexSlash<{ goal?: unknown }>("thread/goal/set", { threadId: managed.session.threadId, objective, - }); - const goal = normalizeCodexGoalPayload(response); + }, "Codex goal command failed"); + if (!response.ok) return; + const goal = normalizeCodexGoalPayload(response.result); managed.session.codexGoal = goal; emitChatEvent(managed, { type: "codex_goal_updated", From c176e635efa85d37305fe7582e5b3544499f8ede Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Tue, 12 May 2026 06:54:09 -0400 Subject: [PATCH 04/18] fix: clear composer drop state --- .../chat/AgentChatComposer.test.tsx | 23 +++++++++++++++++++ .../components/chat/AgentChatComposer.tsx | 2 +- 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/apps/desktop/src/renderer/components/chat/AgentChatComposer.test.tsx b/apps/desktop/src/renderer/components/chat/AgentChatComposer.test.tsx index 7939d26ae..85751ab56 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatComposer.test.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatComposer.test.tsx @@ -838,6 +838,29 @@ describe("AgentChatComposer", () => { } }); + it("clears the drop highlight when a URL drop is rejected", async () => { + const props = renderComposer({ + turnActive: false, + draft: "", + }); + const rejectedUrlDrop = { + files: [], + types: ["text/uri-list"], + getData: vi.fn((type: string) => ( + type === "text/uri-list" ? "https://example.com/page" : "" + )), + }; + const input = screen.getByPlaceholderText("Type to vibecode..."); + + fireEvent.dragOver(input, { dataTransfer: rejectedUrlDrop }); + expect(screen.getByText("Drop files to attach")).toBeTruthy(); + + fireEvent.drop(input, { dataTransfer: rejectedUrlDrop }); + + await waitFor(() => expect(screen.queryByText("Drop files to attach")).toBeNull()); + expect(props.onAddAttachment).not.toHaveBeenCalled(); + }); + it("hides native permission controls until a model is selected", () => { const props = buildComposerProps({ modelId: "", diff --git a/apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx b/apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx index ad868b992..7cc146dd6 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx @@ -2333,9 +2333,9 @@ export function AgentChatComposer({ }; const handleDrop = (event: React.DragEvent) => { + setDragActive(false); if (!canAttach || (!event.dataTransfer.files.length && !addImageUrlFromTransfer(event.dataTransfer))) return; event.preventDefault(); - setDragActive(false); if (event.dataTransfer.files.length) { void addFileAttachments(event.dataTransfer.files); } From 7714b8da3f5f56181c614e7c1f5b95bc163a5c6d Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Tue, 12 May 2026 07:20:37 -0400 Subject: [PATCH 05/18] fix: address token usage review --- apps/ade-cli/src/tuiClient/app.tsx | 2 +- .../services/chat/agentChatService.test.ts | 57 +++++++++++++++++++ .../main/services/chat/agentChatService.ts | 4 +- 3 files changed, 59 insertions(+), 4 deletions(-) diff --git a/apps/ade-cli/src/tuiClient/app.tsx b/apps/ade-cli/src/tuiClient/app.tsx index ddcd6a6ea..00894b366 100644 --- a/apps/ade-cli/src/tuiClient/app.tsx +++ b/apps/ade-cli/src/tuiClient/app.tsx @@ -1318,7 +1318,7 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } ? rightPane.fields[formFieldIndex] ?? rightPane.fields[0] ?? null : null; const statusLineRows = statusLineText ? Math.min(3, statusLineText.split(/\r?\n/).filter(Boolean).length || 1) : 0; - const statusRows = (streaming ? 1 : 0) + statusLineRows; + const statusRows = statusLineRows; const goalBannerRows = goalBannerText ? 1 : 0; const chatRowBudget = Math.max(4, rows - 12 - statusRows - goalBannerRows); const providerReadinessRows = useMemo( diff --git a/apps/desktop/src/main/services/chat/agentChatService.test.ts b/apps/desktop/src/main/services/chat/agentChatService.test.ts index 864bb4c04..d1bba81bd 100644 --- a/apps/desktop/src/main/services/chat/agentChatService.test.ts +++ b/apps/desktop/src/main/services/chat/agentChatService.test.ts @@ -6419,6 +6419,63 @@ describe("createAgentChatService", () => { expect(completedResults).toHaveLength(0); }); + it("does not add Codex cache breakdown tokens to derived totals", async () => { + const events: AgentChatEventEnvelope[] = []; + const { service } = createService({ + onEvent: (event: AgentChatEventEnvelope) => events.push(event), + }); + + const session = await service.createSession({ + laneId: "lane-1", + provider: "codex", + model: "gpt-5.4", + }); + + await service.sendMessage({ + sessionId: session.id, + text: "Track token usage.", + }, { awaitDispatch: true }); + await waitForEvent( + events, + (event): event is AgentChatEventEnvelope & { + event: Extract; + } => + event.event.type === "status" + && event.event.turnStatus === "started" + && event.event.turnId === "turn-1", + ); + + mockState.emitCodexPayload({ + jsonrpc: "2.0", + method: "thread/tokenUsage/updated", + params: { + threadId: "thread-1", + tokenUsage: { + total: { + inputTokens: 1_000, + outputTokens: 250, + cacheReadTokens: 700, + cacheWriteTokens: 50, + }, + }, + }, + }); + + const usageEvent = await waitForEvent( + events, + (event): event is AgentChatEventEnvelope & { + event: Extract; + } => event.event.type === "codex_token_usage", + ); + expect(usageEvent.event.usage.total).toEqual(expect.objectContaining({ + inputTokens: 1_000, + outputTokens: 250, + cacheReadTokens: 700, + cacheWriteTokens: 50, + totalTokens: 1_250, + })); + }); + it("switches the Claude SDK session into plan mode before a plan turn", async () => { const setPermissionMode = vi.fn().mockResolvedValue(undefined); const send = vi.fn().mockResolvedValue(undefined); diff --git a/apps/desktop/src/main/services/chat/agentChatService.ts b/apps/desktop/src/main/services/chat/agentChatService.ts index 22fabd124..473f072bc 100644 --- a/apps/desktop/src/main/services/chat/agentChatService.ts +++ b/apps/desktop/src/main/services/chat/agentChatService.ts @@ -1628,9 +1628,7 @@ function normalizeCodexTokenBreakdown(value: unknown): CodexTokenUsageBreakdown if (normalized.totalTokens == null) { const derivedTotal = (normalized.inputTokens ?? 0) - + (normalized.outputTokens ?? 0) - + (normalized.cacheReadTokens ?? 0) - + (normalized.cacheWriteTokens ?? 0); + + (normalized.outputTokens ?? 0); if (derivedTotal > 0) normalized.totalTokens = derivedTotal; } return Object.keys(normalized).length ? normalized : undefined; From ec32272737058ac6c2acbd575a88e54e15984d56 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Tue, 12 May 2026 07:31:34 -0400 Subject: [PATCH 06/18] fix: address follow-up review comments --- .../services/chat/agentChatService.test.ts | 4 ++++ .../main/services/chat/agentChatService.ts | 18 +++++++++----- .../services/chat/codexCliLauncher.test.ts | 15 ++++++++++++ .../main/services/chat/codexCliLauncher.ts | 12 +++------- .../chat/AgentChatComposer.test.tsx | 24 +++++++++++++++++++ .../components/chat/AgentChatComposer.tsx | 4 ++-- 6 files changed, 60 insertions(+), 17 deletions(-) diff --git a/apps/desktop/src/main/services/chat/agentChatService.test.ts b/apps/desktop/src/main/services/chat/agentChatService.test.ts index d1bba81bd..a08e0662e 100644 --- a/apps/desktop/src/main/services/chat/agentChatService.test.ts +++ b/apps/desktop/src/main/services/chat/agentChatService.test.ts @@ -7967,7 +7967,11 @@ describe("createAgentChatService", () => { const injectedNotice = onEvent.mock.calls .map((call) => call[0]) .find((env: any) => env?.event?.type === "system_notice" && typeof env.event.message === "string" && env.event.message.startsWith("[injected]")); + const completionNotice = onEvent.mock.calls + .map((call) => call[0]) + .find((env: any) => env?.event?.type === "system_notice" && env.event.message === "Context injected into Codex thread history."); expect(injectedNotice?.event.message).toContain("Remember this for the rest of the thread."); + expect(injectedNotice?.event.turnId).toBe(completionNotice?.event.turnId); }); it("completes Codex /inject when the app-server RPC fails", async () => { diff --git a/apps/desktop/src/main/services/chat/agentChatService.ts b/apps/desktop/src/main/services/chat/agentChatService.ts index 473f072bc..1815397a2 100644 --- a/apps/desktop/src/main/services/chat/agentChatService.ts +++ b/apps/desktop/src/main/services/chat/agentChatService.ts @@ -8055,11 +8055,15 @@ export function createAgentChatService(args: { return; } - const completeInlineCodexSlash = (message?: string) => { + const completeInlineCodexSlash = ( + message?: string, + emitBeforeComplete?: (turnId: string) => void, + ) => { const slashTurnId = randomUUID(); markDispatched(); persistDeliveredLaneDirectiveKey(managed, args.laneDirectiveKey); markSessionIdleWithFreshCache(managed); + emitBeforeComplete?.(slashTurnId); if (message) { emitChatEvent(managed, { type: "system_notice", @@ -8190,12 +8194,14 @@ export function createAgentChatService(args: { if (!injectResult.ok) return; const firstLine = trimmed.split(/\r?\n/)[0] ?? trimmed; const preview = firstLine.length > 80 ? `${firstLine.slice(0, 77)}...` : firstLine; - emitChatEvent(managed, { - type: "system_notice", - noticeKind: "info", - message: `[injected] ${preview}`, + completeInlineCodexSlash("Context injected into Codex thread history.", (turnId) => { + emitChatEvent(managed, { + type: "system_notice", + noticeKind: "info", + message: `[injected] ${preview}`, + turnId, + }); }); - completeInlineCodexSlash("Context injected into Codex thread history."); return; } diff --git a/apps/desktop/src/main/services/chat/codexCliLauncher.test.ts b/apps/desktop/src/main/services/chat/codexCliLauncher.test.ts index 28463297e..943829f37 100644 --- a/apps/desktop/src/main/services/chat/codexCliLauncher.test.ts +++ b/apps/desktop/src/main/services/chat/codexCliLauncher.test.ts @@ -64,6 +64,21 @@ describe("codexCliLauncher", () => { expect(buildResumeArgv(strategy, "abc-123")).toEqual(["--thread", "abc-123"]); }); + it("does not pick the resume subcommand from prose in help text", async () => { + stubExecFile([ + "Usage: codex [OPTIONS]", + "", + "Commands:", + " run Start a new session", + "", + "Use --thread to resume a previous conversation.", + " --thread Resume a specific thread", + ].join("\n")); + const strategy = await detectCodexResumeStrategy("/usr/local/bin/codex"); + expect(strategy.flagForm.kind).toBe("long-flag"); + expect(buildResumeArgv(strategy, "abc-123")).toEqual(["--thread", "abc-123"]); + }); + it("falls back to interactive launch + clipboard when neither form exists", async () => { stubExecFile("Usage: codex [OPTIONS]\nNo resume support"); const strategy = await detectCodexResumeStrategy("/usr/local/bin/codex"); diff --git a/apps/desktop/src/main/services/chat/codexCliLauncher.ts b/apps/desktop/src/main/services/chat/codexCliLauncher.ts index 5116a90de..e5c16b3db 100644 --- a/apps/desktop/src/main/services/chat/codexCliLauncher.ts +++ b/apps/desktop/src/main/services/chat/codexCliLauncher.ts @@ -52,15 +52,9 @@ export async function detectCodexResumeStrategy(binary: string): Promise re.test(lower))) { + // Prefer the explicit `resume` subcommand (post-0.130 form). Match command + // table entries only (e.g. " resume Resume a thread"), not prose. + if (/^\s{2,}resume(?:\s{2,}|\t|$)/m.test(lower)) { return { binary, flagForm: { kind: "subcommand", argv: (id) => ["resume", id] }, diff --git a/apps/desktop/src/renderer/components/chat/AgentChatComposer.test.tsx b/apps/desktop/src/renderer/components/chat/AgentChatComposer.test.tsx index 85751ab56..e8e3e73d7 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatComposer.test.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatComposer.test.tsx @@ -861,6 +861,30 @@ describe("AgentChatComposer", () => { expect(props.onAddAttachment).not.toHaveBeenCalled(); }); + it("does not attach URLs whose image extension appears only in query text", () => { + const props = renderComposer({ + turnActive: false, + draft: "", + }); + const clipboardData = { + files: [], + items: [], + getData: vi.fn((type: string) => ( + type === "text/uri-list" || type === "text/plain" + ? "https://example.com/api/asset?file=hero.png" + : "" + )), + }; + + const pasteAllowed = fireEvent.paste(screen.getByPlaceholderText("Type to vibecode..."), { + clipboardData, + }); + + expect(pasteAllowed).toBe(true); + expect(props.onAddAttachment).not.toHaveBeenCalled(); + expect(screen.queryByText("Image URL attached")).toBeNull(); + }); + it("hides native permission controls until a model is selected", () => { const props = buildComposerProps({ modelId: "", diff --git a/apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx b/apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx index 7cc146dd6..40a907196 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx @@ -56,7 +56,7 @@ const CLIPBOARD_IMAGE_PASTE_FALLBACK_DELAY_MS = 80; const ISSUE_CONTEXT_MENU_WIDTH = 256; const ISSUE_CONTEXT_MENU_GAP = 8; const ISSUE_CONTEXT_MENU_VIEWPORT_GUTTER = 8; -const IMAGE_URL_EXTENSION_RE = /\.(png|jpe?g|gif|webp|bmp|svg|ico|tiff?)(?:$|[?#])/i; +const IMAGE_URL_EXTENSION_RE = /\.(png|jpe?g|gif|webp|bmp|svg|ico|tiff?)$/i; type PasteShortcutEvent = { key: string; @@ -95,7 +95,7 @@ function normalizeImageAttachmentUrl(value: string | null | undefined): string | try { const parsed = new URL(raw); if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return null; - if (!IMAGE_URL_EXTENSION_RE.test(`${parsed.pathname}${parsed.search}${parsed.hash}`)) return null; + if (!IMAGE_URL_EXTENSION_RE.test(parsed.pathname)) return null; return parsed.toString(); } catch { return null; From 3c2889006c400a283d47defd434afea6047e2064 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Tue, 12 May 2026 07:54:27 -0400 Subject: [PATCH 07/18] fix: address post-merge review feedback --- .../tuiClient/__tests__/imageTargets.test.ts | 30 +++++++++++++++ apps/ade-cli/src/tuiClient/app.tsx | 38 +++++++++---------- apps/ade-cli/src/tuiClient/imageTargets.ts | 25 ++++++++++++ .../services/chat/agentChatService.test.ts | 21 ++++++++++ .../components/chat/AgentChatPane.tsx | 2 +- 5 files changed, 94 insertions(+), 22 deletions(-) create mode 100644 apps/ade-cli/src/tuiClient/__tests__/imageTargets.test.ts create mode 100644 apps/ade-cli/src/tuiClient/imageTargets.ts diff --git a/apps/ade-cli/src/tuiClient/__tests__/imageTargets.test.ts b/apps/ade-cli/src/tuiClient/__tests__/imageTargets.test.ts new file mode 100644 index 000000000..88fefc0a5 --- /dev/null +++ b/apps/ade-cli/src/tuiClient/__tests__/imageTargets.test.ts @@ -0,0 +1,30 @@ +import path from "node:path"; +import { describe, expect, it } from "vitest"; +import { normalizeOpenableImageTarget } from "../imageTargets"; + +describe("normalizeOpenableImageTarget", () => { + it("allows http and https URLs", () => { + expect(normalizeOpenableImageTarget("https://example.test/image")).toBe( + "https://example.test/image", + ); + expect(normalizeOpenableImageTarget("http://example.test/image.png?sig=1")).toBe( + "http://example.test/image.png?sig=1", + ); + }); + + it("allows absolute image file paths", () => { + const target = path.resolve("proof.PNG"); + expect(normalizeOpenableImageTarget(target)).toBe(target); + }); + + it("rejects data URLs, file URLs, relative paths, and executable names", () => { + expect(normalizeOpenableImageTarget("data:image/png;base64,AAAA")).toBeNull(); + expect(normalizeOpenableImageTarget("file:///tmp/proof.png")).toBeNull(); + expect(normalizeOpenableImageTarget("proof.png")).toBeNull(); + expect(normalizeOpenableImageTarget("calc.exe")).toBeNull(); + }); + + it("rejects absolute non-image file paths", () => { + expect(normalizeOpenableImageTarget(path.resolve("notes.txt"))).toBeNull(); + }); +}); diff --git a/apps/ade-cli/src/tuiClient/app.tsx b/apps/ade-cli/src/tuiClient/app.tsx index 00894b366..b55a8f954 100644 --- a/apps/ade-cli/src/tuiClient/app.tsx +++ b/apps/ade-cli/src/tuiClient/app.tsx @@ -77,6 +77,7 @@ import { chooseInitialLane } from "./project"; import { resolveDrawerChatSelection } from "./drawerSelection"; import { latestExpandableFailureId, renderObject, summarizeDiffChanges } from "./format"; import { startTuiHeartbeat, type TuiHeartbeat } from "./heartbeat"; +import { isImageFilePath, normalizeOpenableImageTarget } from "./imageTargets"; import { loadAdeCodeState, saveAdeCodeState } from "./state"; import { buildLinearToolRequest } from "./linearCommands"; import { buildPendingInputAnswers, latestPendingApproval } from "./pendingInput"; @@ -579,10 +580,6 @@ function readClaudeVimMode(workspaceRoot: string): boolean { return enabled; } -function isImageFilePath(filePath: string): boolean { - return /\.(png|jpe?g|gif|webp|bmp|svg|ico|tiff?)$/i.test(filePath); -} - function commandAvailable(command: string): boolean { const result = spawnSync(process.platform === "win32" ? "where" : "command", process.platform === "win32" ? [command] : ["-v", command], { shell: process.platform !== "win32", @@ -2965,44 +2962,42 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } const openLatestImage = useCallback(() => { let target: string | null = null; + const acceptTarget = (candidate: string) => { + const normalized = normalizeOpenableImageTarget(candidate); + if (!normalized) return false; + target = normalized; + return true; + }; for (let index = events.length - 1; index >= 0; index -= 1) { const event = events[index]?.event as Record | undefined; if (!event) continue; if (event.type === "codex_image_generation") { const candidate = (event as { result?: unknown }).result; - if (typeof candidate === "string" && candidate && !/^data:/i.test(candidate)) { - target = candidate; - break; - } + if (typeof candidate === "string" && acceptTarget(candidate)) break; } if (event.type === "codex_image_view") { const local = (event as { path?: unknown }).path; const remote = (event as { url?: unknown }).url; - if (typeof local === "string" && local) { - target = local; - break; - } - if (typeof remote === "string" && remote && !/^data:/i.test(remote)) { - target = remote; - break; - } + if (typeof local === "string" && acceptTarget(local)) break; + if (typeof remote === "string" && acceptTarget(remote)) break; } } if (!target) { addNotice("No image to open in the recent history.", "info"); return; } + const openTarget = target; try { const child = process.platform === "darwin" - ? spawn("open", [target], { stdio: "ignore", detached: true }) + ? spawn("open", [openTarget], { stdio: "ignore", detached: true }) : process.platform === "win32" - ? spawn("cmd", ["/c", "start", "", target], { stdio: "ignore", detached: true }) - : spawn("xdg-open", [target], { stdio: "ignore", detached: true }); + ? spawn("rundll32.exe", ["url.dll,FileProtocolHandler", openTarget], { stdio: "ignore", detached: true }) + : spawn("xdg-open", [openTarget], { stdio: "ignore", detached: true }); child.once("error", (err) => { addNotice(err instanceof Error ? err.message : String(err), "error"); }); child.once("spawn", () => { - addNotice(`Opening ${path.basename(target)}…`, "info"); + addNotice(`Opening ${path.basename(openTarget)}…`, "info"); }); child.unref(); } catch (err) { @@ -4248,6 +4243,7 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } {goalBannerText ? ( {goalBannerText} + {streaming ? {" · streaming"} : null} ) : null} @@ -4302,7 +4298,7 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } {prompt} - {streaming ? {" · streaming"} : null} + {streaming && !goalBannerText ? {" · streaming"} : null} { expect(mockState.codexRequestPayloads.some((payload) => payload.method === "turn/start")).toBe(false); }); + it("treats /goal set reserved words as objective text", async () => { + const { service } = createService(); + const session = await service.createSession({ + laneId: "lane-1", + provider: "codex", + model: "gpt-5.5", + }); + + await service.sendMessage({ + sessionId: session.id, + text: "/goal set clear", + }, { awaitDispatch: true }); + + expect(mockState.codexRequestPayloads.find((payload) => payload.method === "thread/goal/set")?.params).toMatchObject({ + threadId: expect.any(String), + objective: "clear", + }); + expect(mockState.codexRequestPayloads.some((payload) => payload.method === "thread/goal/clear")).toBe(false); + expect(mockState.codexRequestPayloads.some((payload) => payload.method === "turn/start")).toBe(false); + }); + it("completes Codex /goal slash commands when the app-server RPC fails", async () => { mockState.delayedCodexMethods.add("thread/goal/set"); const events: AgentChatEventEnvelope[] = []; diff --git a/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx b/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx index e622c4223..b4de6479f 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx @@ -6466,7 +6466,7 @@ export function AgentChatPane({ { - void sendCodexControlMessage(selectedSessionId, `/goal ${next}`); + void sendCodexControlMessage(selectedSessionId, `/goal set ${next}`); }} onClear={() => { void sendCodexControlMessage(selectedSessionId, "/goal clear"); From fb71d18bf1d9ce2e78f30c8ed98e137bae307af7 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Tue, 12 May 2026 08:13:01 -0400 Subject: [PATCH 08/18] fix: close follow-up review items --- .../src/tuiClient/__tests__/commands.test.ts | 18 +++++++++++++++--- apps/ade-cli/src/tuiClient/adeApi.ts | 4 ---- apps/ade-cli/src/tuiClient/commands.ts | 6 ++---- .../components/chat/AgentChatComposer.test.tsx | 8 +++++++- .../components/chat/AgentChatComposer.tsx | 7 +++++-- 5 files changed, 29 insertions(+), 14 deletions(-) diff --git a/apps/ade-cli/src/tuiClient/__tests__/commands.test.ts b/apps/ade-cli/src/tuiClient/__tests__/commands.test.ts index 321130830..c0c92e0b4 100644 --- a/apps/ade-cli/src/tuiClient/__tests__/commands.test.ts +++ b/apps/ade-cli/src/tuiClient/__tests__/commands.test.ts @@ -84,7 +84,7 @@ describe("commands", () => { expect(rows).toContainEqual(expect.objectContaining({ name: "/compact", source: "ade", - description: "Compact Claude context through the active SDK session", + description: "Compact the active chat context", })); }); @@ -109,14 +109,26 @@ describe("commands", () => { } expect(rows).toContainEqual(expect.objectContaining({ name: "/compact", - description: "Compact the Codex thread context", + description: "Compact the active chat context", })); expect(rows).toContainEqual(expect.objectContaining({ name: "/goal", - description: "Set, clear, or inspect the Codex thread goal", + description: "Set, clear, or inspect the active chat goal", })); }); + it("shows one provider-gated row for shared Claude and Codex chat commands", () => { + const rows = paletteCommands("/"); + const compactRows = rows.filter((row) => row.name === "/compact"); + const goalRows = rows.filter((row) => row.name === "/goal"); + expect(compactRows).toEqual([ + expect.objectContaining({ description: "Compact the active chat context" }), + ]); + expect(goalRows).toEqual([ + expect.objectContaining({ description: "Set, clear, or inspect the active chat goal" }), + ]); + }); + it("filters Claude-only ADE commands outside Claude chats", () => { expect(paletteCommands("/context", [], { provider: "codex" })).not.toContainEqual( expect.objectContaining({ name: "/context" }), diff --git a/apps/ade-cli/src/tuiClient/adeApi.ts b/apps/ade-cli/src/tuiClient/adeApi.ts index d9eed5fd2..55f63d2fa 100644 --- a/apps/ade-cli/src/tuiClient/adeApi.ts +++ b/apps/ade-cli/src/tuiClient/adeApi.ts @@ -266,10 +266,6 @@ export async function interruptChat(connection: AdeCodeConnection, sessionId: st await connection.action("chat", "interrupt", { sessionId }); } -export async function resumeChat(connection: AdeCodeConnection, sessionId: string): Promise { - return await connection.action("chat", "resumeSession", { sessionId }); -} - export async function renameChat(connection: AdeCodeConnection, sessionId: string, title: string): Promise { return await connection.action("chat", "updateSession", { sessionId, diff --git a/apps/ade-cli/src/tuiClient/commands.ts b/apps/ade-cli/src/tuiClient/commands.ts index 5000ac41e..fe6ff835f 100644 --- a/apps/ade-cli/src/tuiClient/commands.ts +++ b/apps/ade-cli/src/tuiClient/commands.ts @@ -32,12 +32,12 @@ export const BUILTIN_COMMANDS: BuiltinCommand[] = [ { name: "/agents", description: "List Claude agents from user and project config", placement: "right", providers: ["claude"] }, { name: "/skills", description: "List Claude skills from user and project config", placement: "right", providers: ["claude"] }, { name: "/mcp", description: "Show Claude MCP server status", placement: "right", providers: ["claude"] }, - { name: "/compact", description: "Compact Claude context through the active SDK session", placement: "chat", argumentHint: "[instructions]", providers: ["claude"] }, + { name: "/compact", description: "Compact the active chat context", placement: "chat", argumentHint: "[instructions]", providers: ["claude", "codex"] }, { name: "/init", description: "Generate AGENTS.md and Claude pointer files", placement: "right", providers: ["claude"] }, { name: "/usage", description: "Show Claude usage through the active SDK session", placement: "chat", providers: ["claude"] }, { name: "/insights", description: "Generate Claude session insights through the active SDK session", placement: "chat", providers: ["claude"] }, { name: "/fast", description: "Toggle Claude fast mode through the active SDK session", placement: "chat", argumentHint: "[on|off]", providers: ["claude"] }, - { name: "/goal", description: "Set or show the Claude completion goal", placement: "chat", argumentHint: "[completion condition|clear]", providers: ["claude"] }, + { name: "/goal", description: "Set, clear, or inspect the active chat goal", placement: "chat", argumentHint: "[|clear|status active|paused|complete|budget |budget clear]", providers: ["claude", "codex"] }, { name: "/diff", description: "Show active lane diff", placement: "right" }, { name: "/log", description: "Show recent commits", placement: "right" }, { name: "/pr", description: "Show pull request state", placement: "right" }, @@ -59,8 +59,6 @@ export const BUILTIN_COMMANDS: BuiltinCommand[] = [ { name: "/forget", description: "Open memory management", placement: "right" }, { name: "/chats", description: "List chats in the active lane", placement: "right" }, { name: "/switch", description: "Switch lane or chat", placement: "right", argumentHint: "[lane|chat]" }, - { name: "/compact", description: "Compact the Codex thread context", placement: "chat", providers: ["codex"] }, - { name: "/goal", description: "Set, clear, or inspect the Codex thread goal", placement: "chat", argumentHint: "[|clear|status active|paused|budget |budget clear]", providers: ["codex"] }, { name: "/help", description: "Show keymap and command help", placement: "right" }, { name: "/keybindings", description: "Show Claude-compatible keybinding config diagnostics", placement: "right" }, { name: "/statusline", description: "Show Claude-compatible status line config", placement: "right" }, diff --git a/apps/desktop/src/renderer/components/chat/AgentChatComposer.test.tsx b/apps/desktop/src/renderer/components/chat/AgentChatComposer.test.tsx index 258df2b97..85c60fd18 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatComposer.test.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatComposer.test.tsx @@ -936,9 +936,15 @@ describe("AgentChatComposer", () => { fireEvent.dragOver(input, { dataTransfer: rejectedUrlDrop }); expect(screen.getByText("Drop files to attach")).toBeTruthy(); - fireEvent.drop(input, { dataTransfer: rejectedUrlDrop }); + const dropEvent = new Event("drop", { bubbles: true, cancelable: true }); + Object.defineProperty(dropEvent, "dataTransfer", { + configurable: true, + value: rejectedUrlDrop, + }); + fireEvent(input, dropEvent); await waitFor(() => expect(screen.queryByText("Drop files to attach")).toBeNull()); + expect(dropEvent.defaultPrevented).toBe(true); expect(props.onAddAttachment).not.toHaveBeenCalled(); }); diff --git a/apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx b/apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx index 40a907196..d6e7f5102 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx @@ -2334,9 +2334,12 @@ export function AgentChatComposer({ const handleDrop = (event: React.DragEvent) => { setDragActive(false); - if (!canAttach || (!event.dataTransfer.files.length && !addImageUrlFromTransfer(event.dataTransfer))) return; + const hasFiles = event.dataTransfer.files.length > 0; + const hasUriList = event.dataTransfer.types.includes("text/uri-list"); + if (!canAttach || (!hasFiles && !hasUriList)) return; event.preventDefault(); - if (event.dataTransfer.files.length) { + if (!hasFiles && !addImageUrlFromTransfer(event.dataTransfer)) return; + if (hasFiles) { void addFileAttachments(event.dataTransfer.files); } }; From 7c078f252802be8445dd8e7bd9f6910792f85455 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Tue, 12 May 2026 08:24:51 -0400 Subject: [PATCH 09/18] fix: handle goal clear and delegation display --- .../src/tuiClient/__tests__/format.test.ts | 7 ++++++ apps/ade-cli/src/tuiClient/format.ts | 2 +- .../chat/codex/CodexGoalBanner.test.tsx | 22 +++++++++++++++++++ .../components/chat/codex/CodexGoalBanner.tsx | 13 +++++++++-- 4 files changed, 41 insertions(+), 3 deletions(-) diff --git a/apps/ade-cli/src/tuiClient/__tests__/format.test.ts b/apps/ade-cli/src/tuiClient/__tests__/format.test.ts index cd1c1f982..9880e83b0 100644 --- a/apps/ade-cli/src/tuiClient/__tests__/format.test.ts +++ b/apps/ade-cli/src/tuiClient/__tests__/format.test.ts @@ -403,6 +403,12 @@ describe("renderChatLines", () => { contract: { status: "active", workerIntent: "implement" } as never, } as never, }, + { + sessionId: "s1", + timestamp: "2026-01-01T12:00:08.000Z", + sequence: 9, + event: { type: "delegation_state" } as never, + }, ], }); @@ -416,6 +422,7 @@ describe("renderChatLines", () => { expect(body).toMatch(/\[auto-approval\] started/); expect(body).toContain("[tools] ran 4 tools"); expect(body).toContain("[delegation] handoff to worker-a"); + expect(body).toContain("[delegation] state"); }); it("suppresses pending_input_resolved and tokens events from the chat transcript", () => { diff --git a/apps/ade-cli/src/tuiClient/format.ts b/apps/ade-cli/src/tuiClient/format.ts index 2e8c16cc8..85c1a470f 100644 --- a/apps/ade-cli/src/tuiClient/format.ts +++ b/apps/ade-cli/src/tuiClient/format.ts @@ -525,7 +525,7 @@ export function renderChatLines(args: { continue; } if (event.type === "delegation_state") { - const label = event.message ?? event.contract.status ?? event.contract.workerIntent ?? "state"; + const label = event.message ?? event.contract?.status ?? event.contract?.workerIntent ?? "state"; lines.push({ id, tone: "notice", body: `[delegation] ${singleLine(label, 160)}` }); continue; } diff --git a/apps/desktop/src/renderer/components/chat/codex/CodexGoalBanner.test.tsx b/apps/desktop/src/renderer/components/chat/codex/CodexGoalBanner.test.tsx index 4a544ec56..9ef1bc749 100644 --- a/apps/desktop/src/renderer/components/chat/codex/CodexGoalBanner.test.tsx +++ b/apps/desktop/src/renderer/components/chat/codex/CodexGoalBanner.test.tsx @@ -41,6 +41,28 @@ describe("CodexGoalBanner", () => { expect(onClear).toHaveBeenCalledTimes(1); }); + it("does not submit an edit when clearing during edit mode", () => { + const onEdit = vi.fn(); + const onClear = vi.fn(); + render( + , + ); + + fireEvent.click(screen.getByText("Refactor auth")); + const input = screen.getByLabelText("Edit goal objective"); + fireEvent.change(input, { target: { value: "Temporary draft" } }); + const clearButton = screen.getByLabelText("Clear goal"); + fireEvent.mouseDown(clearButton); + fireEvent.click(clearButton); + + expect(onClear).toHaveBeenCalledTimes(1); + expect(onEdit).not.toHaveBeenCalled(); + }); + it("does not invoke onEdit when Escape cancels the edit", () => { const onEdit = vi.fn(); render( diff --git a/apps/desktop/src/renderer/components/chat/codex/CodexGoalBanner.tsx b/apps/desktop/src/renderer/components/chat/codex/CodexGoalBanner.tsx index d55968422..cd75ebbe0 100644 --- a/apps/desktop/src/renderer/components/chat/codex/CodexGoalBanner.tsx +++ b/apps/desktop/src/renderer/components/chat/codex/CodexGoalBanner.tsx @@ -91,6 +91,12 @@ export function CodexGoalBanner({ goal, onEdit, onClear }: CodexGoalBannerProps) setDraft(objective); }; + const clearGoal = () => { + setEditing(false); + setDraft(objective); + onClear?.(); + }; + return (
{"✎"} ) : null} - {onClear && !editing ? ( + {onClear ? (