diff --git a/apps/ade-cli/src/adeRpcServer.test.ts b/apps/ade-cli/src/adeRpcServer.test.ts index da71c31f9..94774ce80 100644 --- a/apps/ade-cli/src/adeRpcServer.test.ts +++ b/apps/ade-cli/src/adeRpcServer.test.ts @@ -2401,6 +2401,30 @@ describe("adeRpcServer", () => { expect(response.structuredContent.contextRef?.path).toBeNull(); }); + it("launches default Codex spawn_agent sessions with supported sandbox flags", async () => { + const fixture = createRuntime(); + const binDir = fs.mkdtempSync(path.join(os.tmpdir(), "ade-cli-spawn-codex-bin-")); + createFakePathExecutable(binDir, "codex"); + const handler = createAdeRpcRequestHandler({ runtime: fixture.runtime, serverVersion: "test" }); + + const response = await withEnv({ PATH: `${binDir}${path.delimiter}${process.env.PATH ?? ""}`, SHELL: "/bin/sh" }, async () => { + await initialize(handler, { role: "orchestrator" }); + return await callTool(handler, "spawn_agent", { + laneId: "lane-1", + provider: "codex", + permissionMode: "default", + prompt: "Check the Codex launch flags", + }); + }); + + expect(response?.isError).toBeUndefined(); + const createCall = fixture.runtime.ptyService.create.mock.calls[0]?.[0] as { args?: string[]; startupCommand?: string }; + expect(createCall.args).toEqual(expect.arrayContaining(["--sandbox", "workspace-write", "--ask-for-approval", "on-request"])); + expect(createCall.args).not.toContain("--full-auto"); + expect(createCall.startupCommand).toContain("--sandbox workspace-write --ask-for-approval on-request"); + expect(createCall.startupCommand).not.toContain("--full-auto"); + }); + it("routes start_cli_session through shared provider launch helpers", async () => { const fixture = createRuntime(); fixture.runtime.sessionService.get.mockReturnValue({ diff --git a/apps/ade-cli/src/adeRpcServer.ts b/apps/ade-cli/src/adeRpcServer.ts index 033dbb71e..ddff5710f 100644 --- a/apps/ade-cli/src/adeRpcServer.ts +++ b/apps/ade-cli/src/adeRpcServer.ts @@ -6905,8 +6905,8 @@ async function runTool(args: { commandArgs.push("--dangerously-bypass-approvals-and-sandbox"); commandPreviewParts.push("--dangerously-bypass-approvals-and-sandbox"); } else if (permissionMode === "default") { - commandArgs.push("--full-auto"); - commandPreviewParts.push("--full-auto"); + commandArgs.push("--sandbox", "workspace-write", "--ask-for-approval", "on-request"); + commandPreviewParts.push("--sandbox", "workspace-write", "--ask-for-approval", "on-request"); } else if (permissionMode === "config-toml") { // No explicit Codex permission flags; let the host config.toml decide. } else if (permissionMode === "plan") { diff --git a/apps/ade-cli/src/tuiClient/__tests__/ChatView.test.tsx b/apps/ade-cli/src/tuiClient/__tests__/ChatView.test.tsx index 4d3976b72..ec2614b25 100644 --- a/apps/ade-cli/src/tuiClient/__tests__/ChatView.test.tsx +++ b/apps/ade-cli/src/tuiClient/__tests__/ChatView.test.tsx @@ -4,8 +4,9 @@ import { render } from "ink-testing-library"; import { ChatView, computeChatScrollMaxOffset, + renderChatSelectableRowTexts, renderChatTranscriptPlainText, - selectedTextFromVisibleChatRows, + selectedTextFromChatRows, } from "../components/ChatView"; import { buildSubagentTranscriptEvents } from "../subagentPane"; import { @@ -59,12 +60,39 @@ function transcriptLines(frame: string): string[] { describe("ChatView", () => { it("copies only the selected chat row columns", () => { - expect(selectedTextFromVisibleChatRows( + expect(selectedTextFromChatRows( ["alpha bravo", "charlie delta", "echo"], { startRow: 0, startColumn: 6, endRow: 1, endColumn: 6 }, )).toBe("bravo\ncharlie"); }); + it("preserves selected leading and trailing whitespace", () => { + expect(selectedTextFromChatRows( + [" const value = 1; ", " return value; "], + { startRow: 0, startColumn: 0, endRow: 1, endColumn: 19 }, + )).toBe(" const value = 1; \n return value; "); + }); + + it("copies selected absolute transcript rows outside the visible viewport", () => { + const events = Array.from({ length: 12 }, (_, index): AgentChatEventEnvelope => ({ + sessionId: "s1", + timestamp: `2026-01-01T12:00:${String(index).padStart(2, "0")}.000Z`, + sequence: index + 1, + event: { type: "text", text: `selectable row ${index + 1}` }, + })); + const rows = renderChatSelectableRowTexts({ + events, + notices: [], + activeSession: session, + width: 80, + }); + + expect(rows.join("\n")).toContain("selectable row 1"); + expect(rows.join("\n")).toContain("selectable row 12"); + expect(selectedTextFromChatRows(rows, { startRow: 0, startColumn: 0, endRow: rows.length - 1, endColumn: 200 })) + .toContain("selectable row 12"); + }); + it("renders a bordered hero card with the ADE wordmark when the chat is empty", () => { const frame = renderEvents([]); // Hero card uses a bordered box @@ -99,7 +127,7 @@ describe("ChatView", () => { expect(frame).not.toContain("type to chat"); }); - it("shows a model working indicator while a turn is active before text arrives", () => { + it("shows an active-turn wait state before runtime events arrive", () => { const frame = renderEvents([ { sessionId: "s1", @@ -116,10 +144,59 @@ describe("ChatView", () => { ], { streaming: true, width: 80 }); expect(frame).toContain("check status"); - expect(frame).toContain("model working"); + expect(frame).toContain("active turn · waiting for runtime events"); }); - it("keeps the model working indicator visible while active text is streaming", () => { + it("shows the active-turn wait state after historical assistant output", () => { + const events: AgentChatEventEnvelope[] = [ + { + sessionId: "s1", + timestamp: "2026-01-01T12:00:00.000Z", + sequence: 1, + event: { type: "user_message", text: "first turn", turnId: "turn-1" }, + }, + { + sessionId: "s1", + timestamp: "2026-01-01T12:00:01.000Z", + sequence: 2, + event: { type: "text", text: "first answer", turnId: "turn-1" }, + }, + { + sessionId: "s1", + timestamp: "2026-01-01T12:00:02.000Z", + sequence: 3, + event: { type: "done", status: "completed", turnId: "turn-1" }, + }, + { + sessionId: "s1", + timestamp: "2026-01-01T12:00:03.000Z", + sequence: 4, + event: { type: "user_message", text: "second turn", turnId: "turn-2" }, + }, + { + sessionId: "s1", + timestamp: "2026-01-01T12:00:04.000Z", + sequence: 5, + event: { type: "status", turnStatus: "started", turnId: "turn-2" }, + }, + ]; + const frame = renderEvents(events, { streaming: true, width: 80 }); + const maxOffset = computeChatScrollMaxOffset({ + events, + notices: [], + activeSession: session, + streaming: true, + maxRows: 3, + width: 80, + }); + + expect(frame).toContain("first answer"); + expect(frame).toContain("second turn"); + expect(frame).toContain("active turn · waiting for runtime events"); + expect(maxOffset).toBeGreaterThan(0); + }); + + it("does not add a generic working indicator while active text is streaming", () => { const frame = renderEvents([ { sessionId: "s1", @@ -130,7 +207,8 @@ describe("ChatView", () => { ], { streaming: true, width: 80 }); expect(frame).toContain("I found the issue."); - expect(frame).toContain("model working"); + expect(frame).not.toContain("model working"); + expect(frame).not.toContain("waiting for runtime events"); }); it("shows interrupted state where the working indicator normally appears", () => { @@ -146,6 +224,7 @@ describe("ChatView", () => { expect(frame).toContain("stop this"); expect(frame).toContain("Interrupted · chat to continue"); expect(frame).not.toContain("model working"); + expect(frame).not.toContain("waiting for runtime events"); }); it("renders context compaction as an explicit active state", () => { @@ -160,6 +239,8 @@ describe("ChatView", () => { expect(frame).toContain("compacting context"); expect(frame).toContain("auto"); + expect(frame).not.toContain("model working"); + expect(frame).not.toContain("waiting for runtime events"); }); it("renders queued steer messages as staged instead of normal sent bubbles", () => { @@ -632,9 +713,9 @@ describe("ChatView", () => { .map((entry) => JSON.stringify(entry.event)) .join("\n"); const frame = renderEvents(transcriptEvents, { width: 100, maxRows: 40 }); - expect(frame).toContain("Viewing subagent: Explore renderer"); - expect(frame).toContain("Leave the agents pane to return to the main chat."); - expect(transcriptBody).toContain("Subagent started: Explore renderer"); + expect(frame).toContain("Viewing agent transcript."); + expect(frame).toContain("Select Main chat in Chat Info to return."); + expect(transcriptBody).toContain("Started."); expect(frame).toContain("read_file"); expect(frame).toContain("src/child.ts"); expect(transcriptBody).toContain("found the renderer path"); diff --git a/apps/ade-cli/src/tuiClient/__tests__/HeaderFooter.test.tsx b/apps/ade-cli/src/tuiClient/__tests__/HeaderFooter.test.tsx index 76b0c2809..add08bf69 100644 --- a/apps/ade-cli/src/tuiClient/__tests__/HeaderFooter.test.tsx +++ b/apps/ade-cli/src/tuiClient/__tests__/HeaderFooter.test.tsx @@ -94,7 +94,7 @@ describe("FooterControls", () => { expect(frame).toContain("full-auto"); }); - it("renders the resting hint strip with lanes/info/subagents/cmds/help", () => { + it("renders the resting hint strip with lanes/pane/chat-info/cmds/help", () => { const result = render( { expect(frame).toContain("^o"); expect(frame).toContain("lanes"); expect(frame).toContain("^p"); - expect(frame).toContain("info"); + expect(frame).toContain("pane"); expect(frame).toContain("^a"); - expect(frame).toContain("subagents"); + expect(frame).toContain("chat info"); + expect(frame).not.toContain("subagents"); expect(frame).toContain("cmds"); expect(frame).toContain("help"); }); @@ -187,7 +188,7 @@ describe("FooterControls", () => { expect(frame).toContain("acceptEdits"); }); - it("renders the subagents button when visible and counts agents", () => { + it("renders the chat info button when visible and counts agents", () => { const result = render( { ); const frame = stripAnsi(result.lastFrame() ?? ""); - expect(frame).toContain("2 subagents"); + expect(frame).toContain("chat info · 2"); }); it("renders the approval prompt hints when an approval is active", () => { diff --git a/apps/ade-cli/src/tuiClient/__tests__/RightPane.test.tsx b/apps/ade-cli/src/tuiClient/__tests__/RightPane.test.tsx index 8bc0e9e65..3676d31a4 100644 --- a/apps/ade-cli/src/tuiClient/__tests__/RightPane.test.tsx +++ b/apps/ade-cli/src/tuiClient/__tests__/RightPane.test.tsx @@ -8,131 +8,157 @@ function stripAnsi(text: string): string { return text.replace(/\[[0-?]*[ -/]*[@-~]/g, ""); } -describe("RightPane subagents", () => { - it("renders an agents process table with main, subagents, and teammates", () => { +import type { ChatInfoSnapshot } from "../types"; + +function chatInfo(overrides: Partial = {}): ChatInfoSnapshot { + return { + provider: "codex", + modelLabel: "gpt-5.5-high", + laneLabel: "fixing-cli-send-error", + contextPercent: 42, + tokenSummary: "+1.2k/340", + streaming: true, + goal: null, + plan: { + current: 1, + total: 2, + live: true, + steps: [ + { text: "Patch runtime bridge", status: "in_progress" }, + { text: "Verify desktop smoke", status: "pending" }, + ], + }, + snapshots: [], + inspectedSubagentId: null, + ...overrides, + }; +} + +describe("RightPane chat info", () => { + it("renders the model + lane header, plan, goal, and chats — but no errors section", () => { const result = render( , ); const frame = stripAnsi(result.lastFrame() ?? ""); - expect(frame).toContain("AGENTS · CLAUDE"); - expect(frame).toContain("Subagents · 1"); - expect(frame).toContain("Teammates · 1"); - expect(frame).toContain("Background · 0"); - expect(frame).toContain("main"); - expect(frame).toContain("research"); - expect(frame).toContain("TEAMMATES"); - expect(frame).toContain("mate-x"); - expect(frame).toContain("transcript follows"); + expect(frame).toContain("CHAT INFO · CODEX"); + expect(frame).toContain("gpt-5.5-high"); + expect(frame).toContain("lane"); + expect(frame).toContain("fixing-cli-send-error"); + expect(frame).toContain("PLAN"); + expect(frame).toContain("Patch runtime bridge"); + expect(frame).toContain("GOAL"); + expect(frame).toContain("Ship CLI parity"); + expect(frame).toContain("CHATS"); + expect(frame).toContain("delegated"); + expect(frame).toContain("↑↓ focus · ↵ swap · esc → main"); + expect(frame).not.toContain("Errors"); + expect(frame).not.toContain("Activity"); + expect(frame).not.toContain("tab · cycle"); }); - it("renders the empty-state copy when no subagents have run yet", () => { + it("hides the Goal section for providers that do not surface goal data", () => { const result = render( , ); const frame = stripAnsi(result.lastFrame() ?? ""); - expect(frame).toContain("No subagents yet."); + expect(frame).toContain("CHAT INFO · CLAUDE"); + expect(frame).toContain("PLAN"); + expect(frame).not.toContain("GOAL"); }); - it("renders a single tab + placeholder for Droid (no subagents in ACP)", () => { + it("shows the main row + a 'no subagents yet' hint when the roster is empty", () => { const result = render( , - ); - const frame = result.lastFrame() ?? ""; - - expect(frame).toContain("AGENTS · DROID"); - expect(frame).toContain("agentclientprotocol.com"); - // Droid does not show the Teammates tab. - expect(frame).not.toContain("Teammates"); - }); - - it("renders only the Subagents tab for Codex/Cursor/OpenCode", () => { - const result = render( - , ); const frame = stripAnsi(result.lastFrame() ?? ""); - expect(frame).toContain("AGENTS · CODEX"); - expect(frame).toContain("Subagents · 1"); - expect(frame).toContain("Teammates · 0"); - expect(frame).toContain("delegated"); + expect(frame).toContain("CHATS"); + expect(frame).toContain("main"); + expect(frame).toContain("viewing"); + expect(frame).toContain("no subagents yet"); }); - it("uses a spinning frame for running subagents at or below the cap", () => { + it("marks the focused subagent row with a rail and exposes its last tool as a hover preview", () => { const result = render( , ); const frame = stripAnsi(result.lastFrame() ?? ""); - expect(frame).toMatch(/[◐◓◑◒] 01/); + expect(frame).toContain("agent-01"); + expect(frame).toContain("edit src/lib/tui.ts"); }); - it("falls back to the static running glyph when more than twelve subagents are running", () => { - const snapshots = Array.from({ length: 13 }, (_, index) => ({ + it("scrolls the roster internally with overflow hints when more than the cap are live", () => { + const snapshots = Array.from({ length: 9 }, (_, index) => ({ id: `x${index + 1}`, - name: `agent-${index + 1}`, + name: `agent-${String(index + 1).padStart(2, "0")}`, kind: "subagent" as const, status: "running" as const, summary: "", })); const result = render( , ); const frame = stripAnsi(result.lastFrame() ?? ""); - expect(frame).toContain("● 01"); - expect(frame).not.toMatch(/[◐◓◑◒] 01/); + expect(frame).toMatch(/↑\s+\d+\s+earlier/); + expect(frame).toContain("agent-07"); }); }); diff --git a/apps/ade-cli/src/tuiClient/__tests__/aggregate.test.ts b/apps/ade-cli/src/tuiClient/__tests__/aggregate.test.ts index b7b609f51..192eb4f92 100644 --- a/apps/ade-cli/src/tuiClient/__tests__/aggregate.test.ts +++ b/apps/ade-cli/src/tuiClient/__tests__/aggregate.test.ts @@ -149,6 +149,22 @@ describe("aggregateChatBlocks typed groups", () => { expect(toolGroup!.entries.map((e) => e.itemId)).toEqual(["kept-1"]); }); + it("groups meaningful runtime activity while suppressing generic thinking heartbeats", () => { + const events: AgentChatEventEnvelope[] = [ + env("2026-01-01T12:00:00.000Z", { type: "activity", activity: "thinking", detail: "Thinking through the answer", turnId: "turn-1" }), + env("2026-01-01T12:00:01.000Z", { type: "activity", activity: "reading", detail: "apps/ade-cli/src/tuiClient/app.tsx", turnId: "turn-1" }), + env("2026-01-01T12:00:02.000Z", { type: "subagent_started", taskId: "agent-1", parentToolUseId: "spawn-1", description: "child launch spam", turnId: "turn-1" }), + ]; + + const blocks = aggregate(events); + const activity = blocks.find((b) => b.kind === "runtime-activity") as Extract | undefined; + + expect(activity).toBeDefined(); + expect(activity!.entries[0]).toMatchObject({ label: "reading", detail: "apps/ade-cli/src/tuiClient/app.tsx" }); + expect(activity!.entries[1]).toMatchObject({ label: "subagent started" }); + expect(activity!.entries[1]).not.toHaveProperty("detail"); + }); + it("marks tool-calls-group and files-changed-group as not-live without stamping turn duration", () => { const events: AgentChatEventEnvelope[] = [ env("2026-01-01T12:00:00.000Z", { type: "tool_call", tool: "read", args: {}, itemId: "t1", turnId: "turn-1" }), diff --git a/apps/ade-cli/src/tuiClient/__tests__/appInput.test.ts b/apps/ade-cli/src/tuiClient/__tests__/appInput.test.ts index 6594cc435..72bc12e3c 100644 --- a/apps/ade-cli/src/tuiClient/__tests__/appInput.test.ts +++ b/apps/ade-cli/src/tuiClient/__tests__/appInput.test.ts @@ -9,6 +9,12 @@ import { isPromptWordBackspace, isTerminalControlToggle, isTerminalMouseTrackingEnabled, + isChatTextSelectionRange, + isCtrlCCopyPlatform, + chatSelectionEdgeDirectionForMouseY, + chatSelectionFromAnchor, + chatSelectionPointFromVisibleRows, + moveChatSelectionFocusByRows, parseTerminalMouseInput, promptDisplayRows, resolveChatWrapWidth, @@ -79,6 +85,37 @@ describe("parseTerminalMouseInput", () => { }); }); + it("parses mouse modifier bits", () => { + expect(parseTerminalMouseInput("[<20;5;6M")).toEqual({ + kind: "click", + x: 5, + y: 6, + shift: true, + ctrl: true, + }); + expect(parseTerminalMouseInput("[<88;5;6M")).toEqual({ + kind: "wheel", + direction: "up", + x: 5, + y: 6, + alt: true, + ctrl: true, + }); + expect(parseTerminalMouseInput("[<52;7;8M")).toEqual({ + kind: "drag", + x: 7, + y: 8, + shift: true, + ctrl: true, + }); + expect(parseTerminalMouseInput("[<16;7;8m")).toEqual({ + kind: "release", + x: 7, + y: 8, + ctrl: true, + }); + }); + it("swallows batched SGR mouse events from fast scrolling", () => { expect(parseTerminalMouseInput("[<64;104;32M[<64;104;32M[<65;104;31M")).toEqual({ kind: "wheel", @@ -93,8 +130,104 @@ describe("parseTerminalMouseInput", () => { }); }); +describe("chat text selection helpers", () => { + it("resolves visible rows to absolute transcript rows", () => { + const rows = [ + { sourceRow: null, text: "↑ older messages" }, + { sourceRow: 42, text: "hello" }, + { sourceRow: 43, text: "world" }, + ]; + + expect(chatSelectionPointFromVisibleRows(rows, 1, 3, false)).toEqual({ row: 42, column: 3 }); + expect(chatSelectionPointFromVisibleRows(rows, 0, 2, false)).toBeNull(); + expect(chatSelectionPointFromVisibleRows(rows, 0, 2, true)).toEqual({ row: 42, column: 2 }); + }); + + it("moves an active selection focus within transcript bounds", () => { + expect(moveChatSelectionFocusByRows({ + startRow: 5, + startColumn: 1, + endRow: 5, + endColumn: 3, + active: true, + }, -10, 20, 0)).toMatchObject({ endRow: 0, endColumn: 0 }); + + expect(moveChatSelectionFocusByRows({ + startRow: 5, + startColumn: 1, + endRow: 18, + endColumn: 3, + active: true, + }, 10, 20, 7)).toMatchObject({ endRow: 19, endColumn: 7 }); + }); + + it("extends chat selection from a retained anchor", () => { + expect(chatSelectionFromAnchor( + { row: 4, column: 2 }, + { row: 9, column: 12 }, + true, + )).toEqual({ + startRow: 4, + startColumn: 2, + endRow: 9, + endColumn: 12, + active: true, + }); + }); + + it("starts selection autoscroll after leaving reachable transcript edge rows", () => { + expect(chatSelectionEdgeDirectionForMouseY({ + y: 1, + topRow: 2, + rowBudget: 8, + scrollOffsetRows: 1, + maxScrollOffsetRows: 4, + })).toBe("older"); + expect(chatSelectionEdgeDirectionForMouseY({ + y: 10, + topRow: 2, + rowBudget: 8, + scrollOffsetRows: 1, + maxScrollOffsetRows: 4, + })).toBe("newer"); + expect(chatSelectionEdgeDirectionForMouseY({ + y: 2, + topRow: 2, + rowBudget: 8, + scrollOffsetRows: 1, + maxScrollOffsetRows: 4, + })).toBeNull(); + expect(chatSelectionEdgeDirectionForMouseY({ + y: 9, + topRow: 2, + rowBudget: 8, + scrollOffsetRows: 1, + maxScrollOffsetRows: 4, + })).toBeNull(); + expect(chatSelectionEdgeDirectionForMouseY({ + y: 5, + topRow: 2, + rowBudget: 8, + scrollOffsetRows: 1, + maxScrollOffsetRows: 4, + })).toBeNull(); + }); + + it("detects non-collapsed chat selections", () => { + expect(isChatTextSelectionRange(null)).toBe(false); + expect(isChatTextSelectionRange({ startRow: 1, startColumn: 2, endRow: 1, endColumn: 2 })).toBe(false); + expect(isChatTextSelectionRange({ startRow: 1, startColumn: 2, endRow: 2, endColumn: 0 })).toBe(true); + }); + + it("only lets Windows use Ctrl+C as copy when chat text is selected", () => { + expect(isCtrlCCopyPlatform("win32")).toBe(true); + expect(isCtrlCCopyPlatform("darwin")).toBe(false); + expect(isCtrlCCopyPlatform("linux")).toBe(false); + }); +}); + describe("footer control ordering", () => { - it("puts agents first only when the active chat has subagent history", () => { + it("puts chat info first when that pane is available", () => { expect(footerControlsForAvailability(true)).toEqual(["agents", "drawer", "details"]); expect(footerControlsForAvailability(false)).toEqual(["drawer", "details"]); }); @@ -121,8 +254,8 @@ describe("terminal control toggle", () => { }); describe("pane width helpers", () => { - it("caps prose chat width but lets embedded terminals use the full center pane", () => { - expect(resolveChatWrapWidth(180, false, 0)).toBe(110); + it("lets prose chat and embedded terminals use the full center pane", () => { + expect(resolveChatWrapWidth(180, false, 0)).toBe(180); expect(resolveChatWrapWidth(Number.NaN, false, 0)).toBe(24); expect(resolveTerminalPaneWidth(180)).toBe(180); expect(resolveTerminalPaneWidth(Number.NaN)).toBe(24); @@ -211,6 +344,56 @@ describe("subagentSnapshotsFromEvents", () => { }); }); + it("keeps sibling subagents separate when they share the same parent tool id", () => { + const snapshots = subagentSnapshotsFromEvents([ + { + sessionId: "s1", + timestamp: "2026-01-01T12:00:00.000Z", + sequence: 1, + event: { + type: "subagent_started", + taskId: "thread-1", + parentToolUseId: "spawn-1", + description: "Inspect renderer", + }, + }, + { + sessionId: "s1", + timestamp: "2026-01-01T12:00:01.000Z", + sequence: 2, + event: { + type: "subagent_started", + taskId: "thread-2", + parentToolUseId: "spawn-1", + description: "Inspect service", + }, + }, + { + sessionId: "s1", + timestamp: "2026-01-01T12:00:02.000Z", + sequence: 3, + event: { + type: "subagent_result", + taskId: "thread-1", + parentToolUseId: "spawn-1", + status: "completed", + summary: "renderer done", + }, + }, + ]); + + expect(snapshots).toHaveLength(2); + expect(snapshots.map((snapshot) => snapshot.id).sort()).toEqual(["thread-1", "thread-2"]); + expect(snapshots.find((snapshot) => snapshot.id === "thread-1")).toMatchObject({ + status: "completed", + summary: "renderer done", + }); + expect(snapshots.find((snapshot) => snapshot.id === "thread-2")).toMatchObject({ + status: "running", + summary: "Inspect service", + }); + }); + it("stops foreground subagents when their parent turn has ended", () => { const snapshots = subagentSnapshotsFromEvents([ { diff --git a/apps/ade-cli/src/tuiClient/__tests__/chatInfo.test.ts b/apps/ade-cli/src/tuiClient/__tests__/chatInfo.test.ts new file mode 100644 index 000000000..5ed31222e --- /dev/null +++ b/apps/ade-cli/src/tuiClient/__tests__/chatInfo.test.ts @@ -0,0 +1,179 @@ +import { describe, expect, it } from "vitest"; +import { deriveChatInfoSnapshot } from "../chatInfo"; +import type { AgentChatEventEnvelope, AgentChatSessionSummary } from "../../../../desktop/src/shared/types/chat"; +import type { TokenStats } from "../adeApi"; + +function env(timestamp: string, event: AgentChatEventEnvelope["event"], sequence: number): AgentChatEventEnvelope { + return { sessionId: "s1", timestamp, sequence, event }; +} + +function session(overrides: Partial = {}): AgentChatSessionSummary { + return { + id: "session-1", + laneId: "lane-1", + provider: "claude", + model: "claude-opus-4-7", + title: null, + createdAt: "2026-05-18T12:00:00.000Z", + updatedAt: "2026-05-18T12:00:00.000Z", + endedAt: null, + lastOutputPreview: null, + summary: null, + ...overrides, + } as AgentChatSessionSummary; +} + +function tokenStats(overrides: Partial = {}): TokenStats { + return { + percent: 25, + streaming: false, + inputTokens: null, + outputTokens: null, + cacheReadTokens: null, + cacheCreationTokens: null, + contextWindow: null, + costUsd: null, + rateLimit: { usedPercentage: null, resetsAt: null }, + ...overrides, + } as TokenStats; +} + +describe("deriveChatInfoSnapshot", () => { + it("derives plan from the most recent plan event with current/total counts and live state", () => { + const events: AgentChatEventEnvelope[] = [ + env("2026-05-18T12:00:00.000Z", { + type: "plan", + steps: [ + { text: "old step", status: "completed" }, + ], + }, 1), + env("2026-05-18T12:00:01.000Z", { type: "text", text: "thinking" }, 2), + env("2026-05-18T12:00:02.000Z", { + type: "plan", + steps: [ + { text: "patch runtime bridge", status: "completed" }, + { text: "verify desktop smoke", status: "in_progress" }, + { text: "ship", status: "pending" }, + ], + }, 3), + ]; + + const snapshot = deriveChatInfoSnapshot({ + events, + activeSession: null, + provider: "codex", + modelLabel: "gpt-5.5", + laneLabel: "lane-1", + snapshots: [], + tokenStats: null, + goal: null, + streaming: false, + }); + + expect(snapshot.plan).toEqual({ + current: 2, + total: 3, + live: true, + steps: [ + { text: "patch runtime bridge", status: "completed" }, + { text: "verify desktop smoke", status: "in_progress" }, + { text: "ship", status: "pending" }, + ], + }); + expect(snapshot.provider).toBe("codex"); + expect(snapshot.modelLabel).toBe("gpt-5.5"); + }); + + it("returns null plan and null token summary when there are no plan events and no token stats", () => { + const snapshot = deriveChatInfoSnapshot({ + events: [ + env("2026-05-18T12:00:00.000Z", { type: "text", text: "hi" }, 1), + ], + activeSession: null, + provider: "claude", + modelLabel: "claude-opus-4-7", + laneLabel: null, + snapshots: [], + tokenStats: null, + goal: null, + streaming: true, + }); + + expect(snapshot.plan).toBeNull(); + expect(snapshot.tokenSummary).toBeNull(); + expect(snapshot.contextPercent).toBeNull(); + expect(snapshot.streaming).toBe(true); + }); + + it("formats token usage as +input/output with cache marker and cost", () => { + const snapshot = deriveChatInfoSnapshot({ + events: [], + activeSession: null, + provider: "claude", + modelLabel: "claude-opus-4-7", + laneLabel: null, + snapshots: [], + tokenStats: tokenStats({ + percent: 42, + inputTokens: 1200, + outputTokens: 340, + cacheReadTokens: 5500, + costUsd: 0.14, + }), + goal: null, + streaming: false, + }); + + expect(snapshot.contextPercent).toBe(42); + expect(snapshot.tokenSummary).toBe("+1.2k/340 (5.5k✶) $0.14"); + }); + + it("derives a completed-only plan as non-live with current pointing at the completed count", () => { + const events: AgentChatEventEnvelope[] = [ + env("2026-05-18T12:00:00.000Z", { + type: "plan", + steps: [ + { text: "one", status: "completed" }, + { text: "two", status: "completed" }, + ], + }, 1), + ]; + + const snapshot = deriveChatInfoSnapshot({ + events, + activeSession: null, + provider: "claude", + modelLabel: "claude-opus-4-7", + laneLabel: "lane-1", + snapshots: [], + tokenStats: null, + goal: null, + streaming: false, + }); + + expect(snapshot.plan).toMatchObject({ current: 2, total: 2, live: false }); + }); + + it("prefers the active session provider over the argument and passes laneLabel/streaming through", () => { + const snapshot = deriveChatInfoSnapshot({ + events: [], + activeSession: session({ provider: "codex" }), + provider: "claude", + modelLabel: "claude-opus-4-7", + laneLabel: "fixing-cli-send-error", + snapshots: [ + { id: "x1", name: "delegated", kind: "subagent", status: "running", summary: "" }, + ], + tokenStats: null, + goal: null, + streaming: true, + inspectedSubagentId: "x1", + }); + + expect(snapshot.provider).toBe("codex"); + expect(snapshot.laneLabel).toBe("fixing-cli-send-error"); + expect(snapshot.streaming).toBe(true); + expect(snapshot.snapshots).toHaveLength(1); + expect(snapshot.inspectedSubagentId).toBe("x1"); + }); +}); diff --git a/apps/ade-cli/src/tuiClient/__tests__/commands.test.ts b/apps/ade-cli/src/tuiClient/__tests__/commands.test.ts index 15e783656..e0ab428d7 100644 --- a/apps/ade-cli/src/tuiClient/__tests__/commands.test.ts +++ b/apps/ade-cli/src/tuiClient/__tests__/commands.test.ts @@ -215,6 +215,24 @@ describe("commands", () => { expect(parsed?.userCommand).toBeNull(); }); + it("does not reserve /copy as an ADE built-in", () => { + const rows = paletteCommands("/copy", [ + { name: "/copy", description: "Runtime copy command", source: "sdk" }, + ]); + expect(rows).toContainEqual(expect.objectContaining({ + name: "/copy", + source: "user", + description: "Runtime copy command", + })); + + const parsed = parseCommand("/copy all", [ + { name: "/copy", description: "Runtime copy command", source: "sdk" }, + ]); + expect(parsed?.spec).toBeNull(); + expect(parsed?.userCommand?.name).toBe("/copy"); + expect(parsed ? commandPlacement(parsed) : null).toBe("chat"); + }); + it("dedupes slash command case variants and keeps runtime casing", () => { const rows = paletteCommands("/ship", [ { name: "/shipLane", description: "Ship the lane", source: "sdk" }, @@ -258,9 +276,9 @@ describe("commands", () => { 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"); + const goalStatus = parseCommand("/goal status active"); + expect(goalStatus?.spec?.name).toBe("/goal"); + expect(goalStatus?.args).toBe("status active"); }); it("drops the legacy /resume builtin", () => { @@ -268,16 +286,22 @@ describe("commands", () => { expect(rows.find((row) => row.name === "/resume" && row.source === "ade")).toBeUndefined(); }); - it("registers /subagents as a right-pane builtin", () => { + it("registers /info as the active-chat info command", () => { + const info = parseCommand("/info"); + expect(info?.spec?.name).toBe("/info"); + expect(info?.spec?.placement).toBe("right"); + expect(info ? commandPlacement(info) : null).toBe("right"); + const parsed = parseCommand("/subagents"); - expect(parsed?.spec?.name).toBe("/subagents"); - expect(parsed?.spec?.placement).toBe("right"); - expect(parsed ? commandPlacement(parsed) : null).toBe("right"); + expect(parsed?.spec).toBeNull(); }); - it("surfaces /subagents in the palette and not /effort or /plan", () => { + it("surfaces /info in the palette and not /subagents, /effort, or /plan", () => { + const infoRows = paletteCommands("info"); + expect(infoRows.some((row) => row.name === "/info")).toBe(true); + const subagentRows = paletteCommands("subagents"); - expect(subagentRows.some((row) => row.name === "/subagents")).toBe(true); + expect(subagentRows.some((row) => row.name === "/subagents")).toBe(false); const allRows = paletteCommands(""); expect(allRows.some((row) => row.name === "/effort" && row.source === "ade")).toBe(false); diff --git a/apps/ade-cli/src/tuiClient/__tests__/keybindings.test.ts b/apps/ade-cli/src/tuiClient/__tests__/keybindings.test.ts index 943b1c836..9a6257611 100644 --- a/apps/ade-cli/src/tuiClient/__tests__/keybindings.test.ts +++ b/apps/ade-cli/src/tuiClient/__tests__/keybindings.test.ts @@ -97,6 +97,18 @@ describe("keybindings", () => { expect(dispatchKeybinding(diagnostics.bindings, "Chat", "s", { ctrl: true })).toBeUndefined(); }); + it("dispatches selection copy as an implemented action", () => { + const diagnostics = validateClaudeKeybindingsConfig({ + bindings: [ + { context: "Chat", bindings: { "ctrl+y": "selection:copy" } }, + ], + }); + + expect(diagnostics.bindingCount).toBe(1); + expect(diagnostics.warnings).toEqual([]); + expect(dispatchKeybinding(diagnostics.bindings, "Chat", "y", { ctrl: true })).toBe("selection:copy"); + }); + it("converts Ink keypresses to chords", () => { expect(keypressToChord("", { pageDown: true })).toBe("pagedown"); expect(keypressToChord("k", { ctrl: true })).toBe("ctrl+k"); diff --git a/apps/ade-cli/src/tuiClient/__tests__/subagentPane.test.ts b/apps/ade-cli/src/tuiClient/__tests__/subagentPane.test.ts index 98a5fee16..c632b805a 100644 --- a/apps/ade-cli/src/tuiClient/__tests__/subagentPane.test.ts +++ b/apps/ade-cli/src/tuiClient/__tests__/subagentPane.test.ts @@ -22,40 +22,63 @@ const session: AgentChatSessionSummary = { summary: null, }; -function subagentsContent(): Extract { +function rosterContent(): Extract { return { - kind: "subagents", - tab: "subagents", - provider: "codex", - snapshots: [ - { id: "run-1", name: "running", kind: "subagent", status: "running", summary: "checking files" }, - { id: "team-1", name: "review", kind: "teammate", status: "completed", summary: "done" }, - { id: "bg-1", name: "lane pack", kind: "subagent", status: "running", background: true, summary: "refreshing" }, - { id: "done-1", name: "tests", kind: "subagent", status: "completed", summary: "passed" }, - ], + kind: "chat-info", + info: { + provider: "codex", + modelLabel: "gpt-5.5", + laneLabel: "lane-1", + contextPercent: null, + tokenSummary: null, + goal: null, + plan: null, + streaming: false, + inspectedSubagentId: null, + snapshots: [ + { id: "run-1", name: "running", kind: "subagent", status: "running", summary: "checking files" }, + { id: "team-1", name: "review", kind: "teammate", status: "completed", summary: "done" }, + { id: "bg-1", name: "lane pack", kind: "subagent", status: "running", background: true, summary: "refreshing" }, + { id: "done-1", name: "tests", kind: "subagent", status: "completed", summary: "passed" }, + ], + }, + }; +} + +function rosterPaneContent() { + const content = rosterContent(); + return { + provider: content.info.provider, + snapshots: content.info.snapshots, }; } describe("subagent pane helpers", () => { it("keeps main first and groups selectable agent rows by section", () => { - const rows = buildSubagentPaneRows(subagentsContent()); + const rows = buildSubagentPaneRows(rosterPaneContent()); - expect(rows.map((row) => row.key)).toEqual(["main", "run-1", "team-1", "bg-1", "done-1"]); - expect(rows.map((row) => row.section)).toEqual(["main", "subagents", "teammates", "background", "recent"]); - expect(selectedSubagentSnapshot(subagentsContent(), 0)).toBeNull(); - expect(selectedSubagentSnapshot(subagentsContent(), 1)?.id).toBe("run-1"); + expect(rows.map((row) => row.key)).toEqual(["main", "run-1", "done-1", "team-1", "bg-1"]); + expect(rows.map((row) => row.section)).toEqual(["main", "subagents", "subagents", "teammates", "background"]); + expect(selectedSubagentSnapshot(rosterPaneContent(), 0)).toBeNull(); + expect(selectedSubagentSnapshot(rosterPaneContent(), 1)?.id).toBe("run-1"); }); it("maps visual table lines back to selectable rows for mouse clicks", () => { - const content = subagentsContent(); - const offsets = subagentPaneSelectableLineOffsets(content); + const content = rosterPaneContent(); + const offsets = subagentPaneSelectableLineOffsets(content, 1); expect(offsets.length).toBe(5); - expect(subagentIndexForPaneLine(content, offsets[0]!)).toBe(0); - expect(subagentIndexForPaneLine(content, offsets[1]!)).toBe(1); - expect(subagentIndexForPaneLine(content, offsets[3]!)).toBe(3); - expect(subagentIndexForPaneLine(content, offsets[4]! + 1)).toBe(4); - expect(subagentIndexForPaneLine(content, offsets[0]! - 2)).toBeNull(); + expect(subagentIndexForPaneLine(content, offsets[0]!, 1)).toBe(0); + expect(subagentIndexForPaneLine(content, offsets[1]!, 1)).toBe(1); + expect(subagentIndexForPaneLine(content, offsets[3]!, 1)).toBe(3); + expect(subagentIndexForPaneLine(content, offsets[4]! + 1, 1)).toBe(4); + expect(subagentIndexForPaneLine(content, offsets[0]! - 2, 1)).toBeNull(); + }); + + it("only accounts for detail lines on the selected subagent row", () => { + const content = rosterPaneContent(); + expect(subagentPaneSelectableLineOffsets(content, 1)).toEqual([4, 8, 10, 13, 16]); + expect(subagentPaneSelectableLineOffsets(content, 2)).toEqual([4, 8, 9, 13, 16]); }); it("builds a focused transcript without unrelated subagent output", () => { @@ -70,12 +93,18 @@ describe("subagent pane helpers", () => { sessionId: "s1", timestamp: "2026-01-01T12:00:01.000Z", sequence: 2, + event: { type: "subagent_started", taskId: "other", parentToolUseId: "spawn-1", description: "sibling agent" }, + }, + { + sessionId: "s1", + timestamp: "2026-01-01T12:00:01.500Z", + sequence: 3, event: { type: "tool_call", itemId: "tool-1", parentItemId: "spawn-1", tool: "read_file", args: { path: "src/app.tsx" } }, }, { sessionId: "s1", timestamp: "2026-01-01T12:00:02.000Z", - sequence: 3, + sequence: 4, event: { type: "subagent_result", taskId: "other", parentToolUseId: "spawn-2", status: "completed", summary: "wrong transcript" }, }, ]; @@ -95,6 +124,7 @@ describe("subagent pane helpers", () => { expect(transcript.map((entry) => entry.event.type)).toEqual(["text", "text", "tool_call"]); expect(transcript.map((entry) => JSON.stringify(entry.event)).join("\n")).toContain("read_file"); + expect(transcript.map((entry) => JSON.stringify(entry.event)).join("\n")).not.toContain("sibling agent"); expect(transcript.map((entry) => JSON.stringify(entry.event)).join("\n")).not.toContain("wrong transcript"); }); @@ -125,6 +155,6 @@ describe("subagent pane helpers", () => { }, }); - expect(transcript.map((entry) => JSON.stringify(entry.event)).join("\n")).toContain("Subagent started: Investigate issue"); + expect(transcript.map((entry) => JSON.stringify(entry.event)).join("\n")).toContain("Started."); }); }); diff --git a/apps/ade-cli/src/tuiClient/aggregate.ts b/apps/ade-cli/src/tuiClient/aggregate.ts index f3dd352cd..190606375 100644 --- a/apps/ade-cli/src/tuiClient/aggregate.ts +++ b/apps/ade-cli/src/tuiClient/aggregate.ts @@ -31,6 +31,13 @@ export type FileChangeEntry = { deleted?: boolean; }; +export type RuntimeActivityEntry = { + id: string; + label: string; + detail?: string; + status: WorkToolStatus | "info"; +}; + export type PlanStep = { text: string; status: "pending" | "in_progress" | "completed" | "failed"; @@ -46,6 +53,7 @@ export type AggregatedBlock = | { kind: "assistant-text"; id: string; line: RenderedChatLine; precededByHeavy?: boolean } | { kind: "tool-calls-group"; id: string; turnId: string | null; entries: ToolCallEntry[]; live: boolean; durationMs?: number } | { kind: "files-changed-group"; id: string; turnId: string | null; entries: FileChangeEntry[]; live: boolean; durationMs?: number } + | { kind: "runtime-activity"; id: string; turnId: string | null; entries: RuntimeActivityEntry[]; live: boolean } | { kind: "memory"; id: string; turnId: string | null; live: boolean; hitCount?: number; text?: string } | { kind: "compaction"; id: string; turnId: string | null; trigger: "manual" | "auto"; live: boolean; preTokens?: number } | { kind: "queued-steer"; id: string; turnId: string | null; steerId: string; text: string } @@ -219,9 +227,10 @@ function findLastBlock( function isLiveTurnBlock( block: AggregatedBlock, -): block is Extract { +): block is Extract { return block.kind === "tool-calls-group" || block.kind === "files-changed-group" + || block.kind === "runtime-activity" || block.kind === "plan" || block.kind === "compaction"; } @@ -272,6 +281,79 @@ function stringField(value: unknown): string | null { return typeof value === "string" && value.trim().length > 0 ? value.trim() : null; } +function compactActivityDetail(value: unknown, max = 70): string | undefined { + const text = stringField(value); + if (!text) return undefined; + const normalized = text.replace(/\s+/g, " ").trim(); + if (normalized.length <= max) return normalized; + return `${normalized.slice(0, Math.max(0, max - 1))}…`; +} + +function runtimeStatus(value: unknown): RuntimeActivityEntry["status"] { + if (value === "failed" || value === "interrupted") return "failed"; + if (value === "completed" || value === "ok" || value === "complete") return "ok"; + if (value === "running" || value === "started" || value === "active") return "running"; + return "info"; +} + +function runtimeActivityFromEvent(id: string, event: AgentChatEvent): RuntimeActivityEntry | null { + if (event.type === "activity") { + const activity = event.activity; + const detail = compactActivityDetail(event.detail); + if (activity === "thinking" || activity === "working") return null; + return { + id, + label: activity.replace(/_/g, " "), + detail, + status: "running", + }; + } + if (event.type === "subagent_started" || event.type === "subagent.started") { + return { + id, + label: "subagent started", + status: "running", + }; + } + if (event.type === "subagent_progress" || event.type === "subagent.progress") { + return { + id, + label: "subagent progress", + status: "running", + }; + } + if (event.type === "subagent_result" || event.type === "subagent.completed") { + return { + id, + label: "subagent finished", + status: runtimeStatus((event as { status?: unknown }).status ?? "completed"), + }; + } + return null; +} + +function appendRuntimeActivityBlock( + blocks: AggregatedBlock[], + id: string, + turnId: string | null, + entry: RuntimeActivityEntry, +): void { + const last = blocks[blocks.length - 1]; + let block: Extract; + if (last && last.kind === "runtime-activity" && last.turnId === turnId) { + block = last; + } else { + block = { kind: "runtime-activity", id, turnId, entries: [], live: true }; + blocks.push(block); + } + const previous = block.entries[block.entries.length - 1]; + if (previous && previous.label === entry.label && previous.detail === entry.detail && previous.status === entry.status) { + return; + } + block.entries.push(entry); + if (block.entries.length > 8) block.entries.splice(0, block.entries.length - 8); +} + function subagentParentItemId(event: AgentChatEvent): string | null { if (!isSubagentTimelineEvent(event)) return null; return stringField((event as { parentToolUseId?: unknown }).parentToolUseId); @@ -410,6 +492,8 @@ export function aggregateChatBlocks(args: { const turnId = turnIdOf(event); if (isSubagentTimelineEvent(event)) { + const activity = runtimeActivityFromEvent(id, event); + if (activity) appendRuntimeActivityBlock(blocks, id, turnId, activity); continue; } @@ -560,7 +644,8 @@ export function aggregateChatBlocks(args: { continue; } if (event.type === "activity") { - // Activity rows are low-signal transcript metadata; keep the main chat quiet. + const activity = runtimeActivityFromEvent(id, event); + if (activity) appendRuntimeActivityBlock(blocks, id, turnId, activity); continue; } if (event.type === "approval_request") { diff --git a/apps/ade-cli/src/tuiClient/app.tsx b/apps/ade-cli/src/tuiClient/app.tsx index 0b5dd51ad..7d2c2292b 100644 --- a/apps/ade-cli/src/tuiClient/app.tsx +++ b/apps/ade-cli/src/tuiClient/app.tsx @@ -80,6 +80,7 @@ import { type TokenStats, } from "./adeApi"; import { derivePendingSteers } from "./aggregate"; +import { deriveChatInfoSnapshot } from "./chatInfo"; import { paletteCommands, parseCommand } from "./commands"; import { hasFirstUserMessage, isPlanMode } from "./planMode"; import { connectToAde } from "./connection"; @@ -87,9 +88,10 @@ import { Drawer, visibleDrawerChatCount, visibleDrawerLaneCount, type DrawerPrSu import { ChatView, computeChatScrollMaxOffset, - renderChatTranscriptPlainText, - renderChatVisibleRowTexts, - selectedTextFromVisibleChatRows, + renderChatSelectableRowTexts, + renderChatVisibleSelectionRows, + selectedTextFromChatRows, + type ChatVisibleSelectionRow, type ChatTextSelection, } from "./components/ChatView"; import { TerminalPane, clampTerminalPaneCols } from "./components/TerminalPane"; @@ -123,9 +125,9 @@ import { claudeHomePath, defaultKeybindingsPath, dispatchKeybinding, openKeybind import { buildSubagentPaneRows, buildSubagentTranscriptEvents, - clampSubagentSelection, subagentIndexForPaneLine, - selectedSubagentSnapshot, + subagentPaneContentFromRightPane, + type SubagentPaneRow, } from "./subagentPane"; import { readClaudeStatusLineConfig, runClaudeStatusLineCommand } from "./statusline"; import type { @@ -491,10 +493,7 @@ function formatGoalBannerLine(goal: CodexThreadGoal | null): string | null { 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) { + if (used != null) { right.push(`${compactNumber(used)} tokens`); } if (typeof goal.timeUsedSeconds === "number" && goal.timeUsedSeconds > 0) { @@ -502,7 +501,8 @@ function formatGoalBannerLine(goal: CodexThreadGoal | null): string | null { 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, " ")); + const visibleStatus = goal.status === "budget_limited" ? "active" : goal.status; + if (visibleStatus) right.push(visibleStatus.replace(/_/g, " ")); return right.length ? `◎ ${objective} ${right.join(" · ")}` : `◎ ${objective}`; } @@ -524,22 +524,39 @@ function formatContextUsage(usage: AgentChatContextUsage | null): string { .join("\n"); } -function findSubagentSnapshotByParent( - snapshots: Map, - parentToolUseId: string | null | undefined, -): [string, SubagentSnapshot] | null { - if (!parentToolUseId) return null; - const direct = snapshots.get(parentToolUseId); - if (direct) return [parentToolUseId, direct]; - for (const [key, snapshot] of snapshots) { - if (snapshot.parentToolUseId === parentToolUseId) return [key, snapshot]; +function buildResolvedSubagentIdsByParent(events: AgentChatEventEnvelope[]): Map> { + const idsByParent = new Map>(); + for (const envelope of events) { + const event = envelope.event as Record; + const type = typeof event.type === "string" ? event.type : ""; + if (!type.startsWith("subagent")) continue; + const parent = typeof event.parentToolUseId === "string" && event.parentToolUseId.trim() + ? event.parentToolUseId.trim() + : null; + if (!parent) continue; + const taskId = typeof event.taskId === "string" && event.taskId.trim() ? event.taskId.trim() : null; + const agentId = typeof event.agentId === "string" && event.agentId.trim() ? event.agentId.trim() : null; + const id = agentId ?? taskId; + if (!id || id === parent) continue; + const ids = idsByParent.get(parent) ?? new Set(); + ids.add(id); + idsByParent.set(parent, ids); } - return null; + return idsByParent; +} + +function isParentSubagentPlaceholder(snapshot: SubagentSnapshot | undefined, parentToolUseId: string): snapshot is SubagentSnapshot { + return Boolean( + snapshot + && snapshot.id === parentToolUseId + && snapshot.parentToolUseId === parentToolUseId, + ); } export function subagentSnapshotsFromEvents(events: AgentChatEventEnvelope[]): SubagentSnapshot[] { const snapshots = new Map(); const terminalTurnIds = new Set(); + const resolvedIdsByParent = buildResolvedSubagentIdsByParent(events); for (const envelope of events) { const event = envelope.event as Record; @@ -606,10 +623,25 @@ export function subagentSnapshotsFromEvents(events: AgentChatEventEnvelope[]): S const incomingParentToolUseId = typeof event.parentToolUseId === "string" && event.parentToolUseId.trim() ? event.parentToolUseId.trim() : null; - const parentMatch = findSubagentSnapshotByParent(snapshots, incomingParentToolUseId); - const existing = snapshots.get(id) ?? (taskId ? snapshots.get(taskId) : undefined) ?? parentMatch?.[1]; + const parentPlaceholder = incomingParentToolUseId ? snapshots.get(incomingParentToolUseId) : undefined; + const parentResolvedIds = incomingParentToolUseId ? resolvedIdsByParent.get(incomingParentToolUseId) : undefined; + const parentIsPlaceholder = Boolean( + incomingParentToolUseId + && isParentSubagentPlaceholder(parentPlaceholder, incomingParentToolUseId), + ); + const canAdoptParentPlaceholder = parentIsPlaceholder + && parentResolvedIds?.size === 1 + && parentResolvedIds.has(id); + const taskAlias = taskId && taskId !== id ? snapshots.get(taskId) : undefined; + const existing = snapshots.get(id) ?? taskAlias ?? (canAdoptParentPlaceholder ? parentPlaceholder : undefined); if (taskId && id !== taskId) snapshots.delete(taskId); - if (parentMatch && parentMatch[0] !== id) snapshots.delete(parentMatch[0]); + if ( + incomingParentToolUseId + && parentIsPlaceholder + && (canAdoptParentPlaceholder || (parentResolvedIds && parentResolvedIds.size > 1)) + ) { + snapshots.delete(incomingParentToolUseId); + } const agentType = typeof event.agentType === "string" ? event.agentType : "subagent"; const usage = event.usage && typeof event.usage === "object" ? event.usage as Record : {}; const parentToolUseId = incomingParentToolUseId ?? existing?.parentToolUseId ?? null; @@ -786,6 +818,7 @@ type ContextDefaultArgs = { liveAgentCount: number; highlightedDrawerLane: LaneSummary | null; drawerMode: "chats" | "lanes"; + chatInfo: Extract["info"]; subagentSnapshots: SubagentSnapshot[]; provider: AdeCodeProvider; newChatSetup: { laneId: string; laneLabel: string; rows: SetupPaneRow[] } | null; @@ -808,12 +841,10 @@ function resolveContextDefault(args: ContextDefaultArgs): RightPaneContent { rows: args.newChatSetup.rows, }; } - if (args.activeSession && args.liveAgentCount > 0) { + if (args.activeSession) { return { - kind: "subagents", - tab: "subagents", - snapshots: args.subagentSnapshots, - provider: args.provider, + kind: "chat-info", + info: args.chatInfo, }; } if (args.activeLane) { @@ -1418,22 +1449,41 @@ type TerminalMouseInput = { x: number | null; y: number | null; direction?: "up" | "down" | "left" | "right"; + shift?: boolean; + alt?: boolean; + ctrl?: boolean; }; +export type ChatSelectionState = ChatTextSelection & { active: boolean }; +export type ChatSelectionPoint = { row: number; column: number }; +type ChatSelectionEdgeDirection = "older" | "newer"; + +const CTRL_C_EXIT_ARM_MS = 1500; +const CHAT_SELECTION_EDGE_SCROLL_MS = 90; + +function withMouseModifiers(input: Omit, code: number): TerminalMouseInput { + return { + ...input, + ...(code & 4 ? { shift: true } : {}), + ...(code & 8 ? { alt: true } : {}), + ...(code & 16 ? { ctrl: true } : {}), + }; +} + function decodeMouseButton(code: number, x: number | null, y: number | null, pressed: boolean): TerminalMouseInput { if (!pressed) { - return { kind: "release", x, y }; + return withMouseModifiers({ kind: "release", x, y }, code); } if (code & 64) { const wheelButton = code & 3; - if (wheelButton === 0) return { kind: "wheel", direction: "up", x, y }; - if (wheelButton === 1) return { kind: "wheel", direction: "down", x, y }; - if (wheelButton === 2) return { kind: "wheel", direction: "left", x, y }; - return { kind: "wheel", direction: "right", x, y }; + if (wheelButton === 0) return withMouseModifiers({ kind: "wheel", direction: "up", x, y }, code); + if (wheelButton === 1) return withMouseModifiers({ kind: "wheel", direction: "down", x, y }, code); + if (wheelButton === 2) return withMouseModifiers({ kind: "wheel", direction: "left", x, y }, code); + return withMouseModifiers({ kind: "wheel", direction: "right", x, y }, code); } - if ((code & 32) && (code & 3) === 0) return { kind: "drag", x, y }; - if ((code & 3) === 0) return { kind: "click", x, y }; - return { kind: "other", x, y }; + if ((code & 32) && (code & 3) === 0) return withMouseModifiers({ kind: "drag", x, y }, code); + if ((code & 3) === 0) return withMouseModifiers({ kind: "click", x, y }, code); + return withMouseModifiers({ kind: "other", x, y }, code); } export function parseTerminalMouseInput(input: string): TerminalMouseInput | null { @@ -1475,6 +1525,86 @@ export function clampChatScrollOffsetRows(value: number, maxOffset: number): num return Math.max(0, Math.min(Math.floor(value), safeMax)); } +export function isChatTextSelectionRange(selection: ChatTextSelection | null | undefined): selection is ChatTextSelection { + if (!selection) return false; + return selection.startRow !== selection.endRow || selection.startColumn !== selection.endColumn; +} + +export function isCtrlCCopyPlatform(platform: NodeJS.Platform = process.platform): boolean { + return platform === "win32"; +} + +export function chatSelectionPointFromVisibleRows( + rows: ChatVisibleSelectionRow[], + visibleRow: number, + column: number, + clampToSelectable: boolean, +): ChatSelectionPoint | null { + if (!rows.length) return null; + const safeVisibleRow = Math.max(0, Math.min(Math.floor(visibleRow), rows.length - 1)); + const safeColumn = Math.max(0, Math.floor(column)); + const exact = rows[safeVisibleRow]; + if (exact && exact.sourceRow != null) { + return { row: exact.sourceRow, column: safeColumn }; + } + if (!clampToSelectable) return null; + for (let distance = 1; distance < rows.length; distance += 1) { + const before = rows[safeVisibleRow - distance]; + if (before?.sourceRow != null) return { row: before.sourceRow, column: safeColumn }; + const after = rows[safeVisibleRow + distance]; + if (after?.sourceRow != null) return { row: after.sourceRow, column: safeColumn }; + } + return null; +} + +export function moveChatSelectionFocusByRows( + selection: ChatSelectionState, + rowDelta: number, + rowCount: number, + column: number, +): ChatSelectionState { + const maxRow = Math.max(0, rowCount - 1); + return { + ...selection, + endRow: Math.max(0, Math.min(maxRow, selection.endRow + rowDelta)), + endColumn: Math.max(0, Math.floor(column)), + }; +} + +export function chatSelectionFromAnchor( + anchor: ChatSelectionPoint, + point: ChatSelectionPoint, + active: boolean, +): ChatSelectionState { + return { + startRow: anchor.row, + startColumn: anchor.column, + endRow: point.row, + endColumn: point.column, + active, + }; +} + +export function chatSelectionEdgeDirectionForMouseY({ + y, + topRow, + rowBudget, + scrollOffsetRows, + maxScrollOffsetRows, +}: { + y: number | null; + topRow: number; + rowBudget: number; + scrollOffsetRows: number; + maxScrollOffsetRows: number; +}): ChatSelectionEdgeDirection | null { + if (y == null) return null; + const bottomRow = topRow + Math.max(1, rowBudget) - 1; + if (y < topRow && scrollOffsetRows < maxScrollOffsetRows) return "older"; + if (y > bottomRow && scrollOffsetRows > 0) return "newer"; + return null; +} + export function isTerminalMouseTrackingEnabled(value?: string): boolean { return !/^(0|false|no|off)$/i.test((value ?? "").trim()); } @@ -1497,11 +1627,6 @@ function useTerminalMouseTracking(): void { const DRAWER_PANE_WIDTH = 32; const MIN_CENTER_PANE_WIDTH = 24; -// When the chat fills most of the terminal (drawer and/or right pane closed), -// agent text can stretch into hard-to-read 200-char lines. Cap the wrap width -// here so the rhythm stays comfortable; the rendered Box still fills the full -// centerWidth, but the wrap budget passed to ChatView is bounded. -const READABLE_CHAT_MAX_WIDTH = 110; const MIN_RIGHT_PANE_WIDTH = 30; const RIGHT_PANE_MAX_WIDTH = 42; const CLAUDE_TERMINAL_HIDDEN_INPUT_ROWS = 3; @@ -1514,12 +1639,8 @@ function safeCenterWidth(centerWidth: number): number { return Math.max(MIN_CENTER_PANE_WIDTH, finiteFloor(centerWidth, MIN_CENTER_PANE_WIDTH)); } -export function resolveChatWrapWidth(centerWidth: number, drawerOpen: boolean, rightPaneWidth: number): number { - const safeWidth = safeCenterWidth(centerWidth); - const bothSidePanesOpen = drawerOpen && rightPaneWidth > 0; - return bothSidePanesOpen - ? safeWidth - : Math.min(safeWidth, READABLE_CHAT_MAX_WIDTH); +export function resolveChatWrapWidth(centerWidth: number, _drawerOpen: boolean, _rightPaneWidth: number): number { + return safeCenterWidth(centerWidth); } export function resolveTerminalPaneWidth(centerWidth: number): number { @@ -1742,10 +1863,11 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } const [hideVimModeIndicator, setHideVimModeIndicator] = useState(false); const [streaming, setStreaming] = useState(false); const [interrupted, setInterrupted] = useState(false); - const [chatMouseSelection, setChatMouseSelection] = useState<(ChatTextSelection & { active: boolean }) | null>(null); + const [chatMouseSelection, setChatMouseSelection] = useState(null); const [clearedAt, setClearedAt] = useState(null); const [expandedLineIds, setExpandedLineIds] = useState>(() => new Set()); const [chatScrollOffsetRows, setChatScrollOffsetRows] = useState(0); + const [inspectedSubagentId, setInspectedSubagentId] = useState(null); const [mentionSuggestions, setMentionSuggestions] = useState([]); const [mentionIndex, setMentionIndex] = useState(0); const [selectedMentions, setSelectedMentions] = useState([]); @@ -1801,7 +1923,13 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } const claudeTerminalSubmitQueueRef = useRef>(Promise.resolve()); const exitRequestedRef = useRef(false); const modelStateRef = useRef(initialModelState()); - const chatMouseSelectionRef = useRef<(ChatTextSelection & { active: boolean }) | null>(null); + const chatMouseSelectionRef = useRef(null); + const chatSelectionAnchorRef = useRef(null); + const selectableChatRowTextsRef = useRef([]); + const chatSelectionEdgeScrollTimerRef = useRef(null); + const chatSelectionEdgeScrollRef = useRef<{ direction: ChatSelectionEdgeDirection; column: number } | null>(null); + const ctrlCExitArmedUntilRef = useRef(0); + const ctrlCExitTimerRef = useRef(null); const loadedSessionIdRef = useRef(null); const providerModelsCacheRef = useRef>(new Map()); const pendingModelCommitTimerRef = useRef(null); @@ -1845,7 +1973,12 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } }, []); const selectActiveLaneId = useCallback((laneId: string | null) => { - if (activeLaneIdRef.current !== laneId) setChatScrollOffset(0); + if (activeLaneIdRef.current !== laneId) { + setChatScrollOffset(0); + chatSelectionAnchorRef.current = null; + chatMouseSelectionRef.current = null; + setChatMouseSelection(null); + } activeLaneIdRef.current = laneId; setActiveLaneId(laneId); if (laneId && lastLaneIdRef.current !== laneId) { @@ -1859,6 +1992,9 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } setChatScrollOffset(0); setCurrentGoal(null); lastUserOpenedPaneRef.current = null; + chatSelectionAnchorRef.current = null; + chatMouseSelectionRef.current = null; + setChatMouseSelection(null); } if (!sessionId) { activeTerminalSessionRef.current = null; @@ -1920,11 +2056,55 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } }, 3000); }, [modelState]); - const updateChatMouseSelection = useCallback((selection: (ChatTextSelection & { active: boolean }) | null) => { + const updateChatMouseSelection = useCallback((selection: ChatSelectionState | null) => { chatMouseSelectionRef.current = selection; setChatMouseSelection(selection); }, []); + const stopChatSelectionEdgeScroll = useCallback(() => { + chatSelectionEdgeScrollRef.current = null; + if (chatSelectionEdgeScrollTimerRef.current) { + clearInterval(chatSelectionEdgeScrollTimerRef.current); + chatSelectionEdgeScrollTimerRef.current = null; + } + }, []); + + const stepChatSelectionEdgeScroll = useCallback(() => { + const edge = chatSelectionEdgeScrollRef.current; + const selection = chatMouseSelectionRef.current; + if (!edge || !selection?.active) { + stopChatSelectionEdgeScroll(); + return; + } + const rowCount = selectableChatRowTextsRef.current.length; + if (!rowCount) { + stopChatSelectionEdgeScroll(); + return; + } + if ( + (edge.direction === "older" && selection.endRow <= 0 && chatScrollOffsetRowsRef.current >= chatScrollMaxOffsetRef.current) + || (edge.direction === "newer" && selection.endRow >= rowCount - 1 && chatScrollOffsetRowsRef.current <= 0) + ) { + stopChatSelectionEdgeScroll(); + return; + } + const rowDelta = edge.direction === "older" ? -1 : 1; + updateChatMouseSelection(moveChatSelectionFocusByRows(selection, rowDelta, rowCount, edge.column)); + setChatScrollOffset((offset) => offset + (edge.direction === "older" ? 1 : -1)); + }, [setChatScrollOffset, stopChatSelectionEdgeScroll, updateChatMouseSelection]); + + const startChatSelectionEdgeScroll = useCallback((direction: ChatSelectionEdgeDirection, column: number) => { + chatSelectionEdgeScrollRef.current = { direction, column }; + if (chatSelectionEdgeScrollTimerRef.current) return; + stepChatSelectionEdgeScroll(); + chatSelectionEdgeScrollTimerRef.current = setInterval(stepChatSelectionEdgeScroll, CHAT_SELECTION_EDGE_SCROLL_MS); + }, [stepChatSelectionEdgeScroll]); + + useEffect(() => () => { + stopChatSelectionEdgeScroll(); + if (ctrlCExitTimerRef.current) clearTimeout(ctrlCExitTimerRef.current); + }, [stopChatSelectionEdgeScroll]); + const stashActiveInput = useCallback(() => { const pane = activePaneRef.current; if (pane === "chat") { @@ -2005,9 +2185,13 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } }, [focusChat, focusDetails, rightOpen, rightPane.kind, selectFooterControl]); const cyclePaneFocus = useCallback(() => { - const order: PaneFocus[] = ["drawer", "chat", "details"]; + const order: PaneFocus[] = [ + ...(drawerOpen ? (["drawer"] as PaneFocus[]) : []), + "chat", + ...(rightOpen ? (["details"] as PaneFocus[]) : []), + ]; const currentIndex = order.indexOf(activePaneRef.current); - const nextPane = order[(currentIndex + 1) % order.length] ?? "chat"; + const nextPane = order[(currentIndex >= 0 ? currentIndex + 1 : 0) % order.length] ?? "chat"; if (nextPane === "drawer") { focusDrawer(); } else if (nextPane === "details") { @@ -2015,7 +2199,7 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } } else { focusChat(); } - }, [focusChat, focusDetails, focusDrawer]); + }, [drawerOpen, focusChat, focusDetails, focusDrawer, rightOpen]); const focusAfterDetails = useCallback(() => { if (paneBeforeDetailsRef.current === "drawer" && drawerOpen) { @@ -2080,19 +2264,44 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } () => subagentSnapshots.filter((snap) => snap.status === "running").length, [subagentSnapshots], ); - // Button visibility (shown in the inline model row) requires snapshots > 0. - // Command availability (typing /subagents) only requires a chat to attach to, - // so the slash command can open an empty-state pane. - const subagentsButtonVisible = Boolean(activeSession && !draftChatActive && subagentSnapshots.length > 0); + const chatInfo = useMemo(() => deriveChatInfoSnapshot({ + events, + activeSession, + provider: modelState.provider, + modelLabel: modelState.displayName || modelState.model || modelState.provider, + laneLabel: activeLane?.name ?? null, + snapshots: subagentSnapshots, + tokenStats: statusLineStats, + goal: currentGoal, + streaming, + inspectedSubagentId, + }), [ + activeLane?.name, + activeSession, + currentGoal, + events, + inspectedSubagentId, + modelState.displayName, + modelState.model, + modelState.provider, + statusLineStats, + streaming, + subagentSnapshots, + ]); + const chatInfoRef = useRef(chatInfo); + useEffect(() => { + chatInfoRef.current = chatInfo; + }, [chatInfo]); + // Chat info is available for any active chat; subagent rows fill in when + // the provider emits agent lifecycle events. const subagentPaneCommandAvailable = Boolean(activeSession && !draftChatActive); - const subagentPaneAvailable = subagentsButtonVisible; const subagentsButtonVisibleRef = useRef(false); useEffect(() => { - subagentsButtonVisibleRef.current = subagentsButtonVisible; - }, [subagentsButtonVisible]); + subagentsButtonVisibleRef.current = subagentPaneCommandAvailable; + }, [subagentPaneCommandAvailable]); const footerControls = useMemo( - () => footerControlsForAvailability(subagentPaneAvailable), - [subagentPaneAvailable], + () => footerControlsForAvailability(subagentPaneCommandAvailable), + [subagentPaneCommandAvailable], ); const cycleFooterControl = useCallback((direction: 1 | -1) => { const controls: FooterControl[] = footerControls.length ? footerControls : ["drawer", "details"]; @@ -2103,34 +2312,40 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } selectFooterControl(controls[nextIndex] ?? "drawer"); }, [footerControls, selectFooterControl]); useEffect(() => { - if (footerControl === "agents" && !subagentPaneAvailable) { + if (footerControl === "agents" && !subagentPaneCommandAvailable) { selectFooterControl(null); } - }, [footerControl, selectFooterControl, subagentPaneAvailable]); + }, [footerControl, selectFooterControl, subagentPaneCommandAvailable]); useEffect(() => { if (rightPaneKindRef.current !== rightPane.kind) { - if (rightPane.kind === "subagents") { + if (rightPane.kind === "chat-info") { setRightSelectionIndex(0); } rightPaneKindRef.current = rightPane.kind; } }, [rightPane.kind]); useEffect(() => { - if (subagentPaneAvailable || rightPane.kind !== "subagents") return; - // If the user explicitly opened via /subagents, keep the pane open with the - // empty state. Auto-close only fires for transient toggle-style openings - // when the snapshot list has drained. - if (lastUserOpenedPaneRef.current === "subagents") return; - setRightPane({ kind: "empty" }); - setRightOpen(false); - if (activePaneRef.current === "details") { - focusChat(); - } - }, [focusChat, rightPane.kind, subagentPaneAvailable]); + if (rightPane.kind !== "chat-info") return; + setRightPane({ kind: "chat-info", info: chatInfo }); + }, [chatInfo, rightPane.kind]); useEffect(() => { - if (rightPane.kind !== "subagents") return; - setRightSelectionIndex((index) => clampSubagentSelection(rightPane, index)); + const content = subagentPaneContentFromRightPane(rightPane); + if (!content) return; + // Chat-info exposes (snapshot count + 1) selectable rows: main row at 0, + // subagents at 1..N. Clamp prior selection back into range when the + // roster shrinks (e.g., a subagent finishes and is reaped). + const rowCount = buildSubagentPaneRows(content).filter((row) => row.kind === "snapshot").length; + setRightSelectionIndex((index) => Math.max(0, Math.min(Number.isFinite(index) ? Math.floor(index) : 0, rowCount))); }, [rightPane]); + useEffect(() => { + if (!inspectedSubagentId) return; + if (rightPane.kind !== "chat-info" || !rightOpen || !subagentSnapshots.some((snap) => snap.id === inspectedSubagentId)) { + setInspectedSubagentId(null); + } + }, [inspectedSubagentId, rightOpen, rightPane.kind, subagentSnapshots]); + useEffect(() => { + setInspectedSubagentId(null); + }, [activeSessionId]); const openSubagentsPane = useCallback((): boolean => { if (!subagentPaneCommandAvailable) return false; const previousPane = activePaneRef.current; @@ -2143,31 +2358,28 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } setPrompt(""); setInlineRowFocus({ cell: null }); setRightPane({ - kind: "subagents", - tab: "subagents", - snapshots: subagentSnapshots, - provider: (activeSession?.provider ?? modelState.provider) as AdeCodeProvider, + kind: "chat-info", + info: chatInfo, }); setRightSelectionIndex(0); setRightOpen(true); setPaneFocus("details"); - lastUserOpenedPaneRef.current = "subagents"; + lastUserOpenedPaneRef.current = "chat-info"; return true; }, [ - activeSession?.provider, - modelState.provider, + chatInfo, selectFooterControl, setPaneFocus, stashActiveInput, subagentPaneCommandAvailable, - subagentSnapshots, ]); const toggleSubagentsPane = useCallback((): boolean => { if (!subagentPaneCommandAvailable) return true; selectFooterControl(null); - if (rightOpen && rightPane.kind === "subagents") { + if (rightOpen && rightPane.kind === "chat-info") { setRightOpen(false); lastUserOpenedPaneRef.current = null; + setInspectedSubagentId(null); focusChat(); return true; } @@ -2248,16 +2460,17 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } const rightPaneMaxWidth = RIGHT_PANE_MAX_WIDTH; const rightPaneWidth = resolveRightPaneWidth(columns, rightOpen, drawerOpen, rightPaneMaxWidth); const centerWidth = resolveCenterPaneWidth(columns, drawerOpen, rightPaneWidth); - const promptRows = promptDisplayRows(prompt, Math.max(1, centerWidth - 5), PROMPT_MAX_ROWS); + const promptPaneWidth = Math.max(MIN_CENTER_PANE_WIDTH, finiteFloor(columns, MIN_CENTER_PANE_WIDTH)); + const promptRows = promptDisplayRows(prompt, Math.max(1, promptPaneWidth - 5), PROMPT_MAX_ROWS); const chatRowBudget = Math.max(4, rows - 8 - (promptRows.length - 1) - statusRows - goalBannerRows); // Only cap the prose chat wrap width. Embedded CLI terminals should track the // center pane so PTYs resize when panes or the host window resize. const chatWrapWidth = resolveChatWrapWidth(centerWidth, drawerOpen, rightPaneWidth); const terminalPaneWidth = resolveTerminalPaneWidth(centerWidth); const selectedAgentSnapshot = useMemo(() => { - if (!rightOpen || activePane !== "details" || rightPane.kind !== "subagents") return null; - return selectedSubagentSnapshot(rightPane, rightSelectionIndex); - }, [activePane, rightOpen, rightPane, rightSelectionIndex]); + if (!rightOpen || rightPane.kind !== "chat-info" || !inspectedSubagentId) return null; + return subagentSnapshots.find((snapshot) => snapshot.id === inspectedSubagentId) ?? null; + }, [inspectedSubagentId, rightOpen, rightPane.kind, subagentSnapshots]); const displayEvents = useMemo(() => ( selectedAgentSnapshot ? buildSubagentTranscriptEvents({ events, activeSession, snapshot: selectedAgentSnapshot }) @@ -2266,6 +2479,11 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } const displayNotices = useMemo(() => (selectedAgentSnapshot ? [] : notices), [notices, selectedAgentSnapshot]); const displayStreaming = selectedAgentSnapshot ? selectedAgentSnapshot.status === "running" : streaming; const displayInterrupted = selectedAgentSnapshot ? false : interrupted && !displayStreaming; + useEffect(() => { + chatSelectionAnchorRef.current = null; + stopChatSelectionEdgeScroll(); + updateChatMouseSelection(null); + }, [selectedAgentSnapshot?.id, stopChatSelectionEdgeScroll, updateChatMouseSelection]); const spinTickActive = displayStreaming || mode === "connecting" || sessions.some((session) => session.status === "active") @@ -2294,7 +2512,7 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } const unseenMessageCount = effectiveChatScrollOffsetRows > 0 ? Math.max(0, displayEvents.length - lastSeenAtBottomEventCountRef.current) : 0; - const visibleChatRowTexts = useMemo(() => renderChatVisibleRowTexts({ + const visibleChatSelectionRows = useMemo(() => renderChatVisibleSelectionRows({ events: displayEvents, notices: displayNotices, activeSession, @@ -2317,6 +2535,26 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } expandedLineIds, unseenMessageCount, ]); + const selectableChatRowTexts = useMemo(() => renderChatSelectableRowTexts({ + events: displayEvents, + notices: displayNotices, + activeSession, + expandedLineIds, + width: chatWrapWidth, + streaming: displayStreaming, + interrupted: displayInterrupted, + }), [ + activeSession, + chatWrapWidth, + displayEvents, + displayInterrupted, + displayNotices, + displayStreaming, + expandedLineIds, + ]); + useEffect(() => { + selectableChatRowTextsRef.current = selectableChatRowTexts; + }, [selectableChatRowTexts]); const providerReadinessRows = useMemo( () => buildProviderReadinessRows(aiStatus, storedApiKeyProviders, openCodeDiagnostics), [aiStatus, openCodeDiagnostics, storedApiKeyProviders], @@ -2454,6 +2692,7 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } liveAgentCount, highlightedDrawerLane, drawerMode: drawerSection, + chatInfo, subagentSnapshots, provider: (activeSession?.provider ?? modelState.provider) as AdeCodeProvider, unavailableLaneIds, @@ -2476,8 +2715,6 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } ) { return prev; } - // Avoid replacing a populated subagents view with an empty seed. - if (prev.kind === "subagents" && next.kind === "subagents") return prev; if (prev.kind === next.kind && next.kind === "empty") return prev; if (prev.kind === "new-chat-setup" && next.kind === "new-chat-setup" && prev.laneId === next.laneId) return prev; return next; @@ -2486,6 +2723,7 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } activeLane, activeLaneId, activeSession, + chatInfo, draftChatActive, drawerLane, drawerLaneId, @@ -2510,11 +2748,6 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } rows: newChatSetupRows, } : prev); - } else if (rightPane.kind === "subagents") { - const provider = activeSession?.provider ?? modelState.provider; - setRightPane((prev) => prev.kind === "subagents" - ? { ...prev, snapshots: subagentSnapshots, provider: provider as AdeCodeProvider } - : prev); } else if (rightPane.kind === "lane-details") { setRightPane((prev) => prev.kind === "lane-details" ? { @@ -3490,6 +3723,14 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } if (event.type === "status" && event.turnStatus === "started") { setStreaming(true); setInterrupted(false); + if (activePaneRef.current !== "drawer") { + setRightPane((prev) => { + if (prev.kind === "chat-info") return { kind: "chat-info", info: chatInfoRef.current }; + if (prev.kind !== "empty" && prev.kind !== "lane-details") return prev; + setRightOpen(true); + return { kind: "chat-info", info: chatInfoRef.current }; + }); + } } if (event.type === "status" && event.turnStatus === "interrupted") { setStreaming(false); @@ -3504,19 +3745,16 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } setInterrupted(false); } if (event.type === "subagent_started" || event.type === "subagent.started") { - // Auto-open only when nothing important is showing. Otherwise the - // footer chip surfaces the live agent count without disrupting the user. + // Auto-open chat info only when the user is in the chat surface. + // Drawer navigation keeps lane details in the right pane. + if (activePaneRef.current === "drawer") return; setRightPane((prev) => { - if (prev.kind === "subagents") return prev; + if (prev.kind === "chat-info") return { kind: "chat-info", info: chatInfoRef.current }; if (prev.kind !== "empty" && prev.kind !== "lane-details") return prev; setRightOpen(true); - setPaneFocus("details"); - const provider = (activeSessionRef.current?.provider ?? modelStateRef.current.provider) as AdeCodeProvider; return { - kind: "subagents", - tab: "subagents", - snapshots: [], - provider, + kind: "chat-info", + info: chatInfoRef.current, }; }); } @@ -3901,37 +4139,43 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } .finally(() => exit()); }, [exit, signalActiveTerminalForExit, signalActiveTerminalForExitSync]); - const copyVisibleTranscript = useCallback((): boolean => { - const transcript = renderChatTranscriptPlainText({ - events: displayEvents, - notices: displayNotices, - activeSession, - expandedLineIds, - maxRows: chatRowBudget, - scrollOffsetRows: effectiveChatScrollOffsetRows, - width: chatWrapWidth, - }).trim(); - if (!transcript) { - addNotice("No visible chat text to copy.", "info"); - return true; + const requestCtrlCExit = useCallback(() => { + const now = Date.now(); + if (now <= ctrlCExitArmedUntilRef.current) { + ctrlCExitArmedUntilRef.current = 0; + if (ctrlCExitTimerRef.current) { + clearTimeout(ctrlCExitTimerRef.current); + ctrlCExitTimerRef.current = null; + } + requestAppExit(); + return; + } + ctrlCExitArmedUntilRef.current = now + CTRL_C_EXIT_ARM_MS; + if (ctrlCExitTimerRef.current) clearTimeout(ctrlCExitTimerRef.current); + ctrlCExitTimerRef.current = setTimeout(() => { + ctrlCExitArmedUntilRef.current = 0; + ctrlCExitTimerRef.current = null; + }, CTRL_C_EXIT_ARM_MS); + addNotice("Press Ctrl+C again to exit ADE Code.", "info"); + }, [addNotice, requestAppExit]); + + const copyChatSelection = useCallback((selection: ChatTextSelection | null | undefined = chatMouseSelectionRef.current): boolean => { + if (!isChatTextSelectionRange(selection)) { + addNotice("No chat text selected.", "info"); + return false; } - if (!writeClipboardText(transcript)) { + const text = selectedTextFromChatRows(selectableChatRowTextsRef.current, selection); + if (text.length === 0) { + addNotice("No chat text selected.", "info"); + return false; + } + if (!writeClipboardText(text)) { addNotice("Could not find a clipboard command for this terminal.", "error"); return true; } - addNotice("Copied visible chat text.", "success"); + addNotice("Copied selected chat text.", "success"); return true; - }, [ - activeSession, - addNotice, - chatRowBudget, - effectiveChatScrollOffsetRows, - chatWrapWidth, - displayEvents, - displayNotices, - expandedLineIds, - rightPane.kind, - ]); + }, [addNotice]); const sendOrSteerChatMessage = useCallback(async ( sessionId: string, @@ -4020,9 +4264,9 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } openModelRow(); return; } - if (name === "/subagents") { + if (name === "/info") { if (!subagentPaneCommandAvailable) { - addNotice("Open a chat first to inspect subagents.", "info"); + addNotice("Open a chat first to inspect chat info.", "info"); return; } openSubagentsPane(); @@ -4530,9 +4774,9 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } openModelRow(); return; } - if (name === "/subagents") { + if (name === "/info") { if (!subagentPaneCommandAvailable) { - addNotice("Open a chat first to inspect subagents.", "info"); + addNotice("Open a chat first to inspect chat info.", "info"); return; } openSubagentsPane(); @@ -4600,10 +4844,6 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } addNotice("Local transcript view cleared. The durable chat remains in ADE.", "info"); return; } - if (name === "/copy") { - copyVisibleTranscript(); - return; - } const conn = connectionRef.current; if (!conn) return; const laneId = activeLaneIdRef.current; @@ -4781,7 +5021,7 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } addNotice(result.message ?? "Desktop route unavailable from this runtime.", "error"); } } - }, [activeSession?.provider, addNotice, applyLocalModelArg, copyVisibleTranscript, displaySessions, loadProviderModels, modelState.provider, pendingSteers, project, refreshAiSetupStatus, refreshState, requestAppExit, scheduleModelStateCommit, sendClaudeModelCommandToTerminal, setChatScrollOffset, socketPath]); + }, [activeSession?.provider, addNotice, applyLocalModelArg, displaySessions, loadProviderModels, modelState.provider, pendingSteers, project, refreshAiSetupStatus, refreshState, requestAppExit, scheduleModelStateCommit, sendClaudeModelCommandToTerminal, setChatScrollOffset, socketPath]); const submitRightForm = useCallback(async ( form: Extract, @@ -5756,19 +5996,20 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } return true; } if (action === "selection:copy") { - return copyVisibleTranscript(); + copyChatSelection(); + return true; } if (action.startsWith("selection:")) { return reportUnavailable(); } return reportUnavailable(); - }, [addNotice, applyModelState, attachClipboardImage, chatRowBudget, copyVisibleTranscript, cycleFooterControl, cyclePaneFocus, cyclePermission, cycleReasoning, drawerOpen, focusAfterDetails, focusChat, focusDetails, footerControls, launchPromptInBackground, modelState.provider, openHistorySearch, openModelRow, prompt, recallPromptHistory, refreshState, requestAppExit, rightOpen, selectFooterControl, setChatScrollOffset, submitPrompt, toggleDetailsPane, toggleSubagentsPane]); + }, [addNotice, applyModelState, attachClipboardImage, chatRowBudget, copyChatSelection, cycleFooterControl, cyclePaneFocus, cyclePermission, cycleReasoning, drawerOpen, focusAfterDetails, focusChat, focusDetails, footerControls, launchPromptInBackground, modelState.provider, openHistorySearch, openModelRow, prompt, recallPromptHistory, refreshState, requestAppExit, rightOpen, selectFooterControl, setChatScrollOffset, submitPrompt, toggleDetailsPane, toggleSubagentsPane]); const chatPointFromMouse = useCallback(( x: number | null, y: number | null, clampToChat: boolean, - ): { row: number; column: number } | null => { + ): ChatSelectionPoint | null => { if (x == null || y == null) return null; const drawerWidth = drawerOpen ? DRAWER_PANE_WIDTH : 0; const textStartColumn = drawerWidth + 2; @@ -5778,18 +6019,21 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } if (!clampToChat && (x < textStartColumn || x > textEndColumn || y < topRow || y > bottomRow)) { return null; } - const row = Math.max(0, Math.min(y - topRow, Math.max(0, chatRowBudget - 1))); + const visibleRow = Math.max(0, Math.min(y - topRow, Math.max(0, chatRowBudget - 1))); const column = Math.max(0, Math.min(x - textStartColumn, Math.max(0, chatWrapWidth - 1))); - return { row, column }; - }, [chatRowBudget, chatWrapWidth, drawerOpen, goalBannerRows]); + return chatSelectionPointFromVisibleRows(visibleChatSelectionRows, visibleRow, column, clampToChat); + }, [chatRowBudget, chatWrapWidth, drawerOpen, goalBannerRows, visibleChatSelectionRows]); - const copyChatMouseSelection = useCallback((selection: ChatTextSelection): void => { - const text = selectedTextFromVisibleChatRows(visibleChatRowTexts, selection); - if (!text) return; - if (!writeClipboardText(text)) { - addNotice("Could not find a clipboard command for this terminal.", "error"); - } - }, [addNotice, visibleChatRowTexts]); + const chatSelectionEdgeFromMouseY = useCallback((y: number | null): ChatSelectionEdgeDirection | null => { + const topRow = 2 + goalBannerRows; + return chatSelectionEdgeDirectionForMouseY({ + y, + topRow, + rowBudget: chatRowBudget, + scrollOffsetRows: chatScrollOffsetRowsRef.current, + maxScrollOffsetRows: chatScrollMaxOffsetRef.current, + }); + }, [chatRowBudget, goalBannerRows]); useInput((input, key) => { if (attachedTerminalIdRef.current) { @@ -5812,22 +6056,34 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } if (mouse) { const activeSelection = chatMouseSelectionRef.current; if (mouse.kind === "click") { + stopChatSelectionEdgeScroll(); const point = chatPointFromMouse(mouse.x, mouse.y, false); if (point) { focusChat(); - updateChatMouseSelection({ - startRow: point.row, - startColumn: point.column, - endRow: point.row, - endColumn: point.column, - active: true, - }); + const shiftAnchor = chatSelectionAnchorRef.current + ?? (activeSelection ? { row: activeSelection.startRow, column: activeSelection.startColumn } : null); + if (mouse.shift && shiftAnchor) { + updateChatMouseSelection(chatSelectionFromAnchor(shiftAnchor, point, true)); + } else { + chatSelectionAnchorRef.current = point; + updateChatMouseSelection({ + startRow: point.row, + startColumn: point.column, + endRow: point.row, + endColumn: point.column, + active: true, + }); + } return; } + chatSelectionAnchorRef.current = null; if (activeSelection) updateChatMouseSelection(null); } if (mouse.kind === "drag" && activeSelection?.active) { const point = chatPointFromMouse(mouse.x, mouse.y, true); + const edge = chatSelectionEdgeFromMouseY(mouse.y); + if (edge) startChatSelectionEdgeScroll(edge, point?.column ?? activeSelection.endColumn); + else stopChatSelectionEdgeScroll(); if (point) { updateChatMouseSelection({ ...activeSelection, @@ -5839,6 +6095,7 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } return; } if (mouse.kind === "release" && activeSelection?.active) { + stopChatSelectionEdgeScroll(); const point = chatPointFromMouse(mouse.x, mouse.y, true); const next = point ? { @@ -5849,8 +6106,8 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } } : { ...activeSelection, active: false }; const collapsed = next.startRow === next.endRow && next.startColumn === next.endColumn; + chatSelectionAnchorRef.current = { row: next.startRow, column: next.startColumn }; updateChatMouseSelection(collapsed ? null : next); - if (!collapsed) copyChatMouseSelection(next); return; } @@ -5861,21 +6118,26 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } const inCenterPane = mouse.x == null || (mouse.x >= centerStart && mouse.x <= centerEnd); const inTranscriptRows = mouse.y == null || mouse.y > 2; if (mouse.kind === "wheel" && inCenterPane && inTranscriptRows) { - updateChatMouseSelection(null); if (mouse.direction === "up") { setChatScrollOffset((offset) => offset + 3); } else if (mouse.direction === "down") { setChatScrollOffset((offset) => offset - 3); } - } else if (mouse.kind === "click" && rightWidth > 0 && rightPane.kind === "subagents" && mouse.x != null && mouse.y != null) { + } else if ( + mouse.kind === "click" + && rightWidth > 0 + && rightPane.kind === "chat-info" + && mouse.x != null + && mouse.y != null + ) { const rightStart = columns - rightWidth + 1; if (mouse.x >= rightStart) { const subagentPaneTop = 4 + goalBannerRows; - const nextIndex = subagentIndexForPaneLine(rightPane, mouse.y - subagentPaneTop); + const subagentContent = subagentPaneContentFromRightPane(rightPane); + const nextIndex = subagentContent ? subagentIndexForPaneLine(subagentContent, mouse.y - subagentPaneTop, rightSelectionIndex) : null; if (nextIndex != null) { setRightSelectionIndex(nextIndex); } - setChatScrollOffset(0); setRightOpen(true); setPaneFocus("details"); } @@ -5883,8 +6145,6 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } return; } - if (chatMouseSelectionRef.current) updateChatMouseSelection(null); - const pane = activePaneRef.current; const detailsFormActive = pane === "details" && rightOpen && rightPane.kind === "form"; const footerActive = footerControlRef.current != null; @@ -6162,6 +6422,13 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } return; } + if (key.escape && chatMouseSelectionRef.current) { + stopChatSelectionEdgeScroll(); + chatSelectionAnchorRef.current = null; + updateChatMouseSelection(null); + return; + } + if (pane === "chat" && textInputActive && vimModeEnabled && !key.ctrl && !key.meta) { if (key.escape) { setVimMode("normal"); @@ -6194,6 +6461,19 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } } if (key.escape) { + // First Esc unwinds a subagent transcript back to the main chat; the + // right pane stays focused on the main agent's info, so a second Esc + // would close the pane normally. + if ( + pane === "details" + && rightOpen + && rightPane.kind === "chat-info" + && inspectedSubagentId + ) { + setInspectedSubagentId(null); + setChatScrollOffset(0); + return; + } if (pane === "details" && rightOpen) { if (rightPane.kind === "form") { const values = currentFormValues(); @@ -6240,7 +6520,16 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } return; } - if (key.ctrl && input === "c") { + if (key.meta && input.toLowerCase() === "c") { + copyChatSelection(); + return; + } + + if ((key.ctrl && input === "c") || input === "\x03") { + if (isCtrlCCopyPlatform() && isChatTextSelectionRange(chatMouseSelectionRef.current)) { + copyChatSelection(); + return; + } const conn = connectionRef.current; const sessionId = activeSessionIdRef.current; const activeTurnVisible = streaming || activeSession?.status === "active"; @@ -6252,7 +6541,7 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } .catch((err) => addNotice(err instanceof Error ? err.message : String(err), "error")); return; } - requestAppExit(); + requestCtrlCExit(); return; } @@ -6286,23 +6575,27 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } if ( pane === "details" && rightOpen - && rightPane.kind === "subagents" - && (key.upArrow || key.downArrow || key.return || input === "k") + && rightPane.kind === "chat-info" + && (key.upArrow || key.downArrow || key.return) ) { - const rows = buildSubagentPaneRows(rightPane); + const subagentContent = subagentPaneContentFromRightPane(rightPane); + if (!subagentContent) return; + const snapshotRows = buildSubagentPaneRows(subagentContent) + .filter((row): row is Extract => row.kind === "snapshot"); + // Selection: 0 = main row; 1..N = subagent rows. + const selectableCount = snapshotRows.length + 1; if (key.upArrow || key.downArrow) { const delta = key.upArrow ? -1 : 1; - setRightSelectionIndex((index) => rows.length ? (index + delta + rows.length) % rows.length : 0); - setChatScrollOffset(0); + setRightSelectionIndex((index) => (index + delta + selectableCount) % selectableCount); return; } if (key.return) { + const row = rightSelectionIndex > 0 ? snapshotRows[rightSelectionIndex - 1] : null; + const snapshot: SubagentSnapshot | null = row ? row.snapshot : null; + setInspectedSubagentId(snapshot?.id ?? null); setChatScrollOffset(0); return; } - if (input === "k") { - addNotice("Subagent kill is not wired in this TUI yet.", "info"); - } return; } @@ -6717,7 +7010,7 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } const drawerFooterSelected = footerControl === "drawer"; const detailsFooterSelected = footerControl === "details"; const agentsFooterSelected = footerControl === "agents"; - const rightPaneShowsAgents = rightPaneVisible && rightPane.kind === "subagents"; + const rightPaneShowsAgents = rightPaneVisible && rightPane.kind === "chat-info"; const showMentionPalette = activeMentionRange != null && mentionSuggestions.length > 0; const showSlashPalette = prompt.startsWith("/") && slashRows.length > 0; const modelStatusOverlayRows = statusRows @@ -6863,7 +7156,14 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } {` · ${modeDescription(modeChangeNotice.summary)}`} ) : null} - + {promptRows.map((line, index) => { const last = index === promptRows.length - 1; return ( @@ -6895,7 +7195,7 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } inlineRowFocused={inlineRowFocused} inlineRowCell={inlineRowFocus.cell} providerLocked={providerLocked} - subagentsButtonVisible={subagentsButtonVisible} + subagentsButtonVisible={subagentPaneCommandAvailable} planMode={isPlanMode(modelState)} terminalControlAvailable={claudeTerminalControlAvailable} terminalControlActive={claudeTerminalControlActive} diff --git a/apps/ade-cli/src/tuiClient/chatInfo.ts b/apps/ade-cli/src/tuiClient/chatInfo.ts new file mode 100644 index 000000000..4bfe9a7b2 --- /dev/null +++ b/apps/ade-cli/src/tuiClient/chatInfo.ts @@ -0,0 +1,81 @@ +import type { + AgentChatEvent, + AgentChatEventEnvelope, + AgentChatSessionSummary, +} from "../../../desktop/src/shared/types/chat"; +import type { + AdeCodeProvider, + ChatInfoPlan, + ChatInfoSnapshot, + SubagentSnapshot, +} from "./types"; +import type { TokenStats } from "./adeApi"; + +function compactNumber(value: number): string { + 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(value); +} + +function tokenStatsSummary(stats: TokenStats | null): string | null { + if (!stats) return null; + const parts: string[] = []; + if (stats.inputTokens != null || stats.outputTokens != null) { + const input = stats.inputTokens != null ? `+${compactNumber(stats.inputTokens)}` : "+0"; + const output = stats.outputTokens != null ? compactNumber(stats.outputTokens) : "0"; + parts.push(`${input}/${output}`); + } + if (stats.cacheReadTokens != null && stats.cacheReadTokens > 0) { + parts.push(`(${compactNumber(stats.cacheReadTokens)}✶)`); + } + if (stats.costUsd != null) { + parts.push(`$${stats.costUsd.toFixed(2)}`); + } + return parts.length ? parts.join(" ") : null; +} + +function planFromEvent(event: Extract): ChatInfoPlan { + const completed = event.steps.filter((step) => step.status === "completed").length; + const inProgress = event.steps.findIndex((step) => step.status === "in_progress"); + const current = inProgress >= 0 ? inProgress + 1 : completed; + return { + current, + total: event.steps.length, + steps: event.steps.map((step) => ({ text: step.text, status: step.status })), + live: event.steps.some((step) => step.status === "in_progress"), + }; +} + +function latestPlan(events: AgentChatEventEnvelope[]): ChatInfoPlan { + for (let index = events.length - 1; index >= 0; index -= 1) { + const event = events[index]?.event; + if (event?.type === "plan") return planFromEvent(event); + } + return null; +} + +export function deriveChatInfoSnapshot(args: { + events: AgentChatEventEnvelope[]; + activeSession: AgentChatSessionSummary | null; + provider: AdeCodeProvider; + modelLabel: string; + laneLabel: string | null; + snapshots: SubagentSnapshot[]; + tokenStats: TokenStats | null; + goal: ChatInfoSnapshot["goal"]; + streaming: boolean; + inspectedSubagentId?: string | null; +}): ChatInfoSnapshot { + return { + provider: (args.activeSession?.provider ?? args.provider) as AdeCodeProvider, + modelLabel: args.modelLabel, + laneLabel: args.laneLabel, + contextPercent: args.tokenStats?.percent ?? null, + tokenSummary: tokenStatsSummary(args.tokenStats), + goal: args.goal, + plan: latestPlan(args.events), + snapshots: args.snapshots, + inspectedSubagentId: args.inspectedSubagentId ?? null, + streaming: args.streaming, + }; +} diff --git a/apps/ade-cli/src/tuiClient/commands.ts b/apps/ade-cli/src/tuiClient/commands.ts index dedd6231e..5924ab2e9 100644 --- a/apps/ade-cli/src/tuiClient/commands.ts +++ b/apps/ade-cli/src/tuiClient/commands.ts @@ -16,7 +16,6 @@ export const BUILTIN_COMMANDS: BuiltinCommand[] = [ { name: "/pull", description: "Pull the active lane branch", placement: "inline" }, { name: "/stage all", description: "Stage all changes in the active lane", placement: "inline" }, { name: "/clear", description: "Clear the local terminal transcript view", placement: "inline" }, - { name: "/copy", description: "Copy the visible chat transcript", placement: "inline" }, { name: "/login", description: "Sign in to the active CLI-backed provider from this terminal", placement: "inline" }, { name: "/open", description: "Open this ADE context in desktop", placement: "inline" }, { name: "/quit", description: "Exit ade code", placement: "inline" }, @@ -35,14 +34,14 @@ export const BUILTIN_COMMANDS: BuiltinCommand[] = [ { name: "/status", description: "Show project, lane, and runtime state", placement: "right" }, { name: "/context", description: "Show Claude context usage", placement: "right", providers: ["claude"] }, { name: "/agents", description: "List Claude agents from user and project config", placement: "right", providers: ["claude"] }, - { name: "/subagents", description: "Show live subagents and teammates for the active chat", placement: "right" }, + { name: "/info", description: "Open active chat info, plan, goal, and agents", placement: "right" }, { name: "/skills", description: "List Claude skills from user and project config", placement: "right", 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, clear, or inspect the active chat goal", placement: "chat", argumentHint: "[|clear|status active|paused|complete|budget |budget clear]", providers: ["claude", "codex"] }, + { name: "/goal", description: "Set, clear, or inspect the active chat goal", placement: "chat", argumentHint: "[|clear|status active|paused|complete]", providers: ["claude", "codex"] }, { name: "/diff", description: "Show active lane diff", placement: "right" }, { name: "/log", description: "Show recent commits", placement: "right" }, { name: "/reparent", description: "Move the active lane under another lane", placement: "right", argumentHint: " [stack-base-ref]" }, diff --git a/apps/ade-cli/src/tuiClient/components/ChatView.tsx b/apps/ade-cli/src/tuiClient/components/ChatView.tsx index 0aba5dc32..695450d21 100644 --- a/apps/ade-cli/src/tuiClient/components/ChatView.tsx +++ b/apps/ade-cli/src/tuiClient/components/ChatView.tsx @@ -16,6 +16,7 @@ import { type AggregatedBlock, type FileChangeEntry, type PlanStep, + type RuntimeActivityEntry, type ToolCallEntry, type WorkToolStatus, } from "../aggregate"; @@ -41,6 +42,7 @@ type RenderedChatRow = { italic?: boolean; rail?: string | null; runs?: InlineRun[]; + sourceRowIndex?: number | null; }; export type ChatTextSelection = { @@ -50,6 +52,11 @@ export type ChatTextSelection = { endColumn: number; }; +export type ChatVisibleSelectionRow = { + sourceRow: number | null; + text: string; +}; + function textWidth(value: string): number { return [...value].length; } @@ -570,6 +577,12 @@ const WORK_STATUS_COLOR: Record = { ok: theme.color.running, failed: theme.color.error, }; +const ACTIVITY_STATUS_COLOR: Record = { + running: theme.color.violet, + ok: theme.color.running, + failed: theme.color.error, + info: theme.color.t4, +}; // How many entries to render inline per work-group before collapsing into "+N more". // Tight by design: the TUI can't expand groups, so flooding with all tools is just noise. @@ -596,6 +609,13 @@ function statusGlyph(status: WorkToolStatus, spinFrame: string): string { return "✓"; } +function activityStatusGlyph(status: RuntimeActivityEntry["status"], spinFrame: string): string { + if (status === "failed") return "✗"; + if (status === "running") return spinFrame; + if (status === "ok") return "✓"; + return "·"; +} + function truncateLongLine(value: string): string { if (textWidth(value) <= LONG_LINE_TRUNCATE_AT) return value; return `${[...value].slice(0, LONG_LINE_TRUNCATE_AT - 1).join("")}…`; @@ -762,6 +782,41 @@ function filesChangedGroupRows( return out; } +function runtimeActivityRows( + block: Extract, + spinFrame: string, +): RenderedChatRow[] { + if (!block.entries.length) return []; + const ok = block.entries.filter((entry) => entry.status === "ok").length; + const failed = block.entries.filter((entry) => entry.status === "failed").length; + const out: RenderedChatRow[] = [{ + id: block.id, + tone: "work", + text: `▸ Runtime (${block.entries.length})`, + runs: groupHeaderRuns("Runtime", block.entries.length, block.live ? null : { ok, failed }, spinFrame), + rail: null, + }]; + const { shown, remaining } = visibleEntries(block.entries); + for (const entry of shown) { + const glyph = activityStatusGlyph(entry.status, spinFrame); + const runs: InlineRun[] = [ + { text: " " }, + { text: glyph, color: ACTIVITY_STATUS_COLOR[entry.status] }, + { text: ` ${entry.label}`, color: theme.color.t1 }, + ]; + if (entry.detail) runs.push({ text: ` ${truncateLongLine(entry.detail)}`, color: theme.color.t3 }); + out.push({ + id: `${block.id}:${entry.id}`, + tone: "work", + text: ` ${glyph} ${entry.label}${entry.detail ? ` ${entry.detail}` : ""}`, + runs, + rail: null, + }); + } + if (remaining > 0) out.push(moreLineRow(block.id, remaining)); + return out; +} + function memoryRows(block: Extract, brailleFrame: string): RenderedChatRow[] { const text = block.live ? `· memory ${brailleFrame}` @@ -845,11 +900,22 @@ function planRows(block: Extract, spinFrame: return out; } -function modelWorkingRows(dots: string): RenderedChatRow[] { +function activeTurnRows(blocks: AggregatedBlock[], dots: string): RenderedChatRow[] { + let activeTurnStart = -1; + for (let index = blocks.length - 1; index >= 0; index -= 1) { + if (blocks[index]?.kind === "user-bubble") { + activeTurnStart = index; + break; + } + } + const activeTurnBlocks = activeTurnStart >= 0 ? blocks.slice(activeTurnStart + 1) : blocks; + const hasLiveBlock = activeTurnBlocks.some((block) => "live" in block && block.live); + const hasAssistantOutput = activeTurnBlocks.some((block) => block.kind === "assistant-text"); + if (hasLiveBlock || hasAssistantOutput) return []; return [{ - id: "model-working", + id: "active-turn-waiting", tone: "work", - text: `✦ model working${dots}`, + text: `✦ active turn · waiting for runtime events${dots}`, color: theme.color.violet, bold: true, rail: null, @@ -937,6 +1003,8 @@ function rowsForBlock( return toolCallsGroupRows(block, spinFrame); case "files-changed-group": return filesChangedGroupRows(block, width, spinFrame); + case "runtime-activity": + return runtimeActivityRows(block, spinFrame); case "memory": return memoryRows(block, brailleFrame); case "compaction": @@ -991,18 +1059,19 @@ function sliceRows( scrollOffsetRows = 0, unseenMessageCount = 0, ): RenderedChatRow[] { - if (!maxRows || maxRows <= 0) return rows; + const indexedRows = rows.map((row, index) => ({ ...row, sourceRowIndex: index })); + if (!maxRows || maxRows <= 0) return indexedRows; const viewportRows = Math.max(1, maxRows); - if (rows.length <= viewportRows) { + if (indexedRows.length <= viewportRows) { return [ - ...rows, - ...Array.from({ length: viewportRows - rows.length }, (_, index) => ( - spacerRow(`scroll-filler:${rows.length + index}`) + ...indexedRows, + ...Array.from({ length: viewportRows - indexedRows.length }, (_, index) => ( + spacerRow(`scroll-filler:${indexedRows.length + index}`) )), ]; } - const offset = Math.max(0, Math.min(scrollOffsetRows, maxScrollOffsetForRows(rows.length, viewportRows))); - const end = Math.max(1, rows.length - offset); + const offset = Math.max(0, Math.min(scrollOffsetRows, maxScrollOffsetForRows(indexedRows.length, viewportRows))); + const end = Math.max(1, indexedRows.length - offset); const hasNewer = offset > 0; let contentRows = Math.max(1, viewportRows - (hasNewer ? 1 : 0)); let start = Math.max(0, end - contentRows); @@ -1011,7 +1080,7 @@ function sliceRows( contentRows = Math.max(1, viewportRows - 1 - (hasNewer ? 1 : 0)); start = Math.max(0, end - contentRows); } - const visible = rows.slice(start, end); + const visible = indexedRows.slice(start, end); const result: RenderedChatRow[] = []; if (hasOlder) { result.push({ id: "older-indicator", tone: "indicator", text: "↑ older messages", dim: true, rail: null }); @@ -1112,16 +1181,16 @@ function splitTextByColumns(value: string, start: number, end: number): [string, function ChatRow({ row, - rowIndex, selection, }: { row: RenderedChatRow; - rowIndex: number; selection?: ChatTextSelection | null; }) { const railColor = row.rail === undefined ? railColorForTone(row.tone) : row.rail; const plainText = row.text || BLANK_ROW_TEXT; - const selectedRange = selection ? selectedRangeForRow(rowIndex, selection, textWidth(renderedRowText(row))) : null; + const selectedRange = selection && typeof row.sourceRowIndex === "number" + ? selectedRangeForRow(row.sourceRowIndex, selection, textWidth(renderedRowText(row))) + : null; if (selectedRange) { const [before, selected, after] = splitTextByColumns(renderedRowText(row), selectedRange[0], selectedRange[1]); return ( @@ -1160,6 +1229,30 @@ function isPaginationIndicatorRow(row: RenderedChatRow): boolean { return row.id === "older-indicator" || row.id === "newer-indicator"; } +function selectableRowsForBlocks({ + blocks, + width = DEFAULT_VIEW_WIDTH, + streaming = false, + interrupted = false, + brailleFrame = "·", + spinFrame = "◐", + dotPulse = "", +}: { + blocks: AggregatedBlock[]; + width?: number; + streaming?: boolean; + interrupted?: boolean; + brailleFrame?: string; + spinFrame?: string; + dotPulse?: string; +}): RenderedChatRow[] { + const innerWidth = Math.max(24, width - 4); + const baseRows = rowsForBlocks(blocks, innerWidth, brailleFrame, spinFrame); + if (streaming) return [...baseRows, ...activeTurnRows(blocks, dotPulse)]; + if (interrupted) return [...baseRows, ...modelInterruptedRows()]; + return baseRows; +} + function visibleRowsForBlocks({ blocks, maxRows, @@ -1183,14 +1276,16 @@ function visibleRowsForBlocks({ spinFrame?: string; dotPulse?: string; }): RenderedChatRow[] { - const innerWidth = Math.max(24, width - 4); - const baseRows = rowsForBlocks(blocks, innerWidth, brailleFrame, spinFrame); return sliceRows( - streaming - ? [...baseRows, ...modelWorkingRows(dotPulse)] - : interrupted - ? [...baseRows, ...modelInterruptedRows()] - : baseRows, + selectableRowsForBlocks({ + blocks, + width, + streaming, + interrupted, + brailleFrame, + spinFrame, + dotPulse, + }), maxRows, scrollOffsetRows, unseenMessageCount, @@ -1237,7 +1332,81 @@ export function renderChatVisibleRowTexts({ }).map(renderedRowText); } -export function selectedTextFromVisibleChatRows(rows: string[], selection: ChatTextSelection): string { +export function renderChatVisibleSelectionRows({ + events, + notices, + activeSession, + expandedLineIds, + maxRows, + scrollOffsetRows = 0, + unseenMessageCount = 0, + width = DEFAULT_VIEW_WIDTH, + streaming = false, + interrupted = false, +}: { + events: AgentChatEventEnvelope[]; + notices: LocalNotice[]; + activeSession: AgentChatSessionSummary | null; + expandedLineIds?: Set; + maxRows?: number; + scrollOffsetRows?: number; + unseenMessageCount?: number; + width?: number; + streaming?: boolean; + interrupted?: boolean; +}): ChatVisibleSelectionRow[] { + const blocks = aggregateChatBlocks({ + events, + notices, + activeSession, + expandedLineIds, + }); + return visibleRowsForBlocks({ + blocks, + maxRows, + scrollOffsetRows, + unseenMessageCount, + width, + streaming, + interrupted, + }).map((row) => ({ + sourceRow: typeof row.sourceRowIndex === "number" ? row.sourceRowIndex : null, + text: renderedRowText(row), + })); +} + +export function renderChatSelectableRowTexts({ + events, + notices, + activeSession, + expandedLineIds, + width = DEFAULT_VIEW_WIDTH, + streaming = false, + interrupted = false, +}: { + events: AgentChatEventEnvelope[]; + notices: LocalNotice[]; + activeSession: AgentChatSessionSummary | null; + expandedLineIds?: Set; + width?: number; + streaming?: boolean; + interrupted?: boolean; +}): string[] { + const blocks = aggregateChatBlocks({ + events, + notices, + activeSession, + expandedLineIds, + }); + return selectableRowsForBlocks({ + blocks, + width, + streaming, + interrupted, + }).map(renderedRowText); +} + +export function selectedTextFromChatRows(rows: string[], selection: ChatTextSelection): string { const normalized = normalizeSelection(selection); const selected: string[] = []; for (let rowIndex = normalized.startRow; rowIndex <= normalized.endRow; rowIndex += 1) { @@ -1250,7 +1419,7 @@ export function selectedTextFromVisibleChatRows(rows: string[], selection: ChatT } selected.push(chars.slice(range[0], range[1]).join("")); } - return selected.join("\n").trimEnd(); + return selected.join("\n"); } export function renderChatTranscriptPlainText({ @@ -1311,7 +1480,9 @@ export function computeChatScrollMaxOffset({ }); if (!blocks.length && !streaming && !interrupted) return 0; const innerWidth = Math.max(24, width - 4); - const statusRows = streaming ? modelWorkingRows("").length : interrupted ? modelInterruptedRows().length : 0; + let statusRows = 0; + if (streaming) statusRows = activeTurnRows(blocks, "").length; + else if (interrupted) statusRows = modelInterruptedRows().length; const rowCount = rowsForBlocks(blocks, innerWidth, "·", "◐").length + statusRows; return maxScrollOffsetForRows(rowCount, maxRows); } @@ -1398,7 +1569,7 @@ export function ChatView({ return ( {rows.map((row, index) => ( - + ))} ); diff --git a/apps/ade-cli/src/tuiClient/components/FooterControls.tsx b/apps/ade-cli/src/tuiClient/components/FooterControls.tsx index 1c36b384a..8b921233c 100644 --- a/apps/ade-cli/src/tuiClient/components/FooterControls.tsx +++ b/apps/ade-cli/src/tuiClient/components/FooterControls.tsx @@ -181,7 +181,9 @@ export function FooterControls({ <> {" "} {(() => { - const subagentValue = `⊚ ${agents} subagent${agents === 1 ? '' : 's'}`; + const subagentValue = agents > 0 + ? `⊚ chat info · ${agents}` + : "⊚ chat info"; const isFocused = inlineRowCell === 'subagents'; if (rowFocused && isFocused) { return ( @@ -244,9 +246,9 @@ export function FooterControls({ <> {" "} - + {" "} - + {" "} {" "} diff --git a/apps/ade-cli/src/tuiClient/components/RightPane.tsx b/apps/ade-cli/src/tuiClient/components/RightPane.tsx index ee7a144d9..785cb2600 100644 --- a/apps/ade-cli/src/tuiClient/components/RightPane.tsx +++ b/apps/ade-cli/src/tuiClient/components/RightPane.tsx @@ -2,12 +2,13 @@ import React from "react"; import { Box, Text } from "ink"; import type { AdeCodeProvider, + ChatInfoPlanStep, + ChatInfoSnapshot, RightPaneContent, SubagentSnapshot, } from "../types"; import { theme } from "../theme"; -import { useSpinFrame } from "../spinTick"; -import { buildSubagentPaneRows, type SubagentPaneRow, type SubagentPaneSection } from "../subagentPane"; +import { buildSubagentPaneRows, type SubagentPaneRow } from "../subagentPane"; // --------------------------------------------------------------------------- // Right-pane width / focus chrome @@ -352,7 +353,7 @@ function LaneDetailsPane({ ) : null} - + {content.run ? ( ) : ( @@ -367,12 +368,26 @@ function LaneDetailsPane({ } // --------------------------------------------------------------------------- -// Agents pane (Subagents · Teammates) — htop process table, runtime-adaptive +// Chat-info pane — main agent dashboard + subagent roster (cross-pane navigator) // --------------------------------------------------------------------------- -function subagentExec(snapshot: SubagentSnapshot, provider: AdeCodeProvider): AdeCodeProvider | "copilot" { - if (snapshot.kind === "teammate") return "copilot"; - return provider; +function planStepColor(status: ChatInfoPlanStep["status"]): string { + if (status === "completed") return theme.color.done; + if (status === "failed") return theme.color.error; + if (status === "in_progress") return theme.color.running; + return theme.color.t4; +} + +function planStepGlyph(status: ChatInfoPlanStep["status"]): string { + if (status === "completed") return "✓"; + if (status === "failed") return "✗"; + if (status === "in_progress") return "◐"; + return "○"; +} + +function secondsElapsed(seconds: number | null | undefined): string { + if (seconds == null || !Number.isFinite(seconds)) return "—"; + return formatElapsed(Math.max(0, seconds) * 1000); } function subagentAgentKind(status: SubagentSnapshot["status"]): "running" | "ok" | "waiting" | "error" { @@ -382,244 +397,213 @@ function subagentAgentKind(status: SubagentSnapshot["status"]): "running" | "ok" return "error"; } -function SpinningAgentGlyph({ color }: { color: string }) { - const frame = useSpinFrame(); - return {frame}; +function isGenericSubagentSummary(value: string | undefined): boolean { + const text = (value ?? "").trim().toLowerCase(); + return !text + || text === "agent closed" + || text === "stopped" + || text === "agent closedstopped" + || text.startsWith("parent turn ended before ade received"); } -function subagentSectionTitle(section: SubagentPaneSection): string { - if (section === "main") return "MAIN"; - if (section === "teammates") return "TEAMMATES"; - if (section === "background") return "BACKGROUND"; - if (section === "recent") return "RECENT · DONE"; - return "SUBAGENTS"; +function rosterRowDetail(snapshot: SubagentSnapshot): string | null { + const parts = [snapshot.lastToolName, snapshot.summary] + .filter((value): value is string => !isGenericSubagentSummary(value)); + const unique = parts.filter((part, index) => parts.indexOf(part) === index); + return unique.length ? unique.join(" · ") : null; } -function subagentSectionCount(rows: SubagentPaneRow[], section: SubagentPaneSection): number { - return rows.filter((row) => row.section === section).length; +function ChatInfoSectionHead({ title, hint, color }: { title: string; hint?: string; color: string }) { + return ( + + {title} + {hint ? {hint} : null} + + ); } -function SubagentMainRow({ - selected, - provider, - nameWidth, -}: { - selected: boolean; - provider: AdeCodeProvider; - nameWidth: number; -}) { - const brand = theme.provider(provider); +function ChatInfoHeader({ info, width }: { info: ChatInfoSnapshot; width: number }) { + const brand = theme.provider(info.provider); + const inner = Math.max(10, width - 4); return ( - {selected ? theme.rail : " "} - - - 00 - - - {` ${endTruncate("main", nameWidth).padEnd(nameWidth)}`} + {brand.glyph} + {` ${endTruncate(info.modelLabel, inner - 2)}`} + + {info.laneLabel ? ( + + lane + · + {endTruncate(info.laneLabel, Math.max(6, inner - 7))} + + ) : null} + + + {info.streaming ? "●" : "○"} {info.streaming ? "active" : "idle"} - {" — — —"} + {info.contextPercent != null ? {` · ${info.contextPercent}% ctx`} : null} + {info.tokenSummary ? {` · ${info.tokenSummary}`} : null} - {` ${brand.label} · main chat`} ); } -function SubagentRow({ - snapshot, - displayIndex, - selected, - provider, - animateRunningGlyph, - nameWidth, -}: { - snapshot: SubagentSnapshot; - displayIndex: number; - selected: boolean; - provider: AdeCodeProvider; - animateRunningGlyph: boolean; - nameWidth: number; -}) { - const kind = subagentAgentKind(snapshot.status); - const glyph = theme.agentStatusGlyph(kind); - const glyphColor = theme.agentStatusColor(kind); - const tok = formatTokens(snapshot.tokens ?? null); - const elapsed = formatElapsed(snapshot.durationMs ?? null); - const cost = "—"; - const id = String(displayIndex).padStart(2, "0"); - const exec = subagentExec(snapshot, provider); - const faded = snapshot.status === "stopped" || snapshot.status === "failed"; - const nameColor = selected ? theme.color.violet : faded ? theme.color.t4 : theme.color.t1; - const detail = snapshot.lastToolName || snapshot.summary; - +function ChatInfoPlanBlock({ info, brandColor, width }: { info: ChatInfoSnapshot; brandColor: string; width: number }) { + const plan = info.plan; + const inner = Math.max(10, width - 4); + if (!plan || !plan.steps.length) { + return ( + + + No plan yet. + + ); + } return ( - - {/* rail/select */} - - {selected ? theme.rail : " "} + + {plan.steps.slice(0, 6).map((step, index) => ( + + {planStepGlyph(step.status)} {endTruncate(step.text, inner - 2)} - - {/* status glyph */} - {kind === "running" && animateRunningGlyph ? ( - - ) : ( - {glyph} - )} - - {/* id */} - {id} - - {/* exec glyph + name */} - - {` ${endTruncate(snapshot.name, nameWidth).padEnd(nameWidth)}`} - {/* tok / elapsed / cost (right side; not literally right-aligned in Ink) */} - {` ${tok.padStart(5)}`} - {` ${elapsed.padStart(4)}`} - {` ${cost.padStart(3)}`} - - {detail ? ( - {endTruncate(detail, Math.max(12, nameWidth + 16))} + ))} + + ); +} + +function ChatInfoGoalBlock({ info, brandColor, width }: { info: ChatInfoSnapshot; brandColor: string; width: number }) { + if (info.provider !== "codex" || !info.goal) return null; + const goal = info.goal; + const inner = Math.max(10, width - 4); + return ( + + + {goal.objective ? ( + {endTruncate(goal.objective, inner)} ) : null} ); } -function SubagentsPane({ - content, +function rosterWindow(rowCount: number, selected: number, capacity: number): { start: number; end: number } { + if (rowCount <= capacity) return { start: 0, end: rowCount }; + const half = Math.floor(capacity / 2); + let start = Math.max(0, selected - half); + let end = start + capacity; + if (end > rowCount) { + end = rowCount; + start = end - capacity; + } + return { start, end }; +} + +function ChatInfoRoster({ + info, selectedIndex, + brandColor, width, }: { - content: Extract; + info: ChatInfoSnapshot; selectedIndex: number; + brandColor: string; width: number; }) { - const provider = content.provider; - const isDroid = provider === "droid"; + const inner = Math.max(10, width - 4); + const snapshotRows = buildSubagentPaneRows(info) + .filter((row): row is Extract => row.kind === "snapshot"); + const runCount = snapshotRows.filter((row) => row.snapshot.status === "running").length; + const doneCount = snapshotRows.filter((row) => row.snapshot.status === "completed").length; + const failedCount = snapshotRows.filter((row) => row.snapshot.status === "failed").length; + // Selection convention: 0 = main row; 1..N = subagent rows (1-indexed). + const totalSelectable = snapshotRows.length + 1; + const selected = Math.max(0, Math.min(selectedIndex, totalSelectable - 1)); + const mainSelected = selected === 0; + const showingMain = !info.inspectedSubagentId; + const hint = snapshotRows.length === 0 + ? "0 live" + : `${runCount} live · ${doneCount} done${failedCount ? ` · ${failedCount} failed` : ""}`; + + const ROSTER_CAPACITY = 5; + const subagentSelectedIndex = mainSelected ? -1 : selected - 1; + const window = rosterWindow(snapshotRows.length, Math.max(0, subagentSelectedIndex), ROSTER_CAPACITY); + const visibleSlice = snapshotRows.slice(window.start, window.end); + const hiddenBefore = window.start; + const hiddenAfter = snapshotRows.length - window.end; - if (isDroid && content.snapshots.length === 0) { - return ( - - - - Droid runs over ACP, which doesn't model + return ( + + + {/* Main row — always present, tagged with the current middle-pane state */} + + + {mainSelected ? theme.rail : " "} + + {" main"} - - subagents yet — see agentclientprotocol.com. - - - See /status for session state. - + {showingMain ? "viewing" : "return ↵"} - ); - } - - if (content.snapshots.length === 0) { - return ( - - No subagents yet. - - Subagents and teammates spawned by the active chat appear here. - + {snapshotRows.length === 0 ? ( + {" "}no subagents yet + ) : ( + <> + {hiddenBefore > 0 ? ( + {` ↑ ${hiddenBefore} earlier`} + ) : null} + {visibleSlice.map((row, sliceIndex) => { + const rosterIndex = window.start + sliceIndex; + const isSelected = !mainSelected && subagentSelectedIndex === rosterIndex; + const kind = subagentAgentKind(row.snapshot.status); + const statusColor = theme.agentStatusColor(kind); + const inspected = info.inspectedSubagentId === row.snapshot.id; + const detail = rosterRowDetail(row.snapshot); + return ( + + + {isSelected ? theme.rail : " "} + {` ${theme.agentStatusGlyph(kind)}`} + + {` ${endTruncate(row.snapshot.name, Math.max(6, inner - 18))}`} + + {` ${formatElapsed(row.snapshot.durationMs ?? null)}`} + + {isSelected && detail ? ( + + {` › ${endTruncate(detail, Math.max(8, inner - 8))}`} + + ) : null} + + ); + })} + {hiddenAfter > 0 ? ( + {` ↓ ${hiddenAfter} more`} + ) : null} + + )} + + ↑↓ focus · ↵ swap · esc → main - ); - } - - const rows = buildSubagentPaneRows(content); - const snapshots = rows.filter((row): row is Extract => row.kind === "snapshot"); - const subagentsCount = subagentSectionCount(rows, "subagents") + subagentSectionCount(rows, "recent"); - const teammatesCount = subagentSectionCount(rows, "teammates"); - const backgroundCount = subagentSectionCount(rows, "background"); - const selected = Math.max(0, Math.min(selectedIndex, Math.max(0, rows.length - 1))); - const nameWidth = Math.max(4, Math.min(22, width - 27)); - let runCount = 0; - let doneCount = 0; - let waitCount = 0; - let totalTok = 0; - let totalMs = 0; - for (const { snapshot: s } of snapshots) { - const k = subagentAgentKind(s.status); - if (k === "running") runCount++; - else if (k === "ok") doneCount++; - else if (k === "waiting") waitCount++; - if (s.tokens) totalTok += s.tokens; - if (s.durationMs) totalMs += s.durationMs; - } + + ); +} +function ChatInfoPane({ + info, + selectedIndex, + width, +}: { + info: ChatInfoSnapshot; + selectedIndex: number; + width: number; +}) { + const brand = theme.provider(info.provider); return ( - {/* Tab strip */} - - - Subagents · {subagentsCount} - Teammates · {teammatesCount} - - Background · {backgroundCount} - - - {/* Column header */} - - {` ID ${"NAME".padEnd(nameWidth + 2)} TOK ELAPSED $`} - - - {/* Rows */} - {rows.map((row, i) => { - const previous = rows[i - 1]; - const showSection = row.section !== "main" && previous?.section !== row.section; - const displayIndex = i; - return ( - - {showSection ? ( - - {subagentSectionTitle(row.section)} - {"─".repeat(Math.max(3, width - subagentSectionTitle(row.section).length - 6))} - - ) : null} - {row.kind === "main" ? ( - - ) : ( - - )} - - ); - })} - - {/* Footer summary */} - {snapshots.length ? ( - - - {` ${runCount} run · `} - - {` ${doneCount} done · `} - - {` ${waitCount} wait`} - - ) : null} - {snapshots.length ? ( - - {formatTokens(totalTok)} tok · {formatElapsed(totalMs)} - - ) : null} - - - - ↑↓ select · transcript follows · tab returns main - - + + + + ); } @@ -634,9 +618,9 @@ function HelpPane() { ↓ from prompt enters the model row; ↑ returns in the row: ← → moves between cells, ↓ cycles values - /model focuses the model row · /subagents opens the agents pane + /model focuses the model row · /info opens chat info ctrl-o opens or focuses lanes and chats - ctrl-p opens or focuses info · ctrl-a toggles subagents + ctrl-p opens or focuses info · ctrl-a toggles chat info shift-tab cycles pane focus · esc closes the active side pane ctrl-c interrupts a running chat; press again to quit / opens commands, @ opens references, tab inserts selected @@ -656,8 +640,8 @@ function paneTitle(content: RightPaneContent): { title: string; hint?: string } }; case "new-chat-setup": return { title: "NEW CHAT" }; - case "subagents": - return { title: `AGENTS · ${theme.provider(content.provider).label.toUpperCase()}`, hint: "tab · cycle" }; + case "chat-info": + return { title: `CHAT INFO · ${theme.provider(content.info.provider).label.toUpperCase()}` }; case "help": return { title: "HELP" }; case "status": @@ -774,8 +758,8 @@ export function RightPane({ ) : null} - {content.kind === "subagents" ? ( - + {content.kind === "chat-info" ? ( + ) : null} {content.kind === "new-chat-setup" ? ( diff --git a/apps/ade-cli/src/tuiClient/subagentPane.ts b/apps/ade-cli/src/tuiClient/subagentPane.ts index 0b8aed218..5d4b77a5f 100644 --- a/apps/ade-cli/src/tuiClient/subagentPane.ts +++ b/apps/ade-cli/src/tuiClient/subagentPane.ts @@ -3,60 +3,66 @@ import type { AgentChatEventEnvelope, AgentChatSessionSummary, } from "../../../desktop/src/shared/types/chat"; -import type { RightPaneContent, SubagentSnapshot } from "./types"; +import type { AdeCodeProvider, RightPaneContent, SubagentSnapshot } from "./types"; import { workEventItemId, workEventParentItemId } from "./workEventIds"; -export type SubagentPaneSection = "main" | "subagents" | "teammates" | "background" | "recent"; +export type SubagentPaneSection = "main" | "subagents" | "teammates" | "background"; export type SubagentPaneRow = | { kind: "main"; key: "main"; section: "main"; label: string } | { kind: "snapshot"; key: string; section: Exclude; snapshot: SubagentSnapshot }; +// Vertical offset of the first selectable roster row in the rendered chat-info +// pane (header + status + plan + goal occupy the preceding lines). Used only by +// the mouse-click → row mapper. const SUBAGENT_PANE_TABLE_START_LINE = 4; -export function buildSubagentPaneRows(content: Extract): SubagentPaneRow[] { - const runningSubagents = content.snapshots.filter((snap) => ( +export type SubagentPaneContent = { + provider: AdeCodeProvider; + snapshots: SubagentSnapshot[]; +}; + +export function subagentPaneContentFromRightPane(content: RightPaneContent): SubagentPaneContent | null { + if (content.kind === "chat-info") { + return { + provider: content.info.provider, + snapshots: content.info.snapshots, + }; + } + return null; +} + +export function buildSubagentPaneRows(content: SubagentPaneContent): SubagentPaneRow[] { + const foregroundSubagents = content.snapshots.filter((snap) => ( snap.kind === "subagent" && snap.background !== true - && snap.status === "running" )); + const runningWeight = (snap: SubagentSnapshot): number => (snap.status === "running" ? 0 : 1); + const sortedForegroundSubagents = [...foregroundSubagents].sort( + (left, right) => runningWeight(left) - runningWeight(right), + ); const teammates = content.snapshots.filter((snap) => snap.kind === "teammate"); const background = content.snapshots.filter((snap) => snap.kind === "subagent" && snap.background === true); - const recent = content.snapshots.filter((snap) => ( - snap.kind === "subagent" - && snap.background !== true - && snap.status !== "running" - )); return [ { kind: "main", key: "main", section: "main", label: "main" }, - ...runningSubagents.map((snapshot) => ({ kind: "snapshot" as const, key: snapshot.id, section: "subagents" as const, snapshot })), + ...sortedForegroundSubagents.map((snapshot) => ({ kind: "snapshot" as const, key: snapshot.id, section: "subagents" as const, snapshot })), ...teammates.map((snapshot) => ({ kind: "snapshot" as const, key: snapshot.id, section: "teammates" as const, snapshot })), ...background.map((snapshot) => ({ kind: "snapshot" as const, key: snapshot.id, section: "background" as const, snapshot })), - ...recent.map((snapshot) => ({ kind: "snapshot" as const, key: snapshot.id, section: "recent" as const, snapshot })), ]; } export function selectedSubagentSnapshot( - content: Extract, + content: SubagentPaneContent, selectedIndex: number, ): SubagentSnapshot | null { const row = buildSubagentPaneRows(content)[selectedIndex] ?? null; return row?.kind === "snapshot" ? row.snapshot : null; } -export function clampSubagentSelection( - content: Extract, - selectedIndex: number, -): number { - const rowCount = buildSubagentPaneRows(content).length; - if (rowCount <= 0) return 0; - if (!Number.isFinite(selectedIndex)) return 0; - return Math.max(0, Math.min(Math.floor(selectedIndex), rowCount - 1)); -} - export function subagentPaneSelectableLineOffsets( - content: Extract, + content: SubagentPaneContent, + selectedIndex = 0, ): number[] { const rows = buildSubagentPaneRows(content); const offsets: number[] = []; @@ -69,7 +75,10 @@ export function subagentPaneSelectableLineOffsets( if (showSection) line += 2; offsets.push(line); line += 1; - if (row.kind === "main" || row.snapshot.lastToolName || row.snapshot.summary) { + const selectedSnapshotHasDetail = row.kind === "snapshot" + && index === selectedIndex + && (row.snapshot.lastToolName || row.snapshot.summary); + if (row.kind === "main" || selectedSnapshotHasDetail) { line += 1; } } @@ -78,11 +87,12 @@ export function subagentPaneSelectableLineOffsets( } export function subagentIndexForPaneLine( - content: Extract, + content: SubagentPaneContent, line: number, + selectedIndex = 0, ): number | null { if (!Number.isFinite(line)) return null; - const offsets = subagentPaneSelectableLineOffsets(content); + const offsets = subagentPaneSelectableLineOffsets(content, selectedIndex); if (!offsets.length) return null; const first = offsets[0]!; const last = offsets[offsets.length - 1]!; @@ -142,7 +152,8 @@ function isLifecycleEventForSnapshot(event: AgentChatEvent, snapshot: SubagentSn ) { return false; } - if (eventSubagentIds(event).includes(snapshot.id)) return true; + const explicitIds = eventSubagentIds(event); + if (explicitIds.length > 0) return explicitIds.includes(snapshot.id); const parentToolUseId = eventParentToolUseId(event); return Boolean(snapshot.parentToolUseId && parentToolUseId === snapshot.parentToolUseId); } @@ -158,7 +169,7 @@ function lifecycleText(event: AgentChatEvent, snapshot: SubagentSnapshot): strin status?: unknown; }; if (type === "subagent_started" || type === "subagent.started") { - return `Subagent started: ${textField(record.description) ?? snapshot.name}`; + return "Started."; } if (type === "subagent_progress" || type === "subagent.progress") { return textField(record.summary) ?? textField(record.text) ?? null; @@ -222,7 +233,7 @@ export function buildSubagentTranscriptEvents(args: { args.snapshot.startedAt ?? args.events[0]?.timestamp ?? new Date(0).toISOString(), -2, args.snapshot.turnId, - `Viewing ${args.snapshot.kind === "teammate" ? "teammate" : args.snapshot.background ? "background agent" : "subagent"}: ${args.snapshot.name}\nLeave the agents pane to return to the main chat.`, + `Viewing ${args.snapshot.kind === "teammate" ? "teammate" : args.snapshot.background ? "background agent" : "agent"} transcript. Select Main chat in Chat Info to return.\nTask: ${args.snapshot.name}`, ), ]; diff --git a/apps/ade-cli/src/tuiClient/types.ts b/apps/ade-cli/src/tuiClient/types.ts index da6c097c0..1986bdb92 100644 --- a/apps/ade-cli/src/tuiClient/types.ts +++ b/apps/ade-cli/src/tuiClient/types.ts @@ -16,6 +16,7 @@ import type { AgentChatSession, AgentChatSessionSummary, AgentChatSlashCommand, + CodexThreadGoal, PendingInputRequest, } from "../../../desktop/src/shared/types/chat"; import type { LaneSummary } from "../../../desktop/src/shared/types/lanes"; @@ -106,8 +107,6 @@ export type SetupPaneRow = { cyclable?: boolean; }; -export type SubagentPaneTab = "subagents" | "teammates"; - export type SubagentSnapshot = { id: string; name: string; @@ -124,6 +123,31 @@ export type SubagentSnapshot = { lastToolName?: string; }; +export type ChatInfoPlanStep = { + text: string; + status: "pending" | "in_progress" | "completed" | "failed"; +}; + +export type ChatInfoPlan = { + current: number; + total: number; + steps: ChatInfoPlanStep[]; + live: boolean; +} | null; + +export type ChatInfoSnapshot = { + provider: AdeCodeProvider; + modelLabel: string; + laneLabel: string | null; + contextPercent: number | null; + tokenSummary: string | null; + goal: CodexThreadGoal | null; + plan: ChatInfoPlan; + snapshots: SubagentSnapshot[]; + inspectedSubagentId?: string | null; + streaming: boolean; +}; + export type RightPaneContent = | { kind: "empty" } | { kind: "help"; title: string } @@ -140,7 +164,7 @@ export type RightPaneContent = } | { kind: "details"; title: string; body: string } | { kind: "diff"; title: string; files: Array<{ path: string; additions?: number; deletions?: number; body?: string }> } - | { kind: "subagents"; tab: SubagentPaneTab; snapshots: SubagentSnapshot[]; provider: AdeCodeProvider } + | { kind: "chat-info"; info: ChatInfoSnapshot } | { kind: "new-chat-setup"; laneId: string; diff --git a/apps/desktop/src/main/services/adeActions/registry.test.ts b/apps/desktop/src/main/services/adeActions/registry.test.ts index 6f7995fa4..f69ff0793 100644 --- a/apps/desktop/src/main/services/adeActions/registry.test.ts +++ b/apps/desktop/src/main/services/adeActions/registry.test.ts @@ -338,6 +338,35 @@ describe("runtime session actions", () => { expect(sessionService.getDelta({ sessionId: "session-1" })).toEqual(delta); expect(runtime.sessionDeltaService?.getSessionDelta).toHaveBeenCalledWith("session-1"); }); + + it("routes readTranscriptTail through ptyService so live PTY output is included", async () => { + const runtime = { + sessionService: { + get: vi.fn(), + list: vi.fn(), + readTranscriptTail: vi.fn(async () => "stale disk tail"), + }, + ptyService: { + readTranscriptTail: vi.fn(async () => "live merged tail"), + }, + } as unknown as Parameters[0]; + const sessionService = getAdeActionDomainServices(runtime).session as { + readTranscriptTail: (args: { sessionId: string; maxBytes?: number; raw?: boolean }) => Promise; + } & Record; + + await expect(sessionService.readTranscriptTail({ + sessionId: "session-1", + maxBytes: 999, + raw: true, + })).resolves.toBe("live merged tail"); + expect(runtime.ptyService?.readTranscriptTail).toHaveBeenCalledWith({ + sessionId: "session-1", + maxBytes: 1024, + raw: true, + alignToLineBoundary: true, + }); + expect(runtime.sessionService.readTranscriptTail).not.toHaveBeenCalled(); + }); }); describe("runtime computer-use artifact actions", () => { diff --git a/apps/desktop/src/main/services/adeActions/registry.ts b/apps/desktop/src/main/services/adeActions/registry.ts index b8d075ed7..ac7afda67 100644 --- a/apps/desktop/src/main/services/adeActions/registry.ts +++ b/apps/desktop/src/main/services/adeActions/registry.ts @@ -52,6 +52,7 @@ import type { PrAiResolutionSessionStatus, PrAiResolutionStartArgs, PrAiResolutionStartResult, + ReadTranscriptTailArgs, PrAiResolutionStopArgs, PrIssueResolutionPromptPreviewArgs, PrIssueResolutionStartArgs, @@ -1164,6 +1165,18 @@ function buildSessionDomainService(runtime: AdeRuntime): OpaqueService | null { if (!sessionService) return null; return { ...(sessionService as unknown as OpaqueService), + readTranscriptTail: (args?: ReadTranscriptTailArgs) => { + const sessionId = requireNonEmptyString(args?.sessionId, "sessionId"); + const maxBytes = typeof args?.maxBytes === "number" && Number.isFinite(args.maxBytes) + ? Math.max(1024, Math.min(2_000_000, Math.floor(args.maxBytes))) + : 160_000; + return runtime.ptyService?.readTranscriptTail({ + sessionId, + maxBytes, + raw: args?.raw === true, + alignToLineBoundary: args?.raw === true, + }) ?? ""; + }, getDelta: (args?: { sessionId?: string } | string) => { const sessionId = typeof args === "string" ? requireNonEmptyString(args, "sessionId") diff --git a/apps/desktop/src/main/services/chat/agentChatService.test.ts b/apps/desktop/src/main/services/chat/agentChatService.test.ts index 7204d8ba7..99164b5cf 100644 --- a/apps/desktop/src/main/services/chat/agentChatService.test.ts +++ b/apps/desktop/src/main/services/chat/agentChatService.test.ts @@ -4333,7 +4333,7 @@ describe("createAgentChatService", () => { }), expect.objectContaining({ name: "/goal", - argumentHint: "[pause|resume|clear|budget |]", + argumentHint: "[pause|resume|clear|]", source: "local", }), ])); @@ -7469,6 +7469,252 @@ describe("createAgentChatService", () => { ]); }); + it("marks optimistic Codex spawn placeholders failed when the app-server rejects the tool call", 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: "Run a parallel repository scan.", + }, { awaitDispatch: true }); + await waitForEvent( + events, + (event): event is AgentChatEventEnvelope => + event.event.type === "status" + && event.event.turnStatus === "started" + && event.event.turnId === "turn-1", + ); + + mockState.emitCodexPayload({ + jsonrpc: "2.0", + method: "item/started", + params: { + turnId: "turn-1", + item: { + id: "call-spawn-1", + type: "collabAgentToolCall", + tool: "spawn_agent", + prompt: "Inspect the shared chat renderer", + }, + }, + }); + + expect(service.listSubagents({ sessionId: session.id })).toEqual([ + expect.objectContaining({ + taskId: "call-spawn-1", + parentToolUseId: "call-spawn-1", + status: "running", + }), + ]); + + mockState.emitCodexPayload({ + jsonrpc: "2.0", + method: "item/completed", + params: { + turnId: "turn-1", + item: { + id: "call-spawn-1", + type: "collabAgentToolCall", + tool: "spawn_agent", + status: "rejected", + error: { message: "spawn_agent is not available in this runtime" }, + }, + }, + }); + + expect(events).toEqual(expect.arrayContaining([ + expect.objectContaining({ + event: expect.objectContaining({ + type: "subagent_result", + taskId: "call-spawn-1", + parentToolUseId: "call-spawn-1", + status: "failed", + summary: "spawn_agent is not available in this runtime", + turnId: "turn-1", + }), + }), + expect.objectContaining({ + event: expect.objectContaining({ + type: "system_notice", + noticeKind: "error", + message: "Codex parallel agent failed: spawn_agent is not available in this runtime", + turnId: "turn-1", + }), + }), + ])); + expect(service.listSubagents({ sessionId: session.id })).toEqual([ + expect.objectContaining({ + taskId: "call-spawn-1", + status: "failed", + summary: "spawn_agent is not available in this runtime", + }), + ]); + expect(service.listSubagents({ sessionId: session.id }).some((snapshot) => snapshot.status === "running")).toBe(false); + }); + + it("uses content text instead of object stringification for Codex spawn failures", 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: "Run a parallel repository scan.", + }, { awaitDispatch: true }); + await waitForEvent( + events, + (event): event is AgentChatEventEnvelope => + event.event.type === "status" + && event.event.turnStatus === "started" + && event.event.turnId === "turn-1", + ); + + mockState.emitCodexPayload({ + jsonrpc: "2.0", + method: "item/started", + params: { + turnId: "turn-1", + item: { + id: "call-spawn-1", + type: "collabAgentToolCall", + tool: "spawn_agent", + prompt: "Inspect the shared chat renderer", + }, + }, + }); + + mockState.emitCodexPayload({ + jsonrpc: "2.0", + method: "item/completed", + params: { + turnId: "turn-1", + item: { + id: "call-spawn-1", + type: "collabAgentToolCall", + tool: "spawn_agent", + status: "rejected", + result: { reason: "runtime_missing" }, + contentItems: [{ text: "spawn_agent is not available in this runtime" }], + }, + }, + }); + + expect(events).toEqual(expect.arrayContaining([ + expect.objectContaining({ + event: expect.objectContaining({ + type: "subagent_result", + taskId: "call-spawn-1", + status: "failed", + summary: "spawn_agent is not available in this runtime", + }), + }), + expect.objectContaining({ + event: expect.objectContaining({ + type: "system_notice", + noticeKind: "error", + message: "Codex parallel agent failed: spawn_agent is not available in this runtime", + }), + }), + ])); + expect(events.some((event) => + event.event.type === "subagent_result" + && event.event.summary === "[object Object]" + )).toBe(false); + }); + + it("reports stopped Codex subagents without error severity", 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: "Run a parallel repository scan.", + }, { awaitDispatch: true }); + await waitForEvent( + events, + (event): event is AgentChatEventEnvelope => + event.event.type === "status" + && event.event.turnStatus === "started" + && event.event.turnId === "turn-1", + ); + + mockState.emitCodexPayload({ + jsonrpc: "2.0", + method: "item/started", + params: { + turnId: "turn-1", + item: { + id: "call-spawn-1", + type: "collabAgentToolCall", + tool: "spawn_agent", + prompt: "Inspect the shared chat renderer", + }, + }, + }); + + mockState.emitCodexPayload({ + jsonrpc: "2.0", + method: "item/completed", + params: { + turnId: "turn-1", + item: { + id: "call-spawn-1", + type: "collabAgentToolCall", + tool: "spawn_agent", + status: "cancelled", + result: "User cancelled", + }, + }, + }); + + expect(events).toEqual(expect.arrayContaining([ + expect.objectContaining({ + event: expect.objectContaining({ + type: "subagent_result", + taskId: "call-spawn-1", + status: "stopped", + summary: "User cancelled", + }), + }), + expect.objectContaining({ + event: expect.objectContaining({ + type: "system_notice", + noticeKind: "info", + severity: "info", + message: "Codex parallel agent stopped: User cancelled", + }), + }), + ])); + expect(events.some((event) => + event.event.type === "system_notice" + && event.event.noticeKind === "error" + && event.event.message === "Codex parallel agent stopped: User cancelled" + )).toBe(false); + }); + it("stops foreground Codex subagents when the parent turn completes without a terminal subagent event", async () => { const events: AgentChatEventEnvelope[] = []; const { service } = createService({ @@ -9114,14 +9360,14 @@ 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 () => { + it("routes Codex /goal pause and resume 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, + tokenBudget: null, tokensUsed: 25, timeUsedSeconds: 60, createdAt: 1_760_000_000, @@ -9156,32 +9402,196 @@ describe("createAgentChatService", () => { 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, + it("automatically removes incoming Codex goal token limits and resumes limited goals", async () => { + mockState.codexResponseOverrides.set("thread/goal/set", (payload) => { + const params = payload.params as Record; + return { + goal: { + objective: params.objective ?? "Ship CLI parity", + status: params.status ?? "active", + tokenBudget: Object.prototype.hasOwnProperty.call(params, "tokenBudget") ? params.tokenBudget : 5000, + tokensUsed: 125, + timeUsedSeconds: 90, + createdAt: 1_760_000_000, + updatedAt: 1_760_000_010, + }, + }; + }); + + 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", }); - mockState.codexRequestPayloads = []; await service.sendMessage({ sessionId: session.id, - text: "/goal budget clear", + text: "Start working.", }, { awaitDispatch: true }); - expect(mockState.codexRequestPayloads.find((payload) => payload.method === "thread/goal/set")?.params).toMatchObject({ + await waitForEvent( + events, + (event): event is AgentChatEventEnvelope => + event.event.type === "status" + && event.event.turnStatus === "started" + && event.event.turnId === "turn-1", + ); + mockState.codexRequestPayloads = []; + + mockState.emitCodexPayload({ + jsonrpc: "2.0", + method: "thread/goal/updated", + params: { + threadId: "thread-1", + turnId: "turn-1", + goal: { + objective: "Ship CLI parity", + status: "budgetLimited", + tokenBudget: 5000, + tokensUsed: 125, + timeUsedSeconds: 90, + createdAt: 1_760_000_000, + updatedAt: 1_760_000_001, + }, + }, + }); + + await vi.waitFor(() => { + expect(mockState.codexRequestPayloads.some((payload) => payload.method === "thread/goal/set")).toBe(true); + }); + const clearRequest = mockState.codexRequestPayloads.find((payload) => payload.method === "thread/goal/set"); + expect(clearRequest?.params).toMatchObject({ + threadId: "thread-1", + objective: "Ship CLI parity", + status: "active", tokenBudget: null, }); - mockState.codexRequestPayloads = []; + await vi.waitFor(() => { + expect(events.some((event) => + event.event.type === "system_notice" + && event.event.message === "Codex goal resumed." + )).toBe(true); + }); + expect(events).toEqual(expect.arrayContaining([ + expect.objectContaining({ + event: expect.objectContaining({ + type: "codex_goal_updated", + goal: expect.objectContaining({ + status: "budget_limited", + tokenBudget: 5000, + timeUsedSeconds: 90, + }), + }), + }), + expect.objectContaining({ + event: expect.objectContaining({ + type: "codex_goal_updated", + goal: expect.objectContaining({ + status: "active", + tokenBudget: null, + timeUsedSeconds: 90, + }), + }), + }), + ])); + }); + + it("backs off automatic Codex goal budget clearing after app-server failures", async () => { + const nowSpy = vi.spyOn(Date, "now").mockReturnValue(1_000_000); + 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", + }); + await service.sendMessage({ sessionId: session.id, - text: "/goal budget 5k", + text: "Start working.", }, { awaitDispatch: true }); + await waitForEvent( + events, + (event): event is AgentChatEventEnvelope => + event.event.type === "status" + && event.event.turnStatus === "started" + && event.event.turnId === "turn-1", + ); + mockState.codexRequestPayloads = []; + + const emitBudgetLimitedGoal = () => { + mockState.emitCodexPayload({ + jsonrpc: "2.0", + method: "thread/goal/updated", + params: { + threadId: "thread-1", + turnId: "turn-1", + goal: { + objective: "Ship CLI parity", + status: "budgetLimited", + tokenBudget: 5000, + tokensUsed: 125, + timeUsedSeconds: 90, + createdAt: 1_760_000_000, + updatedAt: 1_760_000_001, + }, + }, + }); + }; + + emitBudgetLimitedGoal(); + await vi.waitFor(() => { + expect(mockState.codexRequestPayloads.filter((payload) => payload.method === "thread/goal/set")).toHaveLength(1); + }); + + const clearRequest = mockState.codexRequestPayloads.find((payload) => payload.method === "thread/goal/set"); + mockState.emitCodexPayload({ + jsonrpc: "2.0", + id: clearRequest?.id, + error: { code: -32001, message: "goal RPC failed" }, + }); + mockState.pendingCodexResponses = []; + + await vi.waitFor(() => { + expect(events.some((event) => + event.event.type === "system_notice" + && event.event.message === "Codex goal update failed: goal RPC failed" + )).toBe(true); + }); + + mockState.codexRequestPayloads = []; + emitBudgetLimitedGoal(); + await Promise.resolve(); expect(mockState.codexRequestPayloads.some((payload) => payload.method === "thread/goal/set")).toBe(false); - expect(mockState.codexRequestPayloads.some((payload) => payload.method === "turn/start")).toBe(false); + + nowSpy.mockReturnValue(1_031_000); + emitBudgetLimitedGoal(); + await vi.waitFor(() => { + expect(mockState.codexRequestPayloads.some((payload) => payload.method === "thread/goal/set")).toBe(true); + }); + const retryRequest = mockState.codexRequestPayloads.find((payload) => payload.method === "thread/goal/set"); + mockState.emitCodexPayload({ + jsonrpc: "2.0", + id: retryRequest?.id, + result: { + goal: { + objective: "Ship CLI parity", + status: "active", + tokenBudget: null, + }, + }, + }); + mockState.pendingCodexResponses = []; }); it("treats /goal set reserved words as objective text", async () => { @@ -9221,7 +9631,7 @@ describe("createAgentChatService", () => { const sendPromise = service.sendMessage({ sessionId: session.id, - text: "/goal budget 5000", + text: "/goal status paused", }, { awaitDispatch: true }); await vi.waitFor(() => { diff --git a/apps/desktop/src/main/services/chat/agentChatService.ts b/apps/desktop/src/main/services/chat/agentChatService.ts index ee0ab09f4..5e2421157 100644 --- a/apps/desktop/src/main/services/chat/agentChatService.ts +++ b/apps/desktop/src/main/services/chat/agentChatService.ts @@ -497,6 +497,8 @@ type CodexRuntime = { collaborationModes: Set | null; collaborationModesReady: Promise | null; planModeFallbackNotified: boolean; + goalBudgetClearInFlight: Set; + goalBudgetClearRetryAfterByThreadId: Map; }; type QueuedSteer = { @@ -565,7 +567,7 @@ const CODEX_BUILT_IN_SLASH_COMMANDS: AgentChatSlashCommand[] = [ { 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: "/goal", description: "Set, show, pause, resume, or clear the thread goal.", source: "local", argumentHint: "[pause|resume|clear|]" }, { name: "/inject", description: "Inject context text into Codex thread history.", source: "local", argumentHint: "" }, { name: "/logout", description: "Sign out of Codex.", source: "sdk" }, { name: "/model", description: "Choose the active model and reasoning effort.", source: "sdk" }, @@ -1676,6 +1678,7 @@ const SESSION_CLEANUP_INTERVAL_MS = 15 * 1000; // check every 15 seconds const MAX_CONCURRENT_ACTIVE_RUNTIMES = 5; const MAX_RECENT_CONVERSATION_ENTRIES = 50; const MAX_SESSION_MAP_ENTRIES = 200; +const CODEX_GOAL_BUDGET_CLEAR_RETRY_BACKOFF_MS = 30_000; type PendingTranscriptWrite = { chunks: Buffer[]; @@ -2209,7 +2212,7 @@ function formatCodexErrorInfo(value: unknown): string | undefined { } } -type ChatErrorCategory = "auth" | "rate_limit" | "budget" | "network" | "unknown"; +type ChatErrorCategory = "auth" | "rate_limit" | "network" | "unknown"; function readErrorMessage(value: unknown): string { if (value instanceof Error) { @@ -2382,7 +2385,7 @@ function classifyAcpHostError( return { message: payloadDetail ?? "Billing is required for this model before the request can continue.", ...(detailLines.length ? { detail: detailLines.join("\n") } : {}), - errorInfo: { category: "budget", provider: providerLabel, model: modelDisplayName }, + errorInfo: { category: "unknown", provider: providerLabel, model: modelDisplayName }, }; } @@ -7896,6 +7899,89 @@ export function createAgentChatService(args: { commitChatEventWithCanonical(managed, normalizedEvent); }; + const maybeClearCodexGoalBudget = ( + managed: ManagedChatSession, + runtime: CodexRuntime, + goal: CodexThreadGoal | null, + turnId?: string, + ): void => { + if (!goal || goal.tokenBudget == null) return; + const threadId = managed.session.threadId?.trim(); + if (!threadId || runtime.goalBudgetClearInFlight.has(threadId)) return; + const retryAfter = runtime.goalBudgetClearRetryAfterByThreadId.get(threadId) ?? 0; + if (retryAfter > Date.now()) return; + + const nextStatus = goal.status === "budget_limited" ? "active" : goal.status; + const params: Record = { + threadId, + tokenBudget: null, + }; + if (goal.objective) params.objective = goal.objective; + if (nextStatus === "active" || nextStatus === "paused" || nextStatus === "complete") { + params.status = nextStatus; + } + + runtime.goalBudgetClearInFlight.add(threadId); + runtime.request<{ goal?: unknown }>("thread/goal/set", params) + .then((response) => { + runtime.goalBudgetClearRetryAfterByThreadId.delete(threadId); + const updatedGoal = normalizeCodexGoalPayload(response) + ?? { + ...goal, + tokenBudget: null, + ...(goal.status === "budget_limited" ? { status: "active" as const } : {}), + }; + managed.session.codexGoal = updatedGoal; + emitChatEvent(managed, { + type: "codex_goal_updated", + goal: updatedGoal, + ...(turnId ? { turnId } : {}), + }); + emitChatEvent(managed, { + type: "system_notice", + noticeKind: "info", + message: goal.status === "budget_limited" + ? "Codex goal resumed." + : "Codex goal updated.", + ...(turnId ? { turnId } : {}), + }); + persistChatState(managed); + }) + .catch((error) => { + runtime.goalBudgetClearRetryAfterByThreadId.set( + threadId, + Date.now() + CODEX_GOAL_BUDGET_CLEAR_RETRY_BACKOFF_MS, + ); + emitChatEvent(managed, { + type: "system_notice", + noticeKind: "error", + severity: "error", + message: `Codex goal update failed: ${error instanceof Error ? error.message : String(error)}`, + ...(turnId ? { turnId } : {}), + }); + }) + .finally(() => { + runtime.goalBudgetClearInFlight.delete(threadId); + }); + }; + + const applyCodexGoalUpdate = ( + managed: ManagedChatSession, + runtime: CodexRuntime, + value: unknown, + turnId?: string, + ): CodexThreadGoal | null => { + const goal = normalizeCodexGoalPayload(value); + managed.session.codexGoal = goal; + emitChatEvent(managed, { + type: "codex_goal_updated", + goal, + ...(turnId ? { turnId } : {}), + }); + maybeClearCodexGoalBudget(managed, runtime, goal, turnId); + return goal; + }; + const emitPendingInputRequest = ( managed: ManagedChatSession, request: PendingInputRequest, @@ -8841,12 +8927,7 @@ export function createAgentChatService(args: { threadId: managed.session.threadId, }, "Codex goal command failed"); if (!response.ok) return; - const goal = normalizeCodexGoalPayload(response.result); - managed.session.codexGoal = goal; - emitChatEvent(managed, { - type: "codex_goal_updated", - goal, - }); + const goal = applyCodexGoalUpdate(managed, runtime, response.result); completeInlineCodexSlash(goal?.objective ? "Codex goal is current." : "No active Codex goal."); return; } @@ -8874,46 +8955,10 @@ export function createAgentChatService(args: { status, }, "Codex goal command failed"); if (!response.ok) return; - const goal = normalizeCodexGoalPayload(response.result); - managed.session.codexGoal = goal; - emitChatEvent(managed, { - type: "codex_goal_updated", - goal, - }); + applyCodexGoalUpdate(managed, runtime, response.result); 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 requestInlineCodexSlash<{ goal?: unknown }>("thread/goal/set", { - threadId: managed.session.threadId, - tokenBudget, - }, "Codex goal command failed"); - if (!response.ok) return; - const goal = normalizeCodexGoalPayload(response.result); - 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."); @@ -8924,12 +8969,7 @@ export function createAgentChatService(args: { objective, }, "Codex goal command failed"); if (!response.ok) return; - const goal = normalizeCodexGoalPayload(response.result); - managed.session.codexGoal = goal; - emitChatEvent(managed, { - type: "codex_goal_updated", - goal, - }); + applyCodexGoalUpdate(managed, runtime, response.result); completeInlineCodexSlash("Codex goal updated."); return; } @@ -9076,7 +9116,7 @@ export function createAgentChatService(args: { modelDisplayName: string, ): { message: string; - errorInfo: { category: "auth" | "rate_limit" | "budget" | "network" | "unknown"; provider?: string; model?: string }; + errorInfo: { category: "auth" | "rate_limit" | "network" | "unknown"; provider?: string; model?: string }; } => { const rawMessage = error instanceof Error ? error.message : String(error); const lower = rawMessage.toLowerCase(); @@ -9104,10 +9144,10 @@ export function createAgentChatService(args: { }; } - if (lower.includes("budget") || lower.includes("cost limit") || lower.includes("spending limit")) { + if (lower.includes("cost limit") || lower.includes("spending limit")) { return { - message: "Session budget limit reached. Increase budget in Settings or start a new session.", - errorInfo: { category: "budget", provider: providerFamily, model: modelDisplayName }, + message: "Session limit reached. Check Settings or start a new session.", + errorInfo: { category: "unknown", provider: providerFamily, model: modelDisplayName }, }; } @@ -12107,13 +12147,50 @@ export function createAgentChatService(args: { const mapCodexCollabAgentStatus = (value: unknown): "completed" | "failed" | "stopped" | null => { const normalized = String(value ?? "").replace(/[_-]/g, "").toLowerCase(); if (normalized === "completed") return "completed"; - if (normalized === "failed" || normalized === "errored") return "failed"; - if (normalized === "stopped" || normalized === "interrupted" || normalized === "shutdown" || normalized === "notfound") { + if (normalized === "failed" || normalized === "errored" || normalized === "rejected" || normalized === "refused" || normalized === "denied") { + return "failed"; + } + if (normalized === "stopped" || normalized === "interrupted" || normalized === "shutdown" || normalized === "notfound" || normalized === "cancelled" || normalized === "canceled") { return "stopped"; } return null; }; + const readCodexCollabSummaryValue = (value: unknown): string | null => { + if (value == null) return null; + if (value instanceof Error) return trimLine(value.message); + if (typeof value === "string") return trimLine(value); + if (typeof value === "number" || typeof value === "boolean") return trimLine(String(value)); + const record = asRecord(value); + if (!record) return null; + for (const key of ["message", "summary", "text"] as const) { + const summary = trimLine(typeof record[key] === "string" ? record[key] : null); + if (summary) return summary; + } + return null; + }; + + const readCodexCollabFailureSummary = (item: Record): string => { + for (const value of [item.error, item.result, item.message]) { + const direct = readCodexCollabSummaryValue(value); + if (direct) return direct; + } + const contentItems = Array.isArray(item.contentItems) ? item.contentItems : []; + const contentText = contentItems + .map((entry) => { + if (typeof entry === "string") return entry; + const record = asRecord(entry); + return typeof record?.text === "string" ? record.text : ""; + }) + .filter(Boolean) + .join("\n") + .trim(); + return contentText || "Codex rejected the parallel agent request."; + }; + + const codexCollabItemHasFailure = (item: Record): boolean => + item.success === false || item.error != null; + const handleCodexItemEvent = ( managed: ManagedChatSession, runtime: CodexRuntime, @@ -12364,20 +12441,34 @@ export function createAgentChatService(args: { } if (tool === "spawn_agent" && eventKind === "completed") { - const spawnStatus = mapCodexCollabAgentStatus(item.status); - if (spawnStatus === "failed") { - for (const taskId of receiverIds.length ? receiverIds : [itemId]) { + const spawnStatus = mapCodexCollabAgentStatus(item.status) + ?? (codexCollabItemHasFailure(item) ? "failed" : null); + if (spawnStatus === "failed" || spawnStatus === "stopped") { + const stopped = spawnStatus === "stopped"; + const failedTaskIds = runtime.activeSubagents.has(itemId) + ? [itemId] + : (receiverIds.length ? receiverIds : [itemId]); + const summary = readCodexCollabFailureSummary(item); + for (const taskId of failedTaskIds) { const existing = runtime.activeSubagents.get(taskId) ?? runtime.activeSubagents.get(itemId); runtime.activeSubagents.delete(taskId); + if (taskId !== itemId) runtime.activeSubagents.delete(itemId); emitChatEvent(managed, { type: "subagent_result", taskId, parentToolUseId: existing?.parentToolUseId ?? itemId, - status: "failed", - summary: String(item.error ?? item.result ?? "Agent spawn failed"), + status: spawnStatus, + summary, turnId, }); } + emitChatEvent(managed, { + type: "system_notice", + noticeKind: stopped ? "info" : "error", + severity: stopped ? "info" : "error", + message: `Codex parallel agent ${stopped ? "stopped" : "failed"}: ${summary}`, + turnId, + }); } else { const resolvedTaskIds = receiverIds.length ? receiverIds @@ -13113,13 +13204,12 @@ export function createAgentChatService(args: { } 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, - }); + applyCodexGoalUpdate( + managed, + runtime, + params, + turnIdFromParams ?? runtime.activeTurnId ?? undefined, + ); persistChatState(managed); return; } @@ -13378,6 +13468,8 @@ export function createAgentChatService(args: { collaborationModes: null, collaborationModesReady: null, planModeFallbackNotified: false, + goalBudgetClearInFlight: new Set(), + goalBudgetClearRetryAfterByThreadId: new Map(), request: async (method: string, params?: unknown): Promise => { const id = runtime.nextRequestId; runtime.nextRequestId += 1; diff --git a/apps/desktop/src/main/services/pty/ptyService.test.ts b/apps/desktop/src/main/services/pty/ptyService.test.ts index 4d6d95d72..beea9fd12 100644 --- a/apps/desktop/src/main/services/pty/ptyService.test.ts +++ b/apps/desktop/src/main/services/pty/ptyService.test.ts @@ -494,7 +494,8 @@ describe("ptyService", () => { CUSTOM_FLAG: "1", TERM: "xterm-256color", COLORTERM: "truecolor", - FORCE_COLOR: "1", + NO_COLOR: "", + FORCE_COLOR: "", }), }), ); @@ -596,6 +597,60 @@ describe("ptyService", () => { })); }); + it("does not force color when NO_COLOR is explicitly empty", async () => { + const { service, loadPty } = createHarness(); + + await service.create({ + laneId: "lane-1", + title: "Empty no color env", + cols: 80, + rows: 24, + env: { + NO_COLOR: "", + }, + }); + + const ptyLib = loadPty.mock.results.at(-1)?.value as { spawn: ReturnType }; + const spawnArgs = ptyLib.spawn.mock.calls.at(-1); + const opts = spawnArgs?.[2] as { env?: NodeJS.ProcessEnv } | undefined; + expect(opts?.env).toEqual(expect.objectContaining({ + NO_COLOR: "", + })); + expect(opts?.env?.FORCE_COLOR).toBeUndefined(); + }); + + it("does not leak inherited NO_COLOR into interactive terminal launches", async () => { + const previousNoColor = process.env.NO_COLOR; + const previousForceColor = process.env.FORCE_COLOR; + process.env.NO_COLOR = "1"; + delete process.env.FORCE_COLOR; + try { + const { service, loadPty } = createHarness(); + + await service.create({ + laneId: "lane-1", + title: "Inherited color env", + cols: 80, + rows: 24, + }); + + const ptyLib = loadPty.mock.results.at(-1)?.value as { spawn: ReturnType }; + const spawnArgs = ptyLib.spawn.mock.calls.at(-1); + const opts = spawnArgs?.[2] as { env?: NodeJS.ProcessEnv } | undefined; + expect(opts?.env?.NO_COLOR).toBeUndefined(); + expect(opts?.env).toEqual(expect.objectContaining({ + TERM: "xterm-256color", + COLORTERM: "truecolor", + FORCE_COLOR: "1", + })); + } finally { + if (previousNoColor === undefined) delete process.env.NO_COLOR; + else process.env.NO_COLOR = previousNoColor; + if (previousForceColor === undefined) delete process.env.FORCE_COLOR; + else process.env.FORCE_COLOR = previousForceColor; + } + }); + it("does not type startupCommand preview into direct command sessions", async () => { const { service, mockPty } = createHarness(); diff --git a/apps/desktop/src/main/services/pty/ptyService.ts b/apps/desktop/src/main/services/pty/ptyService.ts index 9a70c1b51..44106863b 100644 --- a/apps/desktop/src/main/services/pty/ptyService.ts +++ b/apps/desktop/src/main/services/pty/ptyService.ts @@ -120,6 +120,10 @@ function hasEnvValue(env: NodeJS.ProcessEnv, key: string): boolean { return typeof env[key] === "string" && env[key]!.trim().length > 0; } +function hasEnvKey(env: NodeJS.ProcessEnv, key: string): boolean { + return Object.prototype.hasOwnProperty.call(env, key); +} + function resolveNodePtyPrebuildDir(platform: NodeJS.Platform, arch: string): string | null { if (platform !== "darwin") return null; if (arch === "arm64") return "darwin-arm64"; @@ -177,8 +181,14 @@ export function ensureNodePtySpawnHelperExecutable({ } } -function withInteractiveTerminalColorEnv(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv { +function withInteractiveTerminalColorEnv( + env: NodeJS.ProcessEnv, + opts: { preserveNoColor?: boolean } = {}, +): NodeJS.ProcessEnv { const next: NodeJS.ProcessEnv = { ...env }; + if (!opts.preserveNoColor) { + delete next.NO_COLOR; + } const term = next.TERM?.trim().toLowerCase() ?? ""; if (!term || term === "dumb") { next.TERM = "xterm-256color"; @@ -186,7 +196,7 @@ function withInteractiveTerminalColorEnv(env: NodeJS.ProcessEnv): NodeJS.Process if (!hasEnvValue(next, "COLORTERM")) { next.COLORTERM = "truecolor"; } - if (!hasEnvValue(next, "NO_COLOR") && !hasEnvValue(next, "FORCE_COLOR")) { + if (!hasEnvKey(next, "NO_COLOR") && !hasEnvValue(next, "FORCE_COLOR")) { next.FORCE_COLOR = "1"; } return next; @@ -2425,9 +2435,11 @@ export function createPtyService({ .catch(() => {}); } + const laneRuntimeEnv = (await getLaneRuntimeEnv?.(laneId)) ?? {}; + const explicitNoColor = hasEnvKey(args.env ?? {}, "NO_COLOR") || hasEnvKey(laneRuntimeEnv, "NO_COLOR"); const baseLaunchEnv = { ...process.env, - ...((await getLaneRuntimeEnv?.(laneId)) ?? {}), + ...laneRuntimeEnv, ...(args.env ?? {}) }; const contextLaunchEnv = withAdeTerminalContextEnv(baseLaunchEnv, { @@ -2435,7 +2447,10 @@ export function createPtyService({ laneId, chatSessionId, }); - const launchEnv = withInteractiveTerminalColorEnv(getAdeCliAgentEnv?.(contextLaunchEnv) ?? contextLaunchEnv); + const launchEnv = withInteractiveTerminalColorEnv( + getAdeCliAgentEnv?.(contextLaunchEnv) ?? contextLaunchEnv, + { preserveNoColor: explicitNoColor }, + ); const shouldBackfillResumeTarget = existingSession && isTrackedCliToolType(toolTypeHint) diff --git a/apps/desktop/src/main/utils/terminalSessionSignals.test.ts b/apps/desktop/src/main/utils/terminalSessionSignals.test.ts index c1e034b7f..10094ccd0 100644 --- a/apps/desktop/src/main/utils/terminalSessionSignals.test.ts +++ b/apps/desktop/src/main/utils/terminalSessionSignals.test.ts @@ -6,7 +6,8 @@ import { parseTrackedCliLaunchConfig, parseTrackedCliResumeCommand, normalizeResumeCommand, - runtimeStateFromOsc133Chunk + runtimeStateFromOsc133Chunk, + sanitizeResumeTargetId, } from "./terminalSessionSignals"; describe("terminalSessionSignals", () => { @@ -28,6 +29,18 @@ describe("terminalSessionSignals", () => { ); }); + it("sanitizes resume target ids through the shared CLI launch helper", () => { + expect(sanitizeResumeTargetId(" thread_abc123 ")).toBe("thread_abc123"); + expect(sanitizeResumeTargetId("-dangerous")).toBeNull(); + expect(sanitizeResumeTargetId("bad\nid")).toBeNull(); + }); + + it("does not treat Codex prompt glyphs as resume targets", () => { + const chunk = "codex --no-alt-screen --sandbox workspace-write --ask-for-approval on-request resume ›"; + expect(parseTrackedCliResumeCommand(chunk, "codex")).toBeNull(); + expect(extractResumeCommandFromOutput(chunk, "codex")).toBeNull(); + }); + it("respects preferred tool when both tools appear", () => { const chunk = [ "claude --resume abc", @@ -171,7 +184,7 @@ describe("terminalSessionSignals", () => { targetId: null, launch: { permissionMode: "default" }, }), - ).toBe("codex --no-alt-screen --full-auto resume"); + ).toBe("codex --no-alt-screen --sandbox workspace-write --ask-for-approval on-request resume"); }); it("parses legacy codex approval_policy=untrusted sandbox_mode=read-only as plan", () => { diff --git a/apps/desktop/src/main/utils/terminalSessionSignals.ts b/apps/desktop/src/main/utils/terminalSessionSignals.ts index 173126fbe..100025f96 100644 --- a/apps/desktop/src/main/utils/terminalSessionSignals.ts +++ b/apps/desktop/src/main/utils/terminalSessionSignals.ts @@ -12,6 +12,7 @@ import { modelToCliFlag, normalizeCliFlagValue, resolveClaudeCliModelForLaunch, + sanitizeTrackedCliResumeTargetId, } from "../../shared/cliLaunch"; const OSC_133_REGEX = /\u001b\]133;([ABCD])(?:;[^\u0007\u001b]*)?(?:\u0007|\u001b\\)/g; @@ -28,13 +29,7 @@ function commandArrayToLine(parts: string[]): string { return parts.map(shellQuote).join(" "); } -export function sanitizeResumeTargetId(value: string | null | undefined): string | null { - const target = String(value ?? "").trim(); - if (!target) return null; - if (/[\x00-\x1F\x7F]/.test(target)) return null; - if (target.startsWith("-")) return null; - return target; -} +export const sanitizeResumeTargetId = sanitizeTrackedCliResumeTargetId; function normalizeCommand(raw: string): string { return raw @@ -82,7 +77,7 @@ function permissionModeToClaudeFlag(permissionMode: AgentChatPermissionMode | nu function permissionModeToCodexFlags(permissionMode: AgentChatPermissionMode | null | undefined): string[] { if (permissionMode === "full-auto") return ["--dangerously-bypass-approvals-and-sandbox"]; - if (permissionMode === "default") return ["--full-auto"]; + if (permissionMode === "default") return ["--sandbox", "workspace-write", "--ask-for-approval", "on-request"]; if (permissionMode === "edit") return ["--sandbox", "workspace-write", "--ask-for-approval", "untrusted"]; if (permissionMode === "plan") return ["--sandbox", "read-only", "--ask-for-approval", "on-request"]; return []; diff --git a/apps/desktop/src/renderer/components/chat/chatExecutionSummary.test.ts b/apps/desktop/src/renderer/components/chat/chatExecutionSummary.test.ts index 6815a149c..d570a366a 100644 --- a/apps/desktop/src/renderer/components/chat/chatExecutionSummary.test.ts +++ b/apps/desktop/src/renderer/components/chat/chatExecutionSummary.test.ts @@ -100,6 +100,71 @@ describe("deriveChatSubagentSnapshots", () => { ]); }); + it("keeps Codex sibling subagents separate when they share a parent tool use id", () => { + const events: AgentChatEventEnvelope[] = [ + { + sessionId: "session-1", + timestamp: "2026-03-10T12:00:00.000Z", + event: { + type: "subagent_started", + taskId: "agent-thread-1", + parentToolUseId: "call-spawn-1", + description: "Inspect desktop IPC path", + }, + }, + { + sessionId: "session-1", + timestamp: "2026-03-10T12:00:01.000Z", + event: { + type: "subagent_started", + taskId: "agent-thread-2", + parentToolUseId: "call-spawn-1", + description: "Inspect desktop IPC path", + }, + }, + { + sessionId: "session-1", + timestamp: "2026-03-10T12:00:02.000Z", + event: { + type: "subagent_result", + taskId: "agent-thread-1", + parentToolUseId: "call-spawn-1", + status: "completed", + summary: "Mapped the IPC path.", + }, + }, + { + sessionId: "session-1", + timestamp: "2026-03-10T12:00:03.000Z", + event: { + type: "subagent_result", + taskId: "agent-thread-2", + parentToolUseId: "call-spawn-1", + status: "completed", + summary: "Checked the renderer state.", + }, + }, + ]; + + const snapshots = deriveChatSubagentSnapshots(events); + + expect(snapshots).toHaveLength(2); + expect(snapshots).toEqual([ + expect.objectContaining({ + taskId: "agent-thread-2", + parentToolUseId: "call-spawn-1", + status: "completed", + summary: "Checked the renderer state.", + }), + expect.objectContaining({ + taskId: "agent-thread-1", + parentToolUseId: "call-spawn-1", + status: "completed", + summary: "Mapped the IPC path.", + }), + ]); + }); + it("marks non-background running snapshots stopped when their parent turn has already ended", () => { const events: AgentChatEventEnvelope[] = [ { diff --git a/apps/desktop/src/renderer/components/chat/chatExecutionSummary.ts b/apps/desktop/src/renderer/components/chat/chatExecutionSummary.ts index 9d77a4fa1..4fe5e01c1 100644 --- a/apps/desktop/src/renderer/components/chat/chatExecutionSummary.ts +++ b/apps/desktop/src/renderer/components/chat/chatExecutionSummary.ts @@ -25,23 +25,90 @@ function compareIsoDesc(left: string, right: string): number { return Date.parse(right) - Date.parse(left); } -function findSnapshotByParent( +type ChatSubagentEvent = Extract< + AgentChatEventEnvelope["event"], + { type: "subagent_started" | "subagent_progress" | "subagent_result" } +>; + +function isSubagentEvent(event: AgentChatEventEnvelope["event"]): event is ChatSubagentEvent { + return event.type === "subagent_started" || event.type === "subagent_progress" || event.type === "subagent_result"; +} + +function subagentIdentityKey(event: ChatSubagentEvent): string { + return event.agentId ?? event.taskId; +} + +function buildResolvedSubagentKeysByParent(events: AgentChatEventEnvelope[]): Map> { + const keysByParent = new Map>(); + for (const envelope of events) { + const event = envelope.event; + if (!isSubagentEvent(event) || !event.parentToolUseId) continue; + const key = subagentIdentityKey(event); + if (key === event.parentToolUseId) continue; + const keys = keysByParent.get(event.parentToolUseId) ?? new Set(); + keys.add(key); + keysByParent.set(event.parentToolUseId, keys); + } + return keysByParent; +} + +function isParentPlaceholder(snapshot: ChatSubagentSnapshot, parentToolUseId: string): boolean { + return !snapshot.agentId && snapshot.taskId === parentToolUseId && snapshot.parentToolUseId === parentToolUseId; +} + +function resolveSubagentSnapshot( snapshots: Map, - parentToolUseId: string | null | undefined, -): [string, ChatSubagentSnapshot] | null { - if (!parentToolUseId) return null; - const direct = snapshots.get(parentToolUseId); - if (direct) return [parentToolUseId, direct]; - for (const entry of snapshots.entries()) { - const [, snapshot] = entry; - if (snapshot.parentToolUseId === parentToolUseId) return entry; + event: ChatSubagentEvent, + resolvedKeysByParent: Map>, +): { key: string; existing: ChatSubagentSnapshot | undefined; adoptedPlaceholder: boolean } { + const key = subagentIdentityKey(event); + const direct = snapshots.get(key); + const parentPlaceholder = event.parentToolUseId ? snapshots.get(event.parentToolUseId) : undefined; + const parentResolvedKeys = event.parentToolUseId ? resolvedKeysByParent.get(event.parentToolUseId) : undefined; + const canAdoptParentPlaceholder = Boolean( + event.parentToolUseId + && parentPlaceholder + && isParentPlaceholder(parentPlaceholder, event.parentToolUseId) + && parentResolvedKeys?.size === 1 + && parentResolvedKeys.has(key), + ); + const taskAliasCandidate = key !== event.taskId ? snapshots.get(event.taskId) : undefined; + const taskAliasIsParentPlaceholder = Boolean( + taskAliasCandidate + && event.parentToolUseId + && event.taskId === event.parentToolUseId + && isParentPlaceholder(taskAliasCandidate, event.parentToolUseId), + ); + const taskAlias = taskAliasCandidate && (!taskAliasIsParentPlaceholder || canAdoptParentPlaceholder) + ? taskAliasCandidate + : undefined; + const adoptParentPlaceholder = !taskAlias && canAdoptParentPlaceholder ? parentPlaceholder : undefined; + const adoptedPlaceholder = Boolean(adoptParentPlaceholder || (taskAlias && taskAliasIsParentPlaceholder)); + + if (taskAlias && key !== event.taskId) snapshots.delete(event.taskId); + if (adoptParentPlaceholder && event.parentToolUseId) { + snapshots.delete(event.parentToolUseId); + } else if ( + event.parentToolUseId + && parentPlaceholder + && isParentPlaceholder(parentPlaceholder, event.parentToolUseId) + && parentResolvedKeys + && parentResolvedKeys.size > 1 + ) { + snapshots.delete(event.parentToolUseId); } - return null; + + return { + key, + existing: direct ?? taskAlias ?? adoptParentPlaceholder, + adoptedPlaceholder, + }; } export function deriveChatSubagentSnapshots(events: AgentChatEventEnvelope[]): ChatSubagentSnapshot[] { const snapshots = new Map(); const terminalTurnIds = new Set(); + const resolvedKeysByParent = buildResolvedSubagentKeysByParent(events); for (const envelope of events) { const event = envelope.event; @@ -55,11 +122,7 @@ export function deriveChatSubagentSnapshots(events: AgentChatEventEnvelope[]): C for (const envelope of events) { const event = envelope.event; if (event.type === "subagent_started") { - const key = event.agentId ?? event.taskId; - const parentMatch = findSnapshotByParent(snapshots, event.parentToolUseId); - const existing = snapshots.get(key) ?? snapshots.get(event.taskId) ?? parentMatch?.[1]; - if (key !== event.taskId) snapshots.delete(event.taskId); - if (parentMatch && parentMatch[0] !== key) snapshots.delete(parentMatch[0]); + const { key, existing } = resolveSubagentSnapshot(snapshots, event, resolvedKeysByParent); snapshots.set(key, { taskId: event.taskId, agentId: event.agentId ?? existing?.agentId, @@ -80,17 +143,9 @@ export function deriveChatSubagentSnapshots(events: AgentChatEventEnvelope[]): C } if (event.type === "subagent_progress") { - const key = event.agentId ?? event.taskId; - const parentMatch = findSnapshotByParent(snapshots, event.parentToolUseId); - const existing = snapshots.get(key) ?? snapshots.get(event.taskId) ?? parentMatch?.[1]; - if (key !== event.taskId) snapshots.delete(event.taskId); - if (parentMatch && parentMatch[0] !== key) snapshots.delete(parentMatch[0]); + const { key, existing, adoptedPlaceholder } = resolveSubagentSnapshot(snapshots, event, resolvedKeysByParent); snapshots.set(key, { - // Preserve the stable merged identity. Overwriting taskId with the - // current event's taskId for a parent-based match would split the - // agent's history in `deriveSubagentTimeline`, which keys off - // `snapshot.taskId`. - taskId: existing?.taskId ?? event.taskId, + taskId: adoptedPlaceholder ? event.taskId : existing?.taskId ?? event.taskId, agentId: event.agentId ?? existing?.agentId, agentType: event.agentType ?? existing?.agentType, parentToolUseId: event.parentToolUseId ?? existing?.parentToolUseId ?? null, @@ -109,14 +164,9 @@ export function deriveChatSubagentSnapshots(events: AgentChatEventEnvelope[]): C } if (event.type === "subagent_result") { - const key = event.agentId ?? event.taskId; - const parentMatch = findSnapshotByParent(snapshots, event.parentToolUseId); - const existing = snapshots.get(key) ?? snapshots.get(event.taskId) ?? parentMatch?.[1]; - if (key !== event.taskId) snapshots.delete(event.taskId); - if (parentMatch && parentMatch[0] !== key) snapshots.delete(parentMatch[0]); + const { key, existing, adoptedPlaceholder } = resolveSubagentSnapshot(snapshots, event, resolvedKeysByParent); snapshots.set(key, { - // Preserve the merged taskId (see subagent_progress branch above). - taskId: existing?.taskId ?? event.taskId, + taskId: adoptedPlaceholder ? event.taskId : existing?.taskId ?? event.taskId, agentId: event.agentId ?? existing?.agentId, agentType: event.agentType ?? existing?.agentType, parentToolUseId: event.parentToolUseId ?? existing?.parentToolUseId ?? 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 9ef1bc749..f21c850d9 100644 --- a/apps/desktop/src/renderer/components/chat/codex/CodexGoalBanner.test.tsx +++ b/apps/desktop/src/renderer/components/chat/codex/CodexGoalBanner.test.tsx @@ -92,14 +92,15 @@ describe("CodexGoalBanner", () => { expect(container.firstChild).toBeNull(); }); - it("shows the status pill label with underscores replaced", () => { + it("shows app-server limited goals as active", () => { render( undefined} onClear={() => undefined} />, ); - expect(screen.getByText(/budget limited/i)).toBeTruthy(); + expect(screen.getByText(/^active$/i)).toBeTruthy(); + expect(screen.queryByText(/budget/i)).toBeNull(); }); }); diff --git a/apps/desktop/src/renderer/components/chat/codex/CodexGoalBanner.tsx b/apps/desktop/src/renderer/components/chat/codex/CodexGoalBanner.tsx index cd75ebbe0..ebbec136c 100644 --- a/apps/desktop/src/renderer/components/chat/codex/CodexGoalBanner.tsx +++ b/apps/desktop/src/renderer/components/chat/codex/CodexGoalBanner.tsx @@ -34,10 +34,9 @@ function statusPillClass(status: CodexThreadGoal["status"]): string { 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 "budget_limited": case "active": default: return "bg-amber-500/12 text-amber-100 ring-1 ring-inset ring-amber-400/30"; @@ -46,6 +45,7 @@ function statusPillClass(status: CodexThreadGoal["status"]): string { function statusLabel(status: CodexThreadGoal["status"]): string { if (!status || status === "unknown") return "active"; + if (status === "budget_limited") return "active"; return status.replace("_", " "); } @@ -72,10 +72,6 @@ export function CodexGoalBanner({ goal, onEdit, onClear }: CodexGoalBannerProps) 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"; @@ -182,21 +178,9 @@ export function CodexGoalBanner({ goal, onEdit, onClear }: CodexGoalBannerProps)
-
- {hasBudget ? ( -
- ) : null} -
+
{formatTokens(tokensUsed)} - {hasBudget ? / {formatTokens(tokenBudget)} : null} {elapsed ? ( <> diff --git a/apps/desktop/src/renderer/components/terminals/TerminalView.test.tsx b/apps/desktop/src/renderer/components/terminals/TerminalView.test.tsx index d7935ce20..740c53ade 100644 --- a/apps/desktop/src/renderer/components/terminals/TerminalView.test.tsx +++ b/apps/desktop/src/renderer/components/terminals/TerminalView.test.tsx @@ -173,6 +173,16 @@ function installWindowAde() { sessions: { readTranscriptTail: vi.fn().mockResolvedValue(""), }, + terminal: { + preview: vi.fn().mockResolvedValue({ + terminalId: "session", + session: null, + source: "empty", + snapshot: null, + transcript: null, + capturedAt: new Date().toISOString(), + }), + }, }; } @@ -636,6 +646,193 @@ describe("TerminalView", () => { } }); + it("maps macOS Cmd+C without an xterm selection to Ctrl+C terminal input", async () => { + const platformDescriptor = Object.getOwnPropertyDescriptor(window.navigator, "platform"); + const originalPlatform = window.navigator.platform; + try { + Object.defineProperty(window.navigator, "platform", { + configurable: true, + value: "MacIntel", + }); + + render(); + await flushAllTimers(); + + const terminal = mockState.terminalInstances.at(-1) as { + attachCustomKeyEventHandler: ReturnType; + } | undefined; + const keyHandler = terminal?.attachCustomKeyEventHandler.mock.calls.at(-1)?.[0] as ((ev: KeyboardEvent) => boolean) | undefined; + expect(keyHandler).toBeTruthy(); + + const ptyWrite = window.ade.pty.write as unknown as ReturnType; + ptyWrite.mockClear(); + const preventDefault = vi.fn(); + + const handled = keyHandler!({ + type: "keydown", + key: "c", + metaKey: true, + ctrlKey: false, + altKey: false, + shiftKey: false, + preventDefault, + } as unknown as KeyboardEvent); + + expect(handled).toBe(false); + expect(preventDefault).toHaveBeenCalledTimes(1); + expect(ptyWrite).toHaveBeenCalledWith({ + ptyId: "pty-copy", + data: "\x03", + }); + } finally { + if (platformDescriptor) { + Object.defineProperty(window.navigator, "platform", platformDescriptor); + } else { + Object.defineProperty(window.navigator, "platform", { + configurable: true, + value: originalPlatform, + }); + } + } + }); + + it("forwards Shift+mouse selection gestures while terminal mouse tracking is active", async () => { + render(); + await flushAllTimers(); + + const terminal = mockState.terminalInstances.at(-1) as { + element: HTMLElement | null; + } | undefined; + expect(terminal?.element).toBeTruthy(); + + const ptyWrite = window.ade.pty.write as unknown as ReturnType; + ptyWrite.mockClear(); + + const ignoredDown = new MouseEvent("mousedown", { + bubbles: true, + cancelable: true, + shiftKey: true, + button: 0, + buttons: 1, + clientX: 0, + clientY: 0, + }); + terminal!.element!.dispatchEvent(ignoredDown); + expect(ignoredDown.defaultPrevented).toBe(false); + expect(ptyWrite).not.toHaveBeenCalled(); + + for (const listener of mockState.ptyDataListeners) { + listener({ + ptyId: "pty-shift-mouse", + sessionId: "session-shift-mouse", + projectRoot: "/project/a", + data: "\x1b[?1000h\x1b[?1002h\x1b[?1006h", + }); + } + + const down = new MouseEvent("mousedown", { + bubbles: true, + cancelable: true, + shiftKey: true, + button: 0, + buttons: 1, + clientX: 0, + clientY: 0, + }); + terminal!.element!.dispatchEvent(down); + + const move = new MouseEvent("mousemove", { + bubbles: true, + cancelable: true, + shiftKey: true, + buttons: 1, + clientX: 64, + clientY: 72, + }); + document.dispatchEvent(move); + + const up = new MouseEvent("mouseup", { + bubbles: true, + cancelable: true, + shiftKey: true, + button: 0, + buttons: 0, + clientX: 64, + clientY: 72, + }); + document.dispatchEvent(up); + + expect(down.defaultPrevented).toBe(true); + expect(move.defaultPrevented).toBe(true); + expect(up.defaultPrevented).toBe(true); + expect(ptyWrite).toHaveBeenNthCalledWith(1, { + ptyId: "pty-shift-mouse", + data: "\x1b[<4;1;1M", + }); + expect(ptyWrite).toHaveBeenNthCalledWith(2, { + ptyId: "pty-shift-mouse", + data: "\x1b[<36;13;9M", + }); + expect(ptyWrite).toHaveBeenNthCalledWith(3, { + ptyId: "pty-shift-mouse", + data: "\x1b[<4;13;9m", + }); + }); + + it("does not forward a Shift+mouse release after the runtime is disposed", async () => { + render(); + await flushAllTimers(); + + const terminal = mockState.terminalInstances.at(-1) as { + element: HTMLElement | null; + } | undefined; + expect(terminal?.element).toBeTruthy(); + + for (const listener of mockState.ptyDataListeners) { + listener({ + ptyId: "pty-shift-disposed", + sessionId: "session-shift-disposed", + projectRoot: "/project/a", + data: "\x1b[?1000h\x1b[?1002h\x1b[?1006h", + }); + } + + const ptyWrite = window.ade.pty.write as unknown as ReturnType; + ptyWrite.mockClear(); + terminal!.element!.dispatchEvent(new MouseEvent("mousedown", { + bubbles: true, + cancelable: true, + shiftKey: true, + button: 0, + buttons: 1, + clientX: 0, + clientY: 0, + })); + expect(ptyWrite).toHaveBeenCalledTimes(1); + + __resetTerminalRuntimesForTests(); + terminal!.element!.dispatchEvent(new MouseEvent("mousedown", { + bubbles: true, + cancelable: true, + shiftKey: true, + button: 0, + buttons: 1, + clientX: 0, + clientY: 0, + })); + document.dispatchEvent(new MouseEvent("mouseup", { + bubbles: true, + cancelable: true, + shiftKey: true, + button: 0, + buttons: 0, + clientX: 64, + clientY: 72, + })); + + expect(ptyWrite).toHaveBeenCalledTimes(1); + }); + it("falls back to native image paste when macOS Cmd+V does not fire a paste event", async () => { const platformDescriptor = Object.getOwnPropertyDescriptor(window.navigator, "platform"); const clipboardDescriptor = Object.getOwnPropertyDescriptor(window.navigator, "clipboard"); @@ -781,6 +978,160 @@ describe("TerminalView", () => { expect(getTerminalRuntimeSnapshot("session-switch")).not.toBeNull(); }); + it("hydrates live terminals from serialized snapshots when structured rows are unavailable", async () => { + const previewMock = window.ade.terminal.preview as unknown as ReturnType; + const readTranscriptTailMock = window.ade.sessions.readTranscriptTail as unknown as ReturnType; + previewMock.mockResolvedValueOnce({ + terminalId: "session-snapshot", + session: null, + source: "snapshot", + snapshot: { + version: 1, + terminalId: "session-snapshot", + cols: 120, + rows: 32, + capturedAt: new Date().toISOString(), + status: "running", + runtimeState: "running", + bufferType: "alternate", + cursorX: 0, + cursorY: 0, + baseY: 0, + viewportY: 0, + serialized: "\x1b[?1049hClaude Code ready\n", + visibleRows: [], + }, + transcript: null, + capturedAt: new Date().toISOString(), + }); + + render(); + await flushAllTimers(); + + const terminal = mockState.terminalInstances.at(-1) as { + write: ReturnType; + } | undefined; + expect(terminal?.write).toHaveBeenCalledWith("\x1b[?1049hClaude Code ready\n"); + expect(readTranscriptTailMock).not.toHaveBeenCalled(); + }); + + it("preserves snapshot cell colors when hydrating live terminals", async () => { + const previewMock = window.ade.terminal.preview as unknown as ReturnType; + const readTranscriptTailMock = window.ade.sessions.readTranscriptTail as unknown as ReturnType; + previewMock.mockResolvedValueOnce({ + terminalId: "session-colored-snapshot", + session: null, + source: "snapshot", + snapshot: { + version: 1, + terminalId: "session-colored-snapshot", + cols: 12, + rows: 2, + capturedAt: new Date().toISOString(), + status: "running", + runtimeState: "running", + bufferType: "alternate", + cursorX: 1, + cursorY: 1, + baseY: 0, + viewportY: 0, + serialized: "UNSTYLED SERIALIZED\n", + visibleRows: [ + { + text: "Claude", + wrapped: false, + cells: [ + { text: "C", fg: 0xd77757, bg: null, fgMode: "rgb", bgMode: "default", bold: true }, + { text: "l", fg: 0xd77757, bg: null, fgMode: "rgb", bgMode: "default", bold: true }, + { text: "a", fg: 0xd77757, bg: null, fgMode: "rgb", bgMode: "default", bold: true }, + { text: "u", fg: 0xd77757, bg: null, fgMode: "rgb", bgMode: "default", bold: true }, + { text: "d", fg: 0xd77757, bg: null, fgMode: "rgb", bgMode: "default", bold: true }, + { text: "e", fg: 0xd77757, bg: null, fgMode: "rgb", bgMode: "default", bold: true }, + ], + }, + { + text: "Ready", + wrapped: false, + cells: [ + { text: "R", fg: 34, bg: 18, fgMode: "palette", bgMode: "palette" }, + { text: "e", fg: 34, bg: 18, fgMode: "palette", bgMode: "palette" }, + { text: "a", fg: 34, bg: 18, fgMode: "palette", bgMode: "palette" }, + { text: "d", fg: 34, bg: 18, fgMode: "palette", bgMode: "palette" }, + { text: "y", fg: 34, bg: 18, fgMode: "palette", bgMode: "palette" }, + ], + }, + ], + }, + transcript: null, + capturedAt: new Date().toISOString(), + }); + + render(); + await flushAllTimers(); + + const terminal = mockState.terminalInstances.at(-1) as { + write: ReturnType; + } | undefined; + const written = terminal?.write.mock.calls.find(([value]) => String(value).includes("Claude"))?.[0] as string | undefined; + expect(written).toBeTruthy(); + expect(written).toContain("\x1b[?1049h"); + expect(written).toContain("\x1b[0;1;38;2;215;119;87mClaude"); + expect(written).toContain("\x1b[0;38;5;34;48;5;18mReady"); + expect(written).toContain("\x1b[2;2H"); + expect(written).not.toContain("UNSTYLED SERIALIZED"); + expect(readTranscriptTailMock).not.toHaveBeenCalled(); + }); + + it("switches back to the main buffer before hydrating normal snapshots", async () => { + const previewMock = window.ade.terminal.preview as unknown as ReturnType; + previewMock.mockResolvedValueOnce({ + terminalId: "session-normal-snapshot", + session: null, + source: "snapshot", + snapshot: { + version: 1, + terminalId: "session-normal-snapshot", + cols: 12, + rows: 2, + capturedAt: new Date().toISOString(), + status: "running", + runtimeState: "running", + bufferType: "normal", + cursorX: 0, + cursorY: 0, + baseY: 0, + viewportY: 0, + serialized: "", + visibleRows: [ + { + text: "Main", + wrapped: false, + cells: "Main".split("").map((text) => ({ + text, + fg: null, + bg: null, + fgMode: "default" as const, + bgMode: "default" as const, + })), + }, + ], + }, + transcript: null, + capturedAt: new Date().toISOString(), + }); + + render(); + await flushAllTimers(); + + const terminal = mockState.terminalInstances.at(-1) as { + write: ReturnType; + } | undefined; + const written = terminal?.write.mock.calls.find(([value]) => String(value).includes("Main"))?.[0] as string | undefined; + expect(written).toBeTruthy(); + expect(written).toContain("\x1b[?1049l"); + expect(written).not.toContain("\x1b[?1049h"); + }); + it("keeps a mounted live runtime bound to its original project while the active project changes", async () => { const view = render(); await flushAllTimers(); @@ -867,6 +1218,408 @@ describe("TerminalView", () => { expect(getTerminalRuntimeSnapshot("session-background")).toBeNull(); }); + it("paints live PTY output before initial transcript hydration finishes", async () => { + render(); + await flushAnimationFrame(); + + const terminal = mockState.terminalInstances.at(-1) as { + write: ReturnType; + refresh: ReturnType; + scrollToBottom: ReturnType; + } | undefined; + expect(terminal).toBeTruthy(); + + terminal?.write.mockClear(); + terminal?.refresh.mockClear(); + terminal?.scrollToBottom.mockClear(); + for (const listener of mockState.ptyDataListeners) { + listener({ ptyId: "pty-fast-live", sessionId: "session-fast-live", data: "codex initial frame\n" }); + } + await act(async () => { + await vi.advanceTimersByTimeAsync(16); + }); + await act(async () => { + await vi.advanceTimersByTimeAsync(16); + }); + + expect(terminal?.write).toHaveBeenCalledWith("codex initial frame\n"); + expect(terminal?.scrollToBottom).toHaveBeenCalled(); + expect(terminal?.refresh).toHaveBeenCalled(); + expect(window.ade.terminal.preview).not.toHaveBeenCalled(); + }); + + it("does not replay transcript hydration over live PTY output that already painted", async () => { + const previewMock = window.ade.terminal.preview as unknown as ReturnType; + previewMock.mockResolvedValueOnce({ + terminalId: "session-fast-transcript", + session: null, + source: "transcript", + snapshot: null, + transcript: "old transcript should not replay\n", + capturedAt: new Date().toISOString(), + }); + + render(); + await flushAnimationFrame(); + + const terminal = mockState.terminalInstances.at(-1) as { + write: ReturnType; + } | undefined; + expect(terminal).toBeTruthy(); + + terminal?.write.mockClear(); + for (const listener of mockState.ptyDataListeners) { + listener({ ptyId: "pty-fast-transcript", sessionId: "session-fast-transcript", data: "live frame\n" }); + } + await act(async () => { + await vi.advanceTimersByTimeAsync(16); + }); + await flushAllTimers(); + + expect(terminal?.write).toHaveBeenCalledWith("live frame\n"); + expect(terminal?.write).not.toHaveBeenCalledWith("old transcript should not replay\n"); + }); + + it("backfills a running terminal from preview when initial hydration was empty", async () => { + const previewMock = window.ade.terminal.preview as unknown as ReturnType; + previewMock + .mockResolvedValueOnce({ + terminalId: "session-late-snapshot", + session: null, + source: "empty", + snapshot: null, + transcript: null, + capturedAt: new Date().toISOString(), + }) + .mockResolvedValueOnce({ + terminalId: "session-late-snapshot", + session: null, + source: "transcript", + snapshot: null, + transcript: "late codex frame\n", + capturedAt: new Date().toISOString(), + }); + + render(); + await flushAllTimers(); + + const terminal = mockState.terminalInstances.at(-1) as { + write: ReturnType; + } | undefined; + expect(terminal).toBeTruthy(); + expect(terminal?.write).toHaveBeenCalledWith("late codex frame\n"); + }); + + it("does not let startup-only control bytes block later snapshot backfill", async () => { + const previewMock = window.ade.terminal.preview as unknown as ReturnType; + previewMock + .mockResolvedValueOnce({ + terminalId: "session-control-then-snapshot", + session: null, + source: "empty", + snapshot: null, + transcript: null, + capturedAt: new Date().toISOString(), + }) + .mockResolvedValueOnce({ + terminalId: "session-control-then-snapshot", + session: null, + source: "snapshot", + snapshot: { + version: 1, + terminalId: "session-control-then-snapshot", + cols: 120, + rows: 2, + capturedAt: new Date().toISOString(), + status: "running", + runtimeState: "running", + bufferType: "normal", + cursorX: 0, + cursorY: 1, + baseY: 0, + viewportY: 0, + serialized: "", + visibleRows: [ + { + text: "Codex ready", + wrapped: false, + cells: "Codex ready".split("").map((text) => ({ + text, + fg: null, + bg: null, + fgMode: "default" as const, + bgMode: "default" as const, + })), + }, + ], + }, + transcript: null, + capturedAt: new Date().toISOString(), + }); + + render(); + await flushAnimationFrame(); + + const terminal = mockState.terminalInstances.at(-1) as { + write: ReturnType; + } | undefined; + expect(terminal).toBeTruthy(); + terminal?.write.mockClear(); + + for (const listener of mockState.ptyDataListeners) { + listener({ + ptyId: "pty-control-then-snapshot", + sessionId: "session-control-then-snapshot", + data: "\x1b[?2004h\x1b[>7u\x1b[?1004h\x1b[6n\x1b[?u\x1b[c\x1b]10;?\x1b\\", + }); + } + await act(async () => { + await vi.advanceTimersByTimeAsync(16); + }); + await flushAllTimers(); + + const writes = terminal?.write.mock.calls.map(([value]) => String(value)) ?? []; + expect(writes.some((value) => value.includes("Codex ready"))).toBe(true); + }); + + it("does not let renderable cursor-diff chunks block snapshot backfill when the DOM stays blank", async () => { + const previewMock = window.ade.terminal.preview as unknown as ReturnType; + previewMock + .mockResolvedValueOnce({ + terminalId: "session-diff-then-snapshot", + session: null, + source: "empty", + snapshot: null, + transcript: null, + capturedAt: new Date().toISOString(), + }) + .mockResolvedValueOnce({ + terminalId: "session-diff-then-snapshot", + session: null, + source: "snapshot", + snapshot: { + version: 1, + terminalId: "session-diff-then-snapshot", + cols: 120, + rows: 2, + capturedAt: new Date().toISOString(), + status: "running", + runtimeState: "running", + bufferType: "normal", + cursorX: 0, + cursorY: 1, + baseY: 0, + viewportY: 0, + serialized: "", + visibleRows: [ + { + text: "Codex snapshot ready", + wrapped: false, + cells: "Codex snapshot ready".split("").map((text) => ({ + text, + fg: null, + bg: null, + fgMode: "default" as const, + bgMode: "default" as const, + })), + }, + ], + }, + transcript: null, + capturedAt: new Date().toISOString(), + }); + + render(); + await flushAnimationFrame(); + + const terminal = mockState.terminalInstances.at(-1) as { + write: ReturnType; + } | undefined; + expect(terminal).toBeTruthy(); + terminal?.write.mockClear(); + + for (const listener of mockState.ptyDataListeners) { + listener({ + ptyId: "pty-diff-then-snapshot", + sessionId: "session-diff-then-snapshot", + data: "\x1b[29;3HStarting MCP server", + }); + } + await act(async () => { + await vi.advanceTimersByTimeAsync(16); + }); + await flushAllTimers(); + + const writes = terminal?.write.mock.calls.map(([value]) => String(value)) ?? []; + expect(writes.some((value) => value.includes("Codex snapshot ready"))).toBe(true); + }); + + it("polls the preview snapshot quickly when visible Codex cursor-diff output leaves the DOM blank", async () => { + const previewMock = window.ade.terminal.preview as unknown as ReturnType; + previewMock.mockResolvedValue({ + terminalId: "session-fast-blank-snapshot", + session: null, + source: "snapshot", + snapshot: { + version: 1, + terminalId: "session-fast-blank-snapshot", + cols: 120, + rows: 2, + capturedAt: new Date().toISOString(), + status: "running", + runtimeState: "running", + bufferType: "normal", + cursorX: 0, + cursorY: 1, + baseY: 0, + viewportY: 0, + serialized: "", + visibleRows: [ + { + text: "Fast snapshot paint", + wrapped: false, + cells: "Fast snapshot paint".split("").map((text) => ({ + text, + fg: null, + bg: null, + fgMode: "default" as const, + bgMode: "default" as const, + })), + }, + ], + }, + transcript: null, + capturedAt: new Date().toISOString(), + }); + + render(); + await flushAnimationFrame(); + + const terminal = mockState.terminalInstances.at(-1) as { + write: ReturnType; + } | undefined; + expect(terminal).toBeTruthy(); + terminal?.write.mockClear(); + + for (const listener of mockState.ptyDataListeners) { + listener({ + ptyId: "pty-fast-blank-snapshot", + sessionId: "session-fast-blank-snapshot", + data: "\x1b[29;3HStarting MCP server", + }); + } + await act(async () => { + await vi.advanceTimersByTimeAsync(100); + }); + + const writes = terminal?.write.mock.calls.map(([value]) => String(value)) ?? []; + expect(writes.some((value) => value.includes("Fast snapshot paint"))).toBe(true); + }); + + it("does not spend the backfill retry budget on replaced timers", async () => { + const previewMock = window.ade.terminal.preview as unknown as ReturnType; + previewMock + .mockResolvedValueOnce({ + terminalId: "session-churn-blank-snapshot", + session: null, + source: "empty", + snapshot: null, + transcript: null, + capturedAt: new Date().toISOString(), + }) + .mockResolvedValueOnce({ + terminalId: "session-churn-blank-snapshot", + session: null, + source: "empty", + snapshot: null, + transcript: null, + capturedAt: new Date().toISOString(), + }) + .mockResolvedValue({ + terminalId: "session-churn-blank-snapshot", + session: null, + source: "snapshot", + snapshot: { + version: 1, + terminalId: "session-churn-blank-snapshot", + cols: 120, + rows: 2, + capturedAt: new Date().toISOString(), + status: "running", + runtimeState: "running", + bufferType: "normal", + cursorX: 0, + cursorY: 1, + baseY: 0, + viewportY: 0, + serialized: "", + visibleRows: [ + { + text: "Snapshot after timer churn", + wrapped: false, + cells: "Snapshot after timer churn".split("").map((text) => ({ + text, + fg: null, + bg: null, + fgMode: "default" as const, + bgMode: "default" as const, + })), + }, + ], + }, + transcript: null, + capturedAt: new Date().toISOString(), + }); + + render(); + await flushAnimationFrame(); + + const terminal = mockState.terminalInstances.at(-1) as { + write: ReturnType; + } | undefined; + expect(terminal).toBeTruthy(); + terminal?.write.mockClear(); + + for (let index = 0; index < 130; index += 1) { + for (const listener of mockState.ptyDataListeners) { + listener({ + ptyId: "pty-churn-blank-snapshot", + sessionId: "session-churn-blank-snapshot", + data: `\x1b[29;3HStarting MCP server ${index}`, + }); + } + } + + await act(async () => { + await vi.advanceTimersByTimeAsync(100); + }); + await flushAllTimers(); + + const writes = terminal?.write.mock.calls.map(([value]) => String(value)) ?? []; + expect(writes.some((value) => value.includes("Snapshot after timer churn"))).toBe(true); + }); + + it("does not mask the terminal while waiting for the first xterm text frame", async () => { + const view = render(); + await flushAnimationFrame(); + + expect(view.queryByTestId("terminal-startup-loading")).toBeNull(); + + const terminal = mockState.terminalInstances.at(-1) as { + element: HTMLElement | null; + } | undefined; + const rows = document.createElement("div"); + rows.className = "xterm-rows"; + rows.textContent = "Codex is ready"; + terminal?.element?.appendChild(rows); + + await act(async () => { + await Promise.resolve(); + }); + + expect(view.queryByTestId("terminal-startup-loading")).toBeNull(); + }); + it("writes PTY output into the parked runtime so the terminal state stays current", async () => { const firstView = render(); await flushAllTimers(); diff --git a/apps/desktop/src/renderer/components/terminals/TerminalView.tsx b/apps/desktop/src/renderer/components/terminals/TerminalView.tsx index b41f354ce..4616e9a49 100644 --- a/apps/desktop/src/renderer/components/terminals/TerminalView.tsx +++ b/apps/desktop/src/renderer/components/terminals/TerminalView.tsx @@ -12,6 +12,7 @@ import { } from "../../state/appStore"; import { WORK_SURFACE_REVEALED_EVENT } from "./workSurfaceVisibility"; import { openUrlInAdeBrowser } from "../../lib/openExternal"; +import type { TerminalSerializedSnapshot, TerminalSnapshotCell, TerminalSnapshotRow } from "../../../shared/types"; type XtermTheme = NonNullable[0]>["theme"]; type TerminalRendererMode = "webgl" | "dom"; @@ -60,9 +61,13 @@ type CachedRuntime = { settleTimer2: ReturnType | null; hydrateTimer: ReturnType | null; hydrateRetryTimer: ReturnType | null; + hydrationBackfillTimer: ReturnType | null; + hydrationBackfillAttempts: number; hasFittedOnce: boolean; hydrationStarted: boolean; hydrationCompleted: boolean; + hasAppliedTerminalContent: boolean; + displayedLiveDataBeforeHydration: boolean; pendingHydrationChunks: string[]; pendingHydrationBytes: number; frameWriteChunks: string[]; @@ -79,6 +84,9 @@ type CachedRuntime = { inputEnabled: boolean; active: boolean; visible: boolean; + mouseTrackingModes: Set; + shiftMouseBridgeCleanup: (() => void) | null; + shiftMouseCleanup: (() => void) | null; // Set when a webgl→dom fallback is in flight and the runtime turned // invisible before the webgl restore could run. Persists across renderer // changes so the restore can be retried on the next visibility-true. @@ -88,6 +96,9 @@ type CachedRuntime = { }; const HYDRATE_TAIL_BYTES = 2_000_000; +const HYDRATION_BACKFILL_RETRY_MS = 250; +const HYDRATION_VISIBLE_BLANK_BACKFILL_RETRY_MS = 100; +const HYDRATION_BACKFILL_MAX_ATTEMPTS = 120; const MAX_PENDING_HYDRATION_BYTES = 2_000_000; const MAX_FRAME_WRITE_BYTES = 1_000_000; const EXITED_RUNTIME_KEEPALIVE_MS = 8_000; @@ -100,6 +111,7 @@ const RENDERER_RESET_COOLDOWN_MS = 250; const TERMINAL_RENDERER_STORAGE_KEY = "ade.terminalRenderer"; const TERMINAL_CTRL_V = "\x16"; const TERMINAL_LINK_PATTERN = /(?:https?:\/\/[^\s<>"'`]+|(?:localhost|127\.0\.0\.1|\[::1\])(?::\d+)?(?:\/[^\s<>"'`]*)?)/gi; +const TERMINAL_MOUSE_TRACKING_EVENT_MODES = new Set([1000, 1002, 1003]); const runtimeCache = new Map(); let parkedRoot: HTMLDivElement | null = null; @@ -200,6 +212,20 @@ type TerminalDims = { rows: number; }; +type InitialHydrationData = { + source: "snapshot" | "transcript" | "empty"; + text: string; +}; + +type PreviewHydrationOptions = { + snapshotOnly?: boolean; +}; + +type HydrationBackfillOptions = PreviewHydrationOptions & { + delayMs?: number; + replaceExistingTimer?: boolean; +}; + function computeSuffixPrefixOverlap(left: string, right: string, maxChars = 12_000): number { if (!left.length || !right.length) return 0; const cap = Math.min(maxChars, left.length, right.length); @@ -224,6 +250,123 @@ function trimToLikelyTerminalFrameBoundary(raw: string): string { return raw.slice(idx); } +function hasRenderableTerminalText(data: string): boolean { + if (!data.length) return false; + const withoutControlSequences = data + .replace(/\x1b\][\s\S]*?(?:\x07|\x1b\\)/g, "") + .replace(/\x1bP[\s\S]*?(?:\x07|\x1b\\)/g, "") + .replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, "") + .replace(/\x1b[()][A-Za-z0-9]/g, "") + .replace(/\x1b[@-_]/g, "") + .replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g, ""); + return /\S/.test(withoutControlSequences); +} + +function terminalDomHasRenderableText(runtime: CachedRuntime): boolean { + const rows = runtime.term.element?.querySelector(".xterm-rows") + ?? runtime.host.querySelector(".xterm-rows"); + return Boolean(rows?.innerText?.trim() || rows?.textContent?.trim()); +} + +function needsHydrationBackfill(runtime: CachedRuntime): boolean { + if (runtime.disposed || runtime.exitCode != null) return false; + if (!runtime.hasAppliedTerminalContent && !runtime.displayedLiveDataBeforeHydration) return true; + return Boolean(runtime.visible && runtime.active && !terminalDomHasRenderableText(runtime)); +} + +function ansiRgbParts(value: number | null | undefined): [number, number, number] | null { + if (typeof value !== "number" || !Number.isFinite(value)) return null; + const safe = Math.max(0, Math.min(0xffffff, Math.floor(value))); + return [(safe >> 16) & 0xff, (safe >> 8) & 0xff, safe & 0xff]; +} + +function sgrCodesForSnapshotCell(cell: TerminalSnapshotCell): string[] { + const codes = ["0"]; + if (cell.bold) codes.push("1"); + if (cell.dim) codes.push("2"); + if (cell.italic) codes.push("3"); + if (cell.underline) codes.push("4"); + if (cell.inverse) codes.push("7"); + if (cell.strikethrough) codes.push("9"); + + if (cell.fgMode === "rgb") { + const rgb = ansiRgbParts(cell.fg); + if (rgb) codes.push("38", "2", String(rgb[0]), String(rgb[1]), String(rgb[2])); + } else if (cell.fgMode === "palette" && typeof cell.fg === "number" && Number.isFinite(cell.fg)) { + codes.push("38", "5", String(Math.max(0, Math.min(255, Math.floor(cell.fg))))); + } + + if (cell.bgMode === "rgb") { + const rgb = ansiRgbParts(cell.bg); + if (rgb) codes.push("48", "2", String(rgb[0]), String(rgb[1]), String(rgb[2])); + } else if (cell.bgMode === "palette" && typeof cell.bg === "number" && Number.isFinite(cell.bg)) { + codes.push("48", "5", String(Math.max(0, Math.min(255, Math.floor(cell.bg))))); + } + + return codes; +} + +function snapshotCellStyleKey(cell: TerminalSnapshotCell): string { + return [ + cell.fgMode, + cell.fg ?? "", + cell.bgMode, + cell.bg ?? "", + cell.bold ? "b" : "", + cell.dim ? "d" : "", + cell.italic ? "i" : "", + cell.underline ? "u" : "", + cell.inverse ? "v" : "", + cell.strikethrough ? "s" : "", + ].join("|"); +} + +function isTrimmedSnapshotBlank(cell: TerminalSnapshotCell | undefined): boolean { + return Boolean( + cell + && (cell.text || " ") === " " + && cell.bgMode === "default" + && !cell.inverse, + ); +} + +function trimmedSnapshotCells(row: TerminalSnapshotRow): TerminalSnapshotCell[] { + let end = row.cells.length; + while (end > 0 && isTrimmedSnapshotBlank(row.cells[end - 1])) end -= 1; + return row.cells.slice(0, end); +} + +function serializeSnapshotVisibleRows(snapshot: TerminalSerializedSnapshot): string | null { + const rows = snapshot.visibleRows.slice(0, Math.max(0, snapshot.rows)); + if (rows.length === 0) return null; + + const parts: string[] = []; + if (snapshot.bufferType === "alternate") parts.push("\x1b[?1049h"); + else parts.push("\x1b[?1049l"); + parts.push("\x1b[?25l", "\x1b[?7l", "\x1b[0m", "\x1b[H", "\x1b[J"); + + rows.forEach((row, y) => { + const cells = trimmedSnapshotCells(row); + if (cells.length === 0) return; + parts.push(`\x1b[${y + 1};1H`); + let lastStyleKey = ""; + for (const cell of cells) { + const styleKey = snapshotCellStyleKey(cell); + if (styleKey !== lastStyleKey) { + parts.push(`\x1b[${sgrCodesForSnapshotCell(cell).join(";")}m`); + lastStyleKey = styleKey; + } + parts.push(cell.text || " "); + } + parts.push("\x1b[0m"); + }); + + const cursorY = Math.max(0, Math.min(Math.max(0, snapshot.rows - 1), snapshot.cursorY)); + const cursorX = Math.max(0, Math.min(Math.max(0, snapshot.cols - 1), snapshot.cursorX)); + parts.push(`\x1b[${cursorY + 1};${cursorX + 1}H`, "\x1b[?7h", "\x1b[?25h"); + return parts.join(""); +} + function ensureParkedRoot(): HTMLDivElement { if (parkedRoot && parkedRoot.isConnected) return parkedRoot; const next = document.createElement("div"); @@ -409,6 +552,103 @@ function writePtyInput(runtime: CachedRuntime, data: string) { window.ade.pty.write({ ptyId: runtime.ptyId, data }).catch(() => {}); } +function updateTerminalMouseTrackingModes(runtime: CachedRuntime, data: string): void { + for (const match of data.matchAll(/\x1b\[\?([0-9;]+)([hl])/g)) { + const action = match[2]; + for (const rawParam of match[1].split(";")) { + const mode = Number(rawParam); + if (!TERMINAL_MOUSE_TRACKING_EVENT_MODES.has(mode)) continue; + if (action === "h") { + runtime.mouseTrackingModes.add(mode); + } else { + runtime.mouseTrackingModes.delete(mode); + } + } + } +} + +function clampTerminalMouseCoordinate(value: number, max: number): number { + if (!Number.isFinite(value)) return 1; + return Math.max(1, Math.min(max, Math.floor(value))); +} + +function terminalMousePoint(runtime: CachedRuntime, ev: MouseEvent): { col: number; row: number } | null { + const screen = runtime.term.element?.querySelector(".xterm-screen") + ?? runtime.host.querySelector(".xterm-screen") + ?? runtime.term.element + ?? runtime.host; + const rect = screen.getBoundingClientRect(); + if (rect.width <= 0 || rect.height <= 0) return null; + const cols = Math.max(1, runtime.term.cols || 1); + const rows = Math.max(1, runtime.term.rows || 1); + const col = clampTerminalMouseCoordinate(((ev.clientX - rect.left) / rect.width) * cols + 1, cols); + const row = clampTerminalMouseCoordinate(((ev.clientY - rect.top) / rect.height) * rows + 1, rows); + return { col, row }; +} + +function writeSgrMouse(runtime: CachedRuntime, code: number, point: { col: number; row: number }, final: "M" | "m"): void { + writePtyInput(runtime, `\x1b[<${code};${point.col};${point.row}${final}`); +} + +function isTerminalMouseTrackingActive(runtime: CachedRuntime): boolean { + const xtermMode = runtime.term.modes?.mouseTrackingMode; + return runtime.mouseTrackingModes.size > 0 || (xtermMode != null && xtermMode !== "none"); +} + +function consumeMouseEvent(ev: MouseEvent): void { + ev.preventDefault(); + ev.stopPropagation(); + ev.stopImmediatePropagation(); +} + +function installShiftMouseBridge(runtime: CachedRuntime): void { + const onMouseDown = (ev: MouseEvent) => { + if (runtime.disposed || !isTerminalMouseTrackingActive(runtime)) return; + if (!ev.shiftKey || ev.button !== 0) return; + const point = terminalMousePoint(runtime, ev); + if (!point) return; + + consumeMouseEvent(ev); + writeSgrMouse(runtime, 4, point, "M"); + + let cleanup = () => {}; + const onMouseMove = (moveEv: MouseEvent) => { + if (runtime.disposed) { + cleanup(); + return; + } + if ((moveEv.buttons & 1) === 0) return; + const nextPoint = terminalMousePoint(runtime, moveEv); + if (!nextPoint) return; + consumeMouseEvent(moveEv); + writeSgrMouse(runtime, 36, nextPoint, "M"); + }; + const onMouseUp = (upEv: MouseEvent) => { + cleanup(); + if (runtime.disposed) return; + const releasePoint = terminalMousePoint(runtime, upEv) ?? point; + consumeMouseEvent(upEv); + writeSgrMouse(runtime, 4, releasePoint, "m"); + }; + + cleanup = () => { + document.removeEventListener("mousemove", onMouseMove, true); + document.removeEventListener("mouseup", onMouseUp, true); + if (runtime.shiftMouseCleanup === cleanup) runtime.shiftMouseCleanup = null; + }; + runtime.shiftMouseCleanup?.(); + runtime.shiftMouseCleanup = cleanup; + document.addEventListener("mousemove", onMouseMove, true); + document.addEventListener("mouseup", onMouseUp, true); + }; + runtime.host.addEventListener("mousedown", onMouseDown, true); + const cleanupBridge = () => { + runtime.host.removeEventListener("mousedown", onMouseDown, true); + if (runtime.shiftMouseBridgeCleanup === cleanupBridge) runtime.shiftMouseBridgeCleanup = null; + }; + runtime.shiftMouseBridgeCleanup = cleanupBridge; +} + async function pasteNativeClipboardImageShortcut(runtime: CachedRuntime): Promise { if (runtime.disposed) return false; try { @@ -431,7 +671,18 @@ function teardownRuntime(runtime: CachedRuntime) { if (runtime.settleTimer2) clearTimeout(runtime.settleTimer2); if (runtime.hydrateTimer) clearTimeout(runtime.hydrateTimer); if (runtime.hydrateRetryTimer) clearTimeout(runtime.hydrateRetryTimer); + if (runtime.hydrationBackfillTimer) clearTimeout(runtime.hydrationBackfillTimer); if (runtime.invalidFitRetryTimer) clearTimeout(runtime.invalidFitRetryTimer); + try { + runtime.shiftMouseCleanup?.(); + } catch { + // ignore + } + try { + runtime.shiftMouseBridgeCleanup?.(); + } catch { + // ignore + } try { runtime.ptyDataUnsub?.(); @@ -603,11 +854,30 @@ function flushFrameWriteChunksSync(runtime: CachedRuntime) { runtime.frameWriteBytes = 0; try { runtime.term.write(merged); + if (hasRenderableTerminalText(merged)) { + runtime.hasAppliedTerminalContent = true; + } + scheduleVisibleFrameRefresh(runtime); } catch { // ignore write errors after disposal } } +function scheduleVisibleFrameRefresh(runtime: CachedRuntime) { + if (runtime.disposed || runtime.refs === 0 || !runtime.visible || !runtime.active) return; + if (document.visibilityState !== "visible") return; + requestAnimationFrame(() => { + if (runtime.disposed || runtime.refs === 0 || !runtime.visible || !runtime.active) return; + if (document.visibilityState !== "visible") return; + try { + runtime.term.scrollToBottom(); + runtime.term.refresh(0, Math.max(0, runtime.term.rows - 1)); + } catch { + // ignore refresh failures after disposal + } + }); +} + function clearFrameWriteSchedule(runtime: CachedRuntime) { if (runtime.flushRafId != null) { cancelAnimationFrame(runtime.flushRafId); @@ -619,6 +889,12 @@ function clearFrameWriteSchedule(runtime: CachedRuntime) { } } +function discardScheduledFrameWrites(runtime: CachedRuntime) { + clearFrameWriteSchedule(runtime); + runtime.frameWriteChunks.length = 0; + runtime.frameWriteBytes = 0; +} + function flushPendingFrameWrites(runtime: CachedRuntime) { clearFrameWriteSchedule(runtime); flushFrameWriteChunksSync(runtime); @@ -654,15 +930,20 @@ function scheduleFrameWriteFlush(runtime: CachedRuntime) { runtime.flushRafId = requestAnimationFrame(flush); } -function flushHydrationData(runtime: CachedRuntime, tail: string) { +function flushHydrationData( + runtime: CachedRuntime, + tail: string, + options: { appendPending?: boolean } = {}, +) { const stabilizedTail = trimToLikelyTerminalFrameBoundary(tail); - const pending = runtime.pendingHydrationChunks.join(""); + const shouldAppendPending = options.appendPending ?? true; + const pending = shouldAppendPending ? runtime.pendingHydrationChunks.join("") : ""; runtime.pendingHydrationChunks.length = 0; runtime.pendingHydrationBytes = 0; const overlap = computeSuffixPrefixOverlap(stabilizedTail, pending); - let appendPending = true; - if (pending.length >= 8_000 && overlap < 64) { + let appendPending = shouldAppendPending; + if (shouldAppendPending && pending.length >= 8_000 && overlap < 64) { const probe = pending.slice(0, Math.min(512, pending.length)); if (probe.length >= 64 && stabilizedTail.lastIndexOf(probe) !== -1) { appendPending = false; @@ -673,6 +954,9 @@ function flushHydrationData(runtime: CachedRuntime, tail: string) { if (merged.length) { try { runtime.term.write(merged); + if (hasRenderableTerminalText(merged)) { + runtime.hasAppliedTerminalContent = true; + } requestAnimationFrame(() => { try { runtime.term.refresh(0, Math.max(0, runtime.term.rows - 1)); @@ -687,25 +971,102 @@ function flushHydrationData(runtime: CachedRuntime, tail: string) { } } +async function readPreviewHydrationData( + runtime: CachedRuntime, + options: PreviewHydrationOptions = {}, +): Promise { + const preview = await window.ade.terminal.preview({ + terminalId: runtime.sessionId, + maxBytes: HYDRATE_TAIL_BYTES, + }); + if (preview?.snapshot) { + const visibleRows = serializeSnapshotVisibleRows(preview.snapshot); + if (visibleRows) return { source: "snapshot", text: visibleRows }; + if (preview.snapshot.serialized) return { source: "snapshot", text: preview.snapshot.serialized }; + } + if (options.snapshotOnly) return { source: "empty", text: "" }; + if (preview?.transcript) return { source: "transcript", text: preview.transcript }; + return { source: "empty", text: "" }; +} + +async function readInitialHydrationData(runtime: CachedRuntime): Promise { + try { + const previewData = await readPreviewHydrationData(runtime); + if (previewData.text) return previewData; + } catch { + // Fall back to the transcript tail below. + } + + const transcript = await window.ade.sessions.readTranscriptTail({ + sessionId: runtime.sessionId, + maxBytes: HYDRATE_TAIL_BYTES, + raw: true, + }); + return transcript + ? { source: "transcript", text: transcript } + : { source: "empty", text: "" }; +} + +function scheduleHydrationBackfill(runtime: CachedRuntime, options: HydrationBackfillOptions = {}) { + if (!needsHydrationBackfill(runtime)) return; + if (runtime.hydrationBackfillAttempts >= HYDRATION_BACKFILL_MAX_ATTEMPTS) return; + if (runtime.hydrationBackfillTimer) { + if (!options.replaceExistingTimer) return; + clearTimeout(runtime.hydrationBackfillTimer); + runtime.hydrationBackfillTimer = null; + } + + const delayMs = options.delayMs + ?? (runtime.visible && runtime.active ? HYDRATION_VISIBLE_BLANK_BACKFILL_RETRY_MS : HYDRATION_BACKFILL_RETRY_MS); + const snapshotOnly = options.snapshotOnly ?? runtime.displayedLiveDataBeforeHydration; + runtime.hydrationBackfillTimer = setTimeout(() => { + runtime.hydrationBackfillTimer = null; + if (!needsHydrationBackfill(runtime)) return; + runtime.hydrationBackfillAttempts += 1; + if (runtime.hydrationBackfillAttempts > HYDRATION_BACKFILL_MAX_ATTEMPTS) return; + + readPreviewHydrationData(runtime, { snapshotOnly }) + .then((data) => { + if (!needsHydrationBackfill(runtime)) return; + if (data.text.length > 0) { + discardScheduledFrameWrites(runtime); + flushHydrationData(runtime, data.text, { appendPending: data.source !== "snapshot" }); + scheduleFit(runtime, true); + return; + } + scheduleHydrationBackfill(runtime, { snapshotOnly }); + }) + .catch(() => { + if (runtime.disposed || runtime.exitCode != null) return; + scheduleHydrationBackfill(runtime, { snapshotOnly }); + }); + }, delayMs); +} + function startHydration(runtime: CachedRuntime) { if (runtime.hydrationStarted || runtime.disposed) return; runtime.hydrationStarted = true; + const finalizeHydration = (data: InitialHydrationData) => { + if (runtime.disposed) return; + const preferLivePending = runtime.displayedLiveDataBeforeHydration && data.source !== "snapshot"; + if (preferLivePending) { + runtime.pendingHydrationChunks.length = 0; + runtime.pendingHydrationBytes = 0; + flushPendingFrameWrites(runtime); + } else { + discardScheduledFrameWrites(runtime); + flushHydrationData(runtime, data.text, { appendPending: data.source !== "snapshot" }); + } + runtime.hydrationCompleted = true; + scheduleFit(runtime, true); + scheduleHydrationBackfill(runtime, { snapshotOnly: runtime.displayedLiveDataBeforeHydration }); + }; + const hydrateTranscript = () => { - window.ade.sessions - .readTranscriptTail({ sessionId: runtime.sessionId, maxBytes: HYDRATE_TAIL_BYTES, raw: true }) - .then((text) => { - if (runtime.disposed) return; - flushHydrationData(runtime, text); - runtime.hydrationCompleted = true; - scheduleFit(runtime, true); - }) - .catch(() => { - if (runtime.disposed) return; - flushHydrationData(runtime, ""); - runtime.hydrationCompleted = true; - scheduleFit(runtime, true); - }); + readInitialHydrationData(runtime) + .then(finalizeHydration) + .catch(() => finalizeHydration({ source: "empty", text: "" })); }; const waitForFitThenHydrate = (attempt: number) => { @@ -911,9 +1272,13 @@ function createRuntime(args: { settleTimer2: null, hydrateTimer: null, hydrateRetryTimer: null, + hydrationBackfillTimer: null, + hydrationBackfillAttempts: 0, hasFittedOnce: false, hydrationStarted: false, hydrationCompleted: false, + hasAppliedTerminalContent: false, + displayedLiveDataBeforeHydration: false, pendingHydrationChunks: [], pendingHydrationBytes: 0, frameWriteChunks: [], @@ -930,6 +1295,9 @@ function createRuntime(args: { inputEnabled: true, active: true, visible: true, + mouseTrackingModes: new Set(), + shiftMouseBridgeCleanup: null, + shiftMouseCleanup: null, pendingWebGLRestore: false, invalidFitRetryTimer: null, fitWarningLogged: false @@ -950,6 +1318,7 @@ function createRuntime(args: { } void pasteNativeClipboardImageShortcut(runtime); }, true); + installShiftMouseBridge(runtime); term.attachCustomKeyEventHandler((ev) => { if (!runtime.inputEnabled) return false; @@ -992,6 +1361,11 @@ function createRuntime(args: { navigator.clipboard.writeText(selection).catch(() => {}); return false; } + if (isMac && ev.metaKey) { + ev.preventDefault(); + writePtyInput(runtime, "\x03"); + return false; + } return true; } @@ -1049,6 +1423,7 @@ function createRuntime(args: { if (runtime.disposed) return; if (ev.projectRoot && runtime.projectRoot && ev.projectRoot !== runtime.projectRoot) return; if (ev.ptyId !== runtime.ptyId) return; + updateTerminalMouseTrackingModes(runtime, ev.data); if (!runtime.hydrationCompleted) { runtime.pendingHydrationChunks.push(ev.data); @@ -1058,6 +1433,17 @@ function createRuntime(args: { runtime.pendingHydrationBytes -= dropped?.length ?? 0; incrementHealth(runtime, "droppedChunks"); } + if (hasRenderableTerminalText(ev.data)) { + runtime.displayedLiveDataBeforeHydration = true; + if (runtime.visible && runtime.active && !terminalDomHasRenderableText(runtime)) { + scheduleHydrationBackfill(runtime, { + delayMs: HYDRATION_VISIBLE_BLANK_BACKFILL_RETRY_MS, + replaceExistingTimer: true, + snapshotOnly: true, + }); + } + } + enqueueFrameWrite(runtime, ev.data); return; } @@ -1428,6 +1814,13 @@ export function TerminalView({ }; clearTextureAtlas(runtime); flushPendingFrameWrites(runtime); + if (runtime.active && runtime.displayedLiveDataBeforeHydration && !terminalDomHasRenderableText(runtime)) { + scheduleHydrationBackfill(runtime, { + delayMs: HYDRATION_VISIBLE_BLANK_BACKFILL_RETRY_MS, + replaceExistingTimer: true, + snapshotOnly: true, + }); + } // Replay a webgl restore that was deferred when the runtime turned // invisible mid-fallback. Without this, runtime.renderer stays "dom" // and resetWebglRenderer's webgl-only guard would silently skip retry. diff --git a/apps/desktop/src/renderer/components/terminals/WorkViewArea.test.tsx b/apps/desktop/src/renderer/components/terminals/WorkViewArea.test.tsx index 3f7a8852b..697486750 100644 --- a/apps/desktop/src/renderer/components/terminals/WorkViewArea.test.tsx +++ b/apps/desktop/src/renderer/components/terminals/WorkViewArea.test.tsx @@ -642,6 +642,270 @@ describe("WorkViewArea", () => { expect(modelsMock).toHaveBeenCalledWith({ provider: "claude" }); }); + it("uses colored terminal snapshots for closed TUI sessions", async () => { + const plainCell = (text: string) => ({ + text, + fg: null, + bg: null, + fgMode: "default" as const, + bgMode: "default" as const, + }); + const claudeCell = (text: string) => ({ + text, + fg: 0xd77757, + bg: null, + fgMode: "rgb" as const, + bgMode: "default" as const, + bold: true, + }); + terminalPreviewMock.mockResolvedValueOnce({ + terminalId: "session-1", + source: "snapshot", + transcript: "\u001b[38;2;215;119;87mplain transcript fallback\u001b[0m\n", + capturedAt: "2026-04-06T12:10:00.000Z", + snapshot: { + version: 1, + terminalId: "session-1", + cols: 24, + rows: 3, + capturedAt: "2026-04-06T12:10:00.000Z", + status: "completed", + runtimeState: "exited", + bufferType: "normal", + cursorX: 0, + cursorY: 0, + baseY: 0, + viewportY: 0, + serialized: "", + visibleRows: [ + { + text: "╭─Claude Code─╮", + wrapped: false, + cells: [ + ...Array.from("╭─", plainCell), + ...Array.from("Claude Code", claudeCell), + ...Array.from("─╮", plainCell), + ], + }, + { + text: "│ Ready │", + wrapped: false, + cells: Array.from("│ Ready │", plainCell), + }, + { + text: "╰─────────────╯", + wrapped: false, + cells: Array.from("╰─────────────╯", plainCell), + }, + ], + }, + }); + const session = { + ...makeSession(), + toolType: "claude" as const, + resumeCommand: "claude --resume abc123", + resumeMetadata: { + provider: "claude" as const, + targetKind: "session" as const, + targetId: "abc123", + launch: { permissionMode: "default" as const }, + }, + }; + + const view = render( + {}} + onSelectItem={() => {}} + onCloseItem={() => {}} + onOpenChatSession={() => {}} + onLaunchPtySession={async () => ({})} + onShowDraftKind={() => {}} + onToggleTabGroupCollapsed={() => {}} + closingPtyIds={new Set()} + />, + ); + const local = within(view.container); + + await local.findByText("Claude Code"); + const coloredLabel = Array.from(view.container.querySelectorAll("span")) + .find((node) => ( + node.textContent === "Claude Code" + && (node as HTMLElement).style.color === "rgb(215, 119, 87)" + )) as HTMLElement | undefined; + expect(coloredLabel).toBeTruthy(); + expect(coloredLabel?.style.fontWeight).toBe("700"); + expect(local.getByText(/Ready/)).toBeTruthy(); + expect(local.queryByText(/plain transcript fallback/)).toBeNull(); + expect(local.queryAllByTestId("terminal-view")).toHaveLength(0); + }); + + it("treats background-styled spaces as visible TUI snapshot cells", async () => { + const plainCell = (text: string) => ({ + text, + fg: null, + bg: null, + fgMode: "default" as const, + bgMode: "default" as const, + }); + const bgCell = () => ({ + text: " ", + fg: null, + bg: 0x17324d, + fgMode: "default" as const, + bgMode: "rgb" as const, + }); + terminalPreviewMock.mockResolvedValueOnce({ + terminalId: "session-1", + source: "snapshot", + transcript: "plain transcript fallback\n", + capturedAt: "2026-04-06T12:10:00.000Z", + snapshot: { + version: 1, + terminalId: "session-1", + cols: 8, + rows: 3, + capturedAt: "2026-04-06T12:10:00.000Z", + status: "completed", + runtimeState: "exited", + bufferType: "normal", + cursorX: 0, + cursorY: 0, + baseY: 0, + viewportY: 0, + serialized: "", + visibleRows: ["A", "B", "C"].map((label) => ({ + text: `${label} `, + wrapped: false, + cells: [plainCell(label), ...Array.from({ length: 5 }, bgCell)], + })), + }, + }); + const session = { + ...makeSession(), + toolType: "codex" as const, + resumeCommand: "codex resume thread-1", + resumeMetadata: { + provider: "codex" as const, + targetKind: "thread" as const, + targetId: "thread-1", + launch: { permissionMode: "plan" as const }, + }, + }; + + const view = render( + {}} + onSelectItem={() => {}} + onCloseItem={() => {}} + onOpenChatSession={() => {}} + onLaunchPtySession={async () => ({})} + onShowDraftKind={() => {}} + onToggleTabGroupCollapsed={() => {}} + closingPtyIds={new Set()} + />, + ); + const local = within(view.container); + + expect(await local.findByText("A")).toBeTruthy(); + expect(local.getByText("B")).toBeTruthy(); + expect(local.getByText("C")).toBeTruthy(); + expect(local.queryByText(/plain transcript fallback/)).toBeNull(); + expect(local.queryAllByTestId("terminal-view")).toHaveLength(0); + }); + + it("treats fully styled blank snapshot rows as TUI content", async () => { + const bgCell = () => ({ + text: " ", + fg: null, + bg: 0x17324d, + fgMode: "default" as const, + bgMode: "rgb" as const, + }); + terminalPreviewMock.mockResolvedValueOnce({ + terminalId: "session-1", + source: "snapshot", + transcript: "plain transcript fallback\n", + capturedAt: "2026-04-06T12:10:00.000Z", + snapshot: { + version: 1, + terminalId: "session-1", + cols: 8, + rows: 3, + capturedAt: "2026-04-06T12:10:00.000Z", + status: "completed", + runtimeState: "exited", + bufferType: "normal", + cursorX: 0, + cursorY: 0, + baseY: 0, + viewportY: 0, + serialized: "", + visibleRows: Array.from({ length: 3 }, () => ({ + text: " ", + wrapped: false, + cells: Array.from({ length: 5 }, bgCell), + })), + }, + }); + const session = { + ...makeSession(), + toolType: "codex" as const, + resumeCommand: "codex resume thread-1", + resumeMetadata: { + provider: "codex" as const, + targetKind: "thread" as const, + targetId: "thread-1", + launch: { permissionMode: "plan" as const }, + }, + }; + + const view = render( + {}} + onSelectItem={() => {}} + onCloseItem={() => {}} + onOpenChatSession={() => {}} + onLaunchPtySession={async () => ({})} + onShowDraftKind={() => {}} + onToggleTabGroupCollapsed={() => {}} + closingPtyIds={new Set()} + />, + ); + const local = within(view.container); + + await waitFor(() => { + expect(local.queryByText(/plain transcript fallback/)).toBeNull(); + expect(local.queryAllByTestId("terminal-view")).toHaveLength(0); + }); + }); + it("submits continuation text for ended agent CLI sessions", async () => { const onContinue = vi.fn().mockResolvedValue(undefined); const session = { diff --git a/apps/desktop/src/renderer/components/terminals/WorkViewArea.tsx b/apps/desktop/src/renderer/components/terminals/WorkViewArea.tsx index a2ec17102..8672b9b49 100644 --- a/apps/desktop/src/renderer/components/terminals/WorkViewArea.tsx +++ b/apps/desktop/src/renderer/components/terminals/WorkViewArea.tsx @@ -230,6 +230,37 @@ function snapshotRuns(row: TerminalSnapshotRow): Array<{ text: string; style: CS return runs; } +const TUI_FRAME_CHARS = /[╭╮╰╯│─┌┐└┘├┤┬┴┼▐▌▀▄█▛▜▝▘]/; + +function cellHasVisibleStyle(cell: TerminalSnapshotCell): boolean { + const hasText = (cell.text || " ") !== " "; + const hasTextStyle = ( + cell.fgMode !== "default" + || cell.bgMode !== "default" + || Boolean(cell.bold) + || Boolean(cell.dim) + || Boolean(cell.italic) + || Boolean(cell.underline) + || Boolean(cell.inverse) + || Boolean(cell.strikethrough) + ); + if (hasText) return hasTextStyle; + return cell.bgMode !== "default" || Boolean(cell.inverse); +} + +function snapshotLooksLikeTui(rows: TerminalSnapshotRow[]): boolean { + let nonBlankRows = 0; + let styledCells = 0; + for (const row of rows) { + const text = row.text.trimEnd(); + const visibleStyleCells = row.cells.filter(cellHasVisibleStyle).length; + if (text.trim() || visibleStyleCells > 0) nonBlankRows += 1; + if (TUI_FRAME_CHARS.test(text)) return true; + styledCells += visibleStyleCells; + } + return nonBlankRows >= 3 && styledCells >= 8; +} + function TerminalSnapshotTranscript({ rows }: { rows: TerminalSnapshotRow[] }) { const renderedRows = useMemo(() => withStableDuplicateKeys(rows, (row) => [ row.text, @@ -789,6 +820,7 @@ function ClosedCliSessionSurface({ const useSnapshotPreview = snapshotRows.length > 0 && ( preview?.session?.status === "running" || !preview?.transcript + || snapshotLooksLikeTui(snapshotRows) ); const transcriptText = stripTerminalControls(preview?.transcript ?? "").trimEnd() || session.lastOutputPreview diff --git a/apps/desktop/src/renderer/components/terminals/cliLaunch.test.ts b/apps/desktop/src/renderer/components/terminals/cliLaunch.test.ts index e06ab5fd1..ee3455824 100644 --- a/apps/desktop/src/renderer/components/terminals/cliLaunch.test.ts +++ b/apps/desktop/src/renderer/components/terminals/cliLaunch.test.ts @@ -22,12 +22,12 @@ describe("withCodexNoAltScreen", () => { }); it("adds --no-alt-screen to 'codex' with arguments", () => { - expect(withCodexNoAltScreen("codex --full-auto")).toBe("codex --no-alt-screen --full-auto"); + expect(withCodexNoAltScreen("codex --sandbox workspace-write")).toBe("codex --no-alt-screen --sandbox workspace-write"); }); it("does not add flag if already present", () => { expect(withCodexNoAltScreen("codex --no-alt-screen")).toBe("codex --no-alt-screen"); - expect(withCodexNoAltScreen("codex --no-alt-screen --full-auto")).toBe("codex --no-alt-screen --full-auto"); + expect(withCodexNoAltScreen("codex --no-alt-screen --sandbox workspace-write")).toBe("codex --no-alt-screen --sandbox workspace-write"); }); it("trims whitespace from input", () => { @@ -147,9 +147,10 @@ describe("buildTrackedCliStartupCommand", () => { expect(command).toContain("only normal reason to skip ADE CLI"); }); - it("adds Codex's auto preset for default", () => { + it("adds supported workspace-write defaults for default", () => { const command = buildTrackedCliStartupCommand({ provider: "codex", permissionMode: "default" }); - expect(command).toContain("codex --no-alt-screen --full-auto"); + expect(command).toContain("codex --no-alt-screen --sandbox workspace-write --ask-for-approval on-request"); + expect(command).toContain("-c mcp_servers.linear.enabled=false"); expect(command).toContain("only normal reason to skip ADE CLI"); }); @@ -157,6 +158,7 @@ describe("buildTrackedCliStartupCommand", () => { const command = buildTrackedCliStartupCommand({ provider: "codex", permissionMode: "config-toml" }); expect(command).toContain("codex --no-alt-screen"); expect(command).not.toContain("--full-auto"); + expect(command).not.toContain("mcp_servers.linear.enabled=false"); expect(command).toContain("only normal reason to skip ADE CLI"); }); diff --git a/apps/desktop/src/renderer/components/terminals/useWorkSessions.test.ts b/apps/desktop/src/renderer/components/terminals/useWorkSessions.test.ts index 4a4742719..2a5edab3b 100644 --- a/apps/desktop/src/renderer/components/terminals/useWorkSessions.test.ts +++ b/apps/desktop/src/renderer/components/terminals/useWorkSessions.test.ts @@ -169,9 +169,9 @@ describe("useWorkSessions — refresh-before-focus ordering", () => { }); // ----------------------------------------------------------------------- - // launchPtySession: refresh() must complete before focusSession / openSessionTab + // launchPtySession: focus/open immediately; refresh reconciles in background. // ----------------------------------------------------------------------- - it("launchPtySession: awaits refresh() before calling focusSession and openSessionTab", async () => { + it("launchPtySession opens the optimistic terminal before the forced refresh completes", async () => { const callOrder: string[] = []; const workState = { openItemIds: [] as string[], @@ -224,33 +224,30 @@ describe("useWorkSessions — refresh-before-focus ordering", () => { } }); - // Act: start launchPtySession - let launchPromise!: Promise; - act(() => { - launchPromise = result.current.launchPtySession({ + await act(async () => { + await result.current.launchPtySession({ laneId: "lane-1", profile: "claude", }); }); - // Give the async function a tick to reach the refresh await await act(async () => { - await new Promise((r) => setTimeout(r, 10)); + await Promise.resolve(); }); - // refresh-start should be recorded but focusSession NOT yet + expect(callOrder).toContain("focusSession"); + expect(callOrder).toContain("openSessionTab"); expect(callOrder).toContain("refresh-start"); - expect(callOrder).not.toContain("focusSession"); - expect(callOrder).not.toContain("openSessionTab"); + expect(callOrder).not.toContain("refresh-done"); // Resolve the refresh promise await act(async () => { expect(refreshResolve).not.toBeNull(); refreshResolve!(); - await launchPromise; + await Promise.resolve(); }); - // Verify ordering: refresh-done BEFORE focusSession and openSessionTab + // Verify ordering: focus/open happen before refresh completes. const refreshDoneIdx = callOrder.indexOf("refresh-done"); const focusIdx = callOrder.indexOf("focusSession"); const openTabIdx = callOrder.indexOf("openSessionTab"); @@ -258,8 +255,8 @@ describe("useWorkSessions — refresh-before-focus ordering", () => { expect(refreshDoneIdx).toBeGreaterThanOrEqual(0); expect(focusIdx).toBeGreaterThanOrEqual(0); expect(openTabIdx).toBeGreaterThanOrEqual(0); - expect(refreshDoneIdx).toBeLessThan(focusIdx); - expect(refreshDoneIdx).toBeLessThan(openTabIdx); + expect(focusIdx).toBeLessThan(refreshDoneIdx); + expect(openTabIdx).toBeLessThan(refreshDoneIdx); }); it("lightly refreshes lanes when Work sessions load before lane state recovers", async () => { diff --git a/apps/desktop/src/renderer/components/terminals/useWorkSessions.ts b/apps/desktop/src/renderer/components/terminals/useWorkSessions.ts index d95d5c88e..4bbaa3a2c 100644 --- a/apps/desktop/src/renderer/components/terminals/useWorkSessions.ts +++ b/apps/desktop/src/renderer/components/terminals/useWorkSessions.ts @@ -1271,16 +1271,13 @@ export function useWorkSessions({ active = true }: UseWorkSessionsOptions = {}) // Invalidate all cache entries so other views (e.g. Lanes tab) pick up // the new session on their next refresh. invalidateSessionListCache(); - // Refresh the session list before activating the tab so the new - // session is in sessionsById when the UI resolves activeSession. - try { - await refresh({ force: true }); - } catch { - // Best-effort: if refresh fails the session was still created, - // so proceed to focus/open it. - } focusSession(result.sessionId); openSessionTab(result.sessionId); + // Reconcile with persisted backend state in the background. The + // optimistic row already has the returned pty/session ids, so opening it + // immediately lets TerminalView subscribe before fast TUIs draw their + // initial frame. + void refresh({ showLoading: false, force: true }).catch(() => {}); return result; }, [focusSession, lanes, openSessionTab, refresh, selectLane], diff --git a/apps/desktop/src/shared/cliLaunch.ts b/apps/desktop/src/shared/cliLaunch.ts index 4777ca01e..b346c6dc7 100644 --- a/apps/desktop/src/shared/cliLaunch.ts +++ b/apps/desktop/src/shared/cliLaunch.ts @@ -25,6 +25,7 @@ export function sanitizeTrackedCliResumeTargetId(value: string | null | undefine if (!target) return null; if (/[\x00-\x1F\x7F]/.test(target)) return null; if (target.startsWith("-")) return null; + if (!/^[A-Za-z0-9][A-Za-z0-9_.:@%+=,/-]*$/.test(target)) return null; return target; } @@ -57,6 +58,11 @@ const LAUNCH_PROFILE_TOOL_TYPES: Record` | Write a durable ADE memory entry. | @@ -128,6 +134,7 @@ Right pane (open the contextual drawer): | `/output-style [style]` | List or select the active Claude output style (Claude only). | | `/plugin [reload\|native args]` | List, reload, or manage Claude plugins (Claude only). | | `/agents` | List Claude agents from user/project config (Claude only). | +| `/info` | Open the Chat Info pane for the active chat (plan, Codex goal, subagents). | | `/skills` | List Claude skills from user/project config (Claude only). | | `/context` | Show Claude context usage breakdown (Claude only). | | `/init` | Generate AGENTS.md and Claude pointer files (Claude only). | @@ -156,7 +163,7 @@ Inline chat commands (run through the active Claude SDK session, Claude only): | `/usage` | Show Claude usage / rate-limit window through the active SDK session. | | `/insights` | Generate Claude session insights through the active SDK session. | | `/fast [on\|off]` | Toggle Claude fast mode through the active SDK session. | -| `/goal [condition\|clear]` | Set or clear the Claude completion goal. | +| `/goal [\|clear\|pause\|resume]` | Set, pause, resume, or clear the chat goal. Token-budget management is intentionally not exposed — when a Codex thread reports `budget_limited`, ADE auto-clears the runtime budget and the goal banner stays in the active state. | Claude-only commands only appear in the slash palette when the active chat's provider is `claude`. The palette filters built-in entries by their `providers` whitelist so a Codex / OpenCode / Cursor chat does not show parity affordances that have no backing call. @@ -207,7 +214,7 @@ After local changes, run `npm run build` inside `apps/ade-cli` so both `dist/cli - **Vim namespace.** When vim mode is active, the model-status row exposes the current `insert`/`normal` mode tag and the keybindings dispatcher routes `vim.*` actions. - **Clipboard image paste.** Cross-platform clipboard-image paste is wired into the composer (Linux via `xclip`/`wl-paste`, macOS via `pngpaste`/AppleScript, Windows via PowerShell), so pasting a screenshot uploads it as a Claude attachment alongside text. - **`auto` permission mode.** The Claude permission picker accepts `auto` (mapped onto the SDK `permissionMode: "auto"`) in addition to `default`, `plan`, `acceptEdits`, and `bypassPermissions`. -- **Subagent panel.** The right pane's subagent surface is re-keyed on `agentId` + `parentToolUseId` and split across two tabs — Subagents and Teammates. Background runs render inline within the Subagents tab via a per-row `background` flag rather than a separate tab. Snapshots are reconstructed live from `subagent_*` envelopes (and `teammate.idle` / `task.completed` for teammates) via `subagentSnapshotsFromEvents()`. Each snapshot carries `parentToolUseId`, `turnId`, `startedAt`, `endedAt`, and a derived `durationMs` so rows can show elapsed time even when the runtime did not report `usage.durationMs`. The footer exposes an explicit Agents pane toggle (via `pane:agents` keybinding) when at least one teammate/subagent row exists; absent that, the count surfaces as an inline chip. +- **Chat Info (subagent panel).** The right pane's Chat Info view replaces the legacy Subagents tab strip. It puts the main agent in row 0 and the live subagent / teammate / background roster in rows 1..N, all selectable with `↑`/`↓`; `↵` inspects a subagent by replaying its events into the main transcript via `buildSubagentTranscriptEvents`. Snapshots are still keyed by `agentId + parentToolUseId` and reconstructed from `subagent_*` envelopes (plus `teammate.idle` / `task.completed` for teammates) through `subagentSnapshotsFromEvents()`. Sibling subagents that share a parent tool-use id are tracked separately by counting resolved subagent ids per parent and only adopting the placeholder parent row when exactly one resolves under it. Each snapshot carries `parentToolUseId`, `turnId`, `startedAt`, `endedAt`, and a derived `durationMs` so rows show elapsed time even when the runtime did not report `usage.durationMs`. The `^a` footer toggle opens or closes the Chat Info pane. - **Context, output styles, plugins.** `/context`, `/output-style`, and `/plugin` call `chat.getContextUsage`, `chat.listClaudeOutputStyles` / `chat.setClaudeOutputStyle`, and `chat.listClaudePlugins` / `chat.reloadClaudePlugins` against the same Claude SDK runtime the desktop chat uses. ## Chat setup diff --git a/docs/features/chat/composer-and-ui.md b/docs/features/chat/composer-and-ui.md index d2f90084e..1d7bb39d8 100644 --- a/docs/features/chat/composer-and-ui.md +++ b/docs/features/chat/composer-and-ui.md @@ -336,6 +336,23 @@ Interrupt transitions all running subagents to `stopped` by emitting a `subagent_result` with `status: "stopped"` for each, matching the Claude Code CLI behavior. +Codex parallel agent failures emit a system-notice plus `failed` / +`stopped` `subagent_result` rows. The agentChatService maps +`failed | errored | rejected | refused | denied` Codex status values +to `failed` and `stopped | interrupted | shutdown | notfound | +cancelled | canceled` to `stopped`; `readCodexCollabFailureSummary` +pulls the human-readable rejection out of `item.error` / +`item.result` / `item.contentItems[*].text` so the chat surface shows +a useful reason instead of a bare "Agent spawn failed". + +`deriveChatSubagentSnapshots` (in `chatExecutionSummary.ts`) keeps +sibling Codex subagents distinct when they share a `parentToolUseId`: +it pre-scans every envelope to count the resolved subagent ids per +parent, only adopts the placeholder parent row when exactly one +sibling resolves under that parent, and otherwise creates separate +snapshots keyed by `agentId ?? taskId`. The TUI mirrors this in +`subagentSnapshotsFromEvents`. + ## Terminal drawer `ChatTerminalDrawer` is a collapsible drawer at the bottom of the chat diff --git a/docs/features/terminals-and-sessions/pty-and-processes.md b/docs/features/terminals-and-sessions/pty-and-processes.md index 67aa53396..e641723da 100644 --- a/docs/features/terminals-and-sessions/pty-and-processes.md +++ b/docs/features/terminals-and-sessions/pty-and-processes.md @@ -194,6 +194,17 @@ Each live PTY has an entry in the `ptys` map keyed by `ptyId` with: shell executes the CLI. Direct launches that succeeded skip this — they already received argv. Returns `{ ptyId, sessionId, pid }`. +The launch env is built layer by layer: `process.env`, the lane +runtime env (from `getLaneRuntimeEnv`), the caller's `args.env`, then +`withAdeTerminalContextEnv` (project / lane / chat ids), then +`withInteractiveTerminalColorEnv`. The color helper sets a sensible +`TERM` (`xterm-256color`) and `COLORTERM` (`truecolor`) when missing +and unsets `NO_COLOR` so TUIs render in color by default. If the +caller or the lane env explicitly set `NO_COLOR`, the helper is called +with `preserveNoColor: true` and leaves it alone. Without this, a +user-global `NO_COLOR=1` would silently break Claude / Codex / +OpenCode rendering inside Work tabs. + ### Data, preview, and runtime state `writeTranscript(entry, data)` writes to the append-mode write stream. diff --git a/docs/features/terminals-and-sessions/ui-surfaces.md b/docs/features/terminals-and-sessions/ui-surfaces.md index bef46e714..9817194be 100644 --- a/docs/features/terminals-and-sessions/ui-surfaces.md +++ b/docs/features/terminals-and-sessions/ui-surfaces.md @@ -185,6 +185,15 @@ Grid mode keeps running PTY sessions mounted so multiple terminals can stay live at once; `isActive` only controls focus/input, not whether the terminal renderer exists. +For tracked agent CLI sessions that have already exited, `WorkViewArea` +renders `ClosedCliSessionSurface` instead of `TerminalView`. The surface +fetches `ade.terminal.preview` and decides between a serialized snapshot +preview and the plain transcript text via `snapshotLooksLikeTui(rows)`: +when the snapshot contains TUI frame characters (`╭`, `─`, etc.) or +enough styled cells to be obviously a TUI redraw, the snapshot wins so +the user sees the Claude/Codex final screen instead of a flattened +transcript with the alt-screen escape codes visible. + Constants: - `CHAT_TILE_MIN_WIDTH = 440`, `CHAT_TILE_MIN_HEIGHT = 340` @@ -400,6 +409,32 @@ Key behaviors: reliable signal that "the surface is back on screen at its new size" since hidden surfaces no longer fire layout/resize events; without it, terminals come back blank after a tab swap. +- **Hydration backfill** — initial hydration prefers + `ade.terminal.preview` (serialized snapshot of the visible rows + rebuilt as SGR-bracketed ANSI through `serializeSnapshotVisibleRows`, + falling back to the snapshot's `serialized` scrollback) and only uses + the transcript tail when no snapshot is available. The runtime + tracks `hasAppliedTerminalContent` and + `displayedLiveDataBeforeHydration`; if hydration returns nothing + renderable while live PTY data is already on screen, + `scheduleHydrationBackfill` retries the preview every ~100 ms (up to + 120 attempts) until the DOM reports renderable text. This is the fix + that keeps the Work-tab terminal from showing a blank black pane with + an orange cursor after sending a first prompt to a fast TUI like + Codex or Claude. The backfill also re-arms whenever the tile becomes + visible but the xterm rows are empty (e.g. after a webgl→dom + fallback). +- **Mouse tracking forwarding** — `TerminalView` tracks DECSET 1000 / + 1002 / 1003 (SGR mouse modes) by scanning every PTY data chunk via + `updateTerminalMouseTrackingModes`. When the embedded TUI has mouse + tracking enabled, the renderer installs a Shift-mouse bridge + (`installShiftMouseBridge`) that forwards Shift-click + drag as SGR + mouse press/move/release sequences so users can drive in-app mouse UIs + through the surrounding xterm. +- **Cmd+C → SIGINT on macOS** — when the terminal is focused on macOS, + ⌘C with no current selection sends `\x03` to the PTY (matches the + Terminal.app behaviour TUI users expect). Selection-aware copy is + handled by xterm's own selection plus the runtime's clipboard hook. Font stack defaults: `ui-monospace`, `SFMono-Regular`, `Menlo`, `Monaco`, `Cascadia Mono`, `JetBrains Mono`, `Geist Mono`, `monospace`. @@ -452,9 +487,18 @@ Launch commands are built by `apps/desktop/src/shared/cliLaunch.ts`: choices map onto provider-native flags / configs: - **Claude** → `--permission-mode` flag (CLI default plus plan/acceptEdits/bypassPermissions). - - **Codex** → `--ask-for-approval` + `--sandbox` pair (or - `--full-auto`/`--dangerously-bypass-approvals-and-sandbox` for the - presets), plus `config-toml` mode that defers to `.codex/config.toml`. + - **Codex** → `--ask-for-approval` + `--sandbox` pair. `default` + maps to `--sandbox workspace-write --ask-for-approval on-request` + (Codex's documented Guarded Edit semantics; the older `--full-auto` + alias caused the TUI to drop straight into auto-approval and was + surprising in the Work tab). `full-auto` keeps the explicit + `--dangerously-bypass-approvals-and-sandbox` flag, and `config-toml` + mode defers to `.codex/config.toml`. Fresh Codex launches also + pass `-c mcp_servers.linear.enabled=false` (see + `ADE_CODEX_STARTUP_CONFIG_FLAGS` in `cliLaunch.ts`) to keep a stale + user-level Linear MCP OAuth handshake from blocking the TUI's + first paint — ADE has its own Linear surfaces, so the MCP wiring + is intentionally suppressed for the in-app launch. - **Cursor** → `--mode plan|ask` for read-only modes and `--force` for full-auto. Sessions pre-allocate a chat id with `cursor-agent create-chat` so `--resume ` is always known. @@ -516,7 +560,17 @@ The hook exposes `openSessionTab`, `focusSession`, `selectLane`, `upsertOptimisticChatSession` (so new chats appear in the tab strip before the IPC round-trip completes), `refresh`, and the right-sidebar setters `setWorkSidebarOpen`, `setWorkSidebarTab` (also forces the -sidebar open), and `setWorkSidebarWidthPct` (clamped 26–55%). The +sidebar open), and `setWorkSidebarWidthPct` (clamped 26–55%). + +`launchPtySession` opens the tab off the synchronous `ptyCreate` +result before kicking off the background refresh: it focuses the +session, calls `openSessionTab`, and only then fires `refresh({ +showLoading: false, force: true })`. This is what makes the Work tab's +optimistic terminal visible the moment the PTY exists, which is the +window in which the new `TerminalView` runtime needs to attach so it +can subscribe to live PTY data before fast TUIs like Codex or Claude +paint their first frame. Waiting on the refresh round-trip first used +to lose the initial paint and leave the terminal blank. The `launchPtySession({ laneId, profile, command?, args?, startupCommand?, env?, title?, tracked? })` helper (and its lane-scoped twin in `useLaneWorkSessions`) builds a default launch payload with diff --git a/goal.md b/goal.md index fe2b8edce..2f857e411 100644 --- a/goal.md +++ b/goal.md @@ -1,32 +1,60 @@ -I want to add a sidebar toggle in teh work tab, that open up a pane on the right side of whatever chat or cli sessoins you have in view in the ade work tab. Ok so right now there are a coupl eof things that all independentlay open up to the - right, there is a proof drawer, which is currently tied per chat, there is an app control drawer and an ios drawer also per chat, then separetly theres a whole panel, that if you tkae your mouse all the way to the right side of the screen, has a popup - arrow appear which pulls out the floating pane with a bunch of options. Now heres the reality of this. Things have changed, the onlt thing that really ahs any need to be tied to a chat is the proof drawer. I dont see any reason that the app contorl - and the ios drawer cannot be per lane, i mean they run an app from a certain pwd, which for all chats within the same lane, theyre all he same. Now with the floating pane, we have git, files, stack, diff, and work. Now over here, i wanna remove stack - and work, and diff. Heres the new directoin i wanna take. A gobal sidebar toggle button or somehtign indicative of a sidebar on the right poppin out. In the drawer, im expecting a sliding out drawer that sompeltely splits the screen. Right now, the - app cotnrol and the ios sim drawer are th eonly ones that have the fully splut ui right, so look at that. Anyways, in this new drawer i wanna show first the git view, which should just be the git actions pane in the lanes tab (which also currenlty - shows int eh pop out pane), but with the lane of the chat that the drawer is open from. So state of the current chat is what populates the items in this new pane. So the tabs in order are gonna be the git tab, files tab, ios sim drawer, and the app - contorl drawer. Now all of these are tied to the current lane of the open chat or cli session or normal chat. - - Now i want to make sure that in the work tab, this new slide out drawer on the right is global in the sense that if u have it open, no matter what session or what u choose it stays open, but as u navigate sessions omethigns will chage. For exmaple, if - u select a session in a diff lane then info ahs to populate to that new lane. For git and files, this is easy just display info for that lane. For the ios and app control tabs we have to keep it open, but show a clear wanring that they waere laucnehd - from a different xyz lane, not this one, and so the user cannot attach context and scrrenshots to chat. Also we have to show the cannot attach context to chat message hwen looking at a cli session or shell session even if in the same lane. Rmemeber - that for both fo these views ade has a system whe reu can highlight different elements and cpmponents and a screenshot with context pack gets added, but only works for ade chats. - - Now for the new chat pane, the lside out drawer shoulst still wok, but of course be popualted based on the lane selected in teh lane picker in teh new chat view. So like there is no case int eh work tab where a clear lane the user is "working in" is - not in veiw. In the new chat view, autoamtaiclly primary is selected as the lane, so we show primary, but if the user goes to the dorpwdown and choose a diff one then of course we change the info in the pane. The only view which if confusing, is the - grid view, as many chats from different lanes can be open, if the user goes to grid view we auto close and hide the slide out drawer, but as soon as they leave, we reopen/reshow. - - So to recap this is a "global" view that keeps state as in the open tab and he views as long as naviagtinv sessoins in the same lane, but the info repopulates for different lanes, but we keep the same tab open int eh slide our drawer. - - I wnat to think of this as its own system, not really tied to the cahts specificalkly but to the work tab as a whole, and diff lanes can be shown as info in there. - - Also, for the files tab, the one in the current pop oput floaitng pane sucks, its really old, in the new view please make sure the files tab looks like the real files tab in teh ade sidebar. - This is a huge architecture change, so please carefully make edits, and clean up all legcay and dead code as to not leave behind old code not needed. So once again we need to comepltely get rid of and delete the current floating pop up pane stuff, all - of ti, its getting refactored into this new view. - - Also leave the roof drawer alone, since that truly is per chat. - - For all this work, consider .claude/commands/optimize.md, i dont want yout o direclty run this, becuase this sessoins is currenlty running in the ade desktop app via npm run dev, and if u start a new ade session, this one will crash meaning this chat - ends. U cannot spin up a new ade instance, since we are running in ade right. U have to take into condersation the things in this slash commadn though, and try to maek this enw feature as perofmative as possivle. - - Use parallel agents as needed \ No newline at end of file +You are working in /Users/arul/ADE/.ade/worktrees/fixing-cli-send-error-099dff5b only. Do not switch repos or lanes. + +Goal: +Fix the ADE Work tab bug where sending a prompt to a CLI session shows a blank black terminal with an orange cursor or falls back to a closed-session resume pane instead of a live Claude/Codex session. Then verify it with Computer Use on the live ADE desktop app. + +Important context: +- The prior “success” was false. The UI was showing a closed session resume pane, not a live CLI session. +- The user wants the actual live terminal to appear after sending a prompt from the Work tab. +- This must work for both Claude and Codex providers. +- Verification must be visual with Computer Use, not just logs. +- Keep the scope to the Work-tab CLI launch/render path. + +What I already learned: +- `useWorkSessions.ts` already opens the optimistic terminal immediately after creating a PTY session. +- `TerminalView.tsx` hydrates missed startup output from `window.ade.sessions.readTranscriptTail(...)`. +- The local-runtime path previously looked suspicious because transcript hydration could miss live PTY output, but the remaining problem now appears to be the actual CLI launch command path, not just rendering. +- The likely place to inspect next is the CLI launch builder in: + - `apps/desktop/src/main/services/orchestrator/orchestratorService.ts` + - `apps/desktop/src/main/services/ai/providerTaskRunner.ts` + - anything that generates the Codex/Claude startup command or resume command +- There are existing files already modified in the worktree from earlier attempts: + - `apps/desktop/src/main/services/adeActions/registry.ts` + - `apps/desktop/src/main/services/adeActions/registry.test.ts` + - `apps/desktop/src/renderer/components/terminals/useWorkSessions.ts` + - `apps/desktop/src/renderer/components/terminals/useWorkSessions.test.ts` + +Likely bug shape: +- A bad launch arg / command string is causing the CLI to exit immediately or launch into a resume flow. +- The UI then shows the closed session/resume pane instead of a live terminal. +- There may be a provider-specific mismatch between Claude and Codex startup/resume commands. + +What to do: +1. Inspect the actual command generation for Work-tab CLI launches for Claude and Codex. +2. Trace how the session is created, how startup commands are assembled, and how the session gets marked as live vs resumable. +3. Fix the underlying launch command issue, not just the renderer. +4. Keep changes narrow and preserve the existing Work-tab optimistic open behavior unless it is directly part of the bug. +5. Add or update tests around the real failure mode. +6. Verify with focused tests first. +7. Run the desktop dev app and use Computer Use to send a fresh Work-tab prompt like “test message” for both CLI providers if feasible. +8. Confirm visually that the app shows a live terminal session, not the closed resume pane. + +Validation expectations: +- Run the smallest relevant tests first. +- Then run whatever broader checks are necessary for the touched code path. +- Use Computer Use for the final proof of the live UI state. +- If you capture proof, make sure it is visual, not just textual. + +Useful files to inspect: +- `apps/desktop/src/main/services/orchestrator/orchestratorService.ts` +- `apps/desktop/src/main/services/ai/providerTaskRunner.ts` +- `apps/desktop/src/main/services/sessions/sessionService.ts` +- `apps/desktop/src/main/services/ipc/registerIpc.ts` +- `apps/desktop/src/renderer/components/terminals/useWorkSessions.ts` +- `apps/desktop/src/renderer/components/terminals/TerminalView.tsx` +- `apps/desktop/src/renderer/components/terminals/WorkCliSessionHeader.tsx` + +Known recent state: +- There was already a false-positive verification run. +- The user is frustrated and wants the actual fix plus proof. +- Stay direct and keep the work scoped to the live CLI launch issue. diff --git a/scripts/dev-shared.mjs b/scripts/dev-shared.mjs index 597820859..811e5ff40 100644 --- a/scripts/dev-shared.mjs +++ b/scripts/dev-shared.mjs @@ -47,6 +47,63 @@ export function cliPath() { return path.join(repoRoot, "apps", "ade-cli", "dist", "cli.cjs"); } +function newestMtimeMs(rootPath) { + let newest = 0; + const stack = [rootPath]; + while (stack.length) { + const current = stack.pop(); + if (!current) continue; + let stat; + try { + stat = fs.statSync(current); + } catch { + continue; + } + if (stat.isDirectory()) { + const name = path.basename(current); + if (name === "node_modules" || name === "dist" || name === ".git") continue; + for (const child of fs.readdirSync(current)) { + stack.push(path.join(current, child)); + } + continue; + } + if (stat.isFile()) newest = Math.max(newest, stat.mtimeMs); + } + return newest; +} + +function oldestMtimeMs(paths) { + let oldest = Number.POSITIVE_INFINITY; + for (const candidate of paths) { + try { + oldest = Math.min(oldest, fs.statSync(candidate).mtimeMs); + } catch { + return 0; + } + } + return Number.isFinite(oldest) ? oldest : 0; +} + +export function isRuntimeCliBuildFresh() { + const packageRoot = path.join(repoRoot, "apps", "ade-cli"); + const distMtime = oldestMtimeMs([ + cliPath(), + path.join(packageRoot, "dist", "tuiClient", "cli.mjs"), + ]); + if (distMtime <= 0) return false; + const sourceMtime = Math.max( + newestMtimeMs(path.join(packageRoot, "src")), + newestMtimeMs(path.join(packageRoot, "scripts")), + ...[ + "package.json", + "package-lock.json", + "tsconfig.json", + "tsup.config.ts", + ].map((file) => newestMtimeMs(path.join(packageRoot, file))), + ); + return distMtime >= sourceMtime; +} + export function resolveDevAppVersion() { const override = process.env.ADE_CLI_VERSION?.trim(); if (override) return override; @@ -107,8 +164,8 @@ export async function buildRuntimeCli(skipRuntimeBuild = false) { export async function buildRuntimeCliForDevClient(skipRuntimeBuild, socketPath) { if (skipRuntimeBuild) return; - if (fs.existsSync(cliPath()) && await canConnectToSocket(socketPath)) { - process.stdout.write("[ade] dev runtime is already listening; skipping runtime CLI rebuild\n"); + if (isRuntimeCliBuildFresh() && await canConnectToSocket(socketPath)) { + process.stdout.write("[ade] dev runtime is already listening and CLI build is fresh; skipping runtime CLI rebuild\n"); return; } await buildRuntimeCli(false);