diff --git a/.gitignore b/.gitignore index ecd5b29ef..cca784ccc 100644 --- a/.gitignore +++ b/.gitignore @@ -69,3 +69,5 @@ package-lock.json /apps/desktop/release-alpha /apps/desktop/release-beta apps/desktop/resources/runtime/ade-* + +.claude/scheduled_tasks.lock diff --git a/apps/ade-cli/src/tuiClient/__tests__/HeaderFooter.test.tsx b/apps/ade-cli/src/tuiClient/__tests__/HeaderFooter.test.tsx index add08bf69..82936e368 100644 --- a/apps/ade-cli/src/tuiClient/__tests__/HeaderFooter.test.tsx +++ b/apps/ade-cli/src/tuiClient/__tests__/HeaderFooter.test.tsx @@ -84,6 +84,7 @@ describe("FooterControls", () => { provider="codex" modelDisplay="GPT-5.5" permissionLabel="full-auto" + permissionDetail="never · danger-full-access" fastMode />, ); @@ -92,6 +93,7 @@ describe("FooterControls", () => { expect(frame).toContain("GPT-5.5"); expect(frame).toContain("fast"); expect(frame).toContain("full-auto"); + expect(frame).toContain("full-auto ·"); }); it("renders the resting hint strip with lanes/pane/chat-info/cmds/help", () => { diff --git a/apps/ade-cli/src/tuiClient/__tests__/Palettes.test.tsx b/apps/ade-cli/src/tuiClient/__tests__/Palettes.test.tsx index 42c0a5de4..9c178d89d 100644 --- a/apps/ade-cli/src/tuiClient/__tests__/Palettes.test.tsx +++ b/apps/ade-cli/src/tuiClient/__tests__/Palettes.test.tsx @@ -119,7 +119,9 @@ describe("SlashPalette", () => { ).lastFrame() ?? ""); const lines = frame.split("\n").filter(Boolean); - expect(lines).toHaveLength(8); + // 6 command rows (MIN_VISIBLE_ROWS, no maxRows budget passed) + header + + // selected-summary + footer. + expect(lines).toHaveLength(9); expect(lines.every((line) => /^[┌│└]/.test(line) && /[┐│┘]$/.test(line))).toBe(true); expect(frame).toContain("/feedback"); expect(frame).toContain("Submit ADE feedback to GitHub issues"); diff --git a/apps/ade-cli/src/tuiClient/__tests__/RightPane.test.tsx b/apps/ade-cli/src/tuiClient/__tests__/RightPane.test.tsx index f8c946823..c20c88527 100644 --- a/apps/ade-cli/src/tuiClient/__tests__/RightPane.test.tsx +++ b/apps/ade-cli/src/tuiClient/__tests__/RightPane.test.tsx @@ -1,9 +1,25 @@ import React from "react"; import { describe, expect, it } from "vitest"; import { render } from "ink-testing-library"; -import { LANE_DETAIL_ACTIONS, LANE_DETAIL_PR_ACTION_INDEX, laneDetailsInteractionLayout, RightPane } from "../components/RightPane"; +import { LANE_DETAIL_ACTIONS, LANE_DETAIL_PR_ACTION_INDEX, feedbackStateFromContent, laneDetailsInteractionLayout, rightPaneScrollableRowCount, RightPane } from "../components/RightPane"; +import { feedbackFormToFormValues } from "../feedbackForm"; +import { buildFeedbackDraftInput } from "../feedback"; import type { LaneSummary } from "../../../../desktop/src/shared/types/lanes"; +describe("rightPaneScrollableRowCount", () => { + it("counts details body lines and list rows; flows full diff bodies for scrolling", () => { + expect(rightPaneScrollableRowCount({ kind: "details", title: "t", body: "a\nb\nc" })).toBe(3); + expect(rightPaneScrollableRowCount({ kind: "list", title: "t", rows: ["a", "b"] })).toBe(2); + expect(rightPaneScrollableRowCount({ kind: "empty" })).toBe(0); + const bigBody = Array.from({ length: 50 }, (_, i) => `line ${i}`).join("\n"); + // 1 header row + all 50 body rows (diffs scroll in full, capped per file at 600). + expect(rightPaneScrollableRowCount({ kind: "diff", title: "d", files: [{ path: "a.ts", body: bigBody }] })).toBe(51); + // Per-file cap: 600 body rows shown + 1 header + 1 "more lines" row. + const huge = Array.from({ length: 650 }, (_, i) => `+line ${i}`).join("\n"); + expect(rightPaneScrollableRowCount({ kind: "diff", title: "d", files: [{ path: "a.ts", body: huge }] })).toBe(602); + }); +}); + function stripAnsi(text: string): string { return text.replace(/\u001b(?:\[[0-9;]*[ -/]*[@-~]|\][^\u0007]*(?:\u0007|\u001b\\))/g, ""); } @@ -511,19 +527,33 @@ describe("RightPane setup panes", () => { expect(frame).toContain("enter deletes this lane"); }); - it("renders model setup rows and selected row detail", () => { + it("renders the unified model picker with model list and settings footer", () => { const result = render( , @@ -531,12 +561,11 @@ describe("RightPane setup panes", () => { const frame = stripAnsi(result.lastFrame() ?? ""); expect(frame).toContain("MODEL"); - expect(frame).toContain("Provider: Claude"); - expect(frame).toContain("Model: Sonnet"); - expect(frame).toContain("Reasoning: high"); - expect(frame).toContain("low, medium, high"); - expect(frame).toContain("Permissions: auto"); - expect(frame).toContain("↑↓ rows · ←→ change · ↵ apply · esc close"); + expect(frame).toContain("Claude Sonnet 4.6"); + // Reasoning is shown in the settings footer, not as an inline per-model + // "think high" chip (that detail was removed from model rows). + expect(frame).toContain("reasoning"); + expect(frame).not.toContain("think high"); }); }); @@ -557,6 +586,121 @@ describe("RightPane details", () => { expect(frame).toContain("SKILLS"); expect(frame).toContain("line 1"); - expect(frame).toContain("… 14 more lines"); + expect(frame).toContain("↓ 14 more lines"); + }); + + it("renders context usage as a visual pane", () => { + const result = render( + , + ); + const frame = stripAnsi(result.lastFrame() ?? ""); + + expect(frame).toContain("CONTEXT"); + expect(frame).toContain("gpt-5.5"); + expect(frame).toContain("12.0k / 20.0k (60%)"); + expect(frame).toContain("messages"); + }); +}); + +describe("RightPane feedback form", () => { + // The feedback pane render is exercised end-to-end through the deterministic + // helper contract below (state rebuild -> serialize -> daemon draft). Full-frame + // string assertions are intentionally avoided: ink-testing-library leaks tall + // frames between renders in this suite, making pixel-frame matches flaky. + + it("feedbackStateFromContent rebuilds the framework-free form state", () => { + const content = { + kind: "form" as const, + title: "Feedback", + command: "feedback" as const, + fields: [], + feedback: { + type: "idea" as const, + body: "add dark mode\nplease", + showContext: false, + provider: "anthropic", + model: "opus", + lane: "L", + lastError: "boom", + }, + }; + const state = feedbackStateFromContent(content); + expect(state.type).toBe("idea"); + expect(state.text).toBe("add dark mode\nplease"); + expect(state.showContext).toBe(false); + expect(state.context).toEqual({ provider: "anthropic", model: "opus", lane: "L", lastError: "boom" }); + }); + + it("submit path: feedbackStateFromContent -> feedbackFormToFormValues -> buildFeedbackDraftInput keeps the multiline body", () => { + const content = { + kind: "form" as const, + title: "Feedback", + command: "feedback" as const, + fields: [], + feedback: { + type: "bug" as const, + body: "crash on launch\nstep one\nstep two", + showContext: true, + provider: "anthropic", + model: "opus", + lane: "my-lane", + lastError: "boom", + }, + }; + const values = feedbackFormToFormValues(feedbackStateFromContent(content)); + expect(values.category).toBe("bug"); + expect(values.summary).toBe("crash on launch"); + const draft = buildFeedbackDraftInput(values); + expect(draft.category).toBe("bug"); + expect(draft.summary).toBe("crash on launch"); + if (draft.category === "bug") { + expect(draft.stepsToReproduce).toBe("crash on launch\nstep one\nstep two"); + } + expect(draft.additionalContext).toContain("--- Context ---"); + expect(draft.additionalContext).toContain("Provider/Model: anthropic / opus"); + expect(draft.additionalContext).toContain("Lane: my-lane"); + expect(draft.additionalContext).toContain("Last error/notice: boom"); + }); + + it("omits the context footer from the serialized draft when showContext is false", () => { + const content = { + kind: "form" as const, + title: "Feedback", + command: "feedback" as const, + fields: [], + feedback: { type: "idea" as const, body: "an idea", showContext: false, lane: "L" }, + }; + const values = feedbackFormToFormValues(feedbackStateFromContent(content)); + expect(values.additionalContext).toBeUndefined(); + }); + + it("idea type maps to a feature draft", () => { + const content = { + kind: "form" as const, + title: "Feedback", + command: "feedback" as const, + fields: [], + feedback: { type: "idea" as const, body: "add dark mode\nplease", showContext: false }, + }; + const draft = buildFeedbackDraftInput(feedbackFormToFormValues(feedbackStateFromContent(content))); + expect(draft.category).toBe("feature"); + expect(draft.summary).toBe("add dark mode"); }); }); diff --git a/apps/ade-cli/src/tuiClient/__tests__/RightPane.usage.test.tsx b/apps/ade-cli/src/tuiClient/__tests__/RightPane.usage.test.tsx new file mode 100644 index 000000000..3d56dae71 --- /dev/null +++ b/apps/ade-cli/src/tuiClient/__tests__/RightPane.usage.test.tsx @@ -0,0 +1,81 @@ +import React from "react"; +import { describe, expect, it } from "vitest"; +import { render } from "ink-testing-library"; +import { RightPane } from "../components/RightPane"; +import type { RightPaneContent } from "../types"; +import { SpinTickProvider } from "../spinTick"; + +function renderPane(content: RightPaneContent, width = 48) { + return render( + + + , + ); +} + +describe("RightPane usage", () => { + it("renders a quota window with percent + reset countdown and the session block", () => { + const resetAt = Math.round(Date.now() / 1000) + 2 * 3600; // 2h out + const { lastFrame } = renderPane({ + kind: "usage", + title: "Usage", + quotaWindows: [{ id: "rate-limit", label: "Rate limit", percent: 42, resetAt }], + session: { input: 12_000, output: 3_400, cost: 0.42 }, + }); + const text = lastFrame() ?? ""; + expect(text).toContain("USAGE"); + expect(text).toContain("Rate limit"); + expect(text).toContain("42%"); + // Live reset countdown marker. + expect(text).toContain("↻"); + // Session block. + expect(text).toContain("This session"); + expect(text).toContain("12.0k"); + expect(text).toContain("3.4k"); + expect(text).toContain("$0.42"); + }); + + it("degrades to the session block when quota windows are unavailable", () => { + const { lastFrame } = renderPane({ + kind: "usage", + title: "Usage", + quotaWindows: undefined, + session: { input: 500, output: 100, cost: 0 }, + }); + const text = lastFrame() ?? ""; + expect(text).toContain("Quota windows unavailable."); + expect(text).toContain("This session"); + expect(text).toContain("$0.00"); + }); + + it("renders a loading state while the snapshot is being fetched", () => { + const { lastFrame } = renderPane({ kind: "usage", title: "Usage", loading: true }); + expect(lastFrame() ?? "").toContain("Loading usage…"); + }); + + it("surfaces an error while keeping the pane title", () => { + const { lastFrame } = renderPane({ + kind: "usage", + title: "Usage", + error: "daemon offline", + session: { input: 1, output: 2, cost: 3 }, + }); + const text = lastFrame() ?? ""; + expect(text).toContain("USAGE"); + expect(text).toContain("Usage unavailable"); + expect(text).toContain("daemon offline"); + }); + + it("renders a near-full window's percent (≥95% danger escalation) with no session", () => { + const { lastFrame } = renderPane({ + kind: "usage", + title: "Usage", + quotaWindows: [{ id: "rate-limit", label: "Rate limit", percent: 97, resetAt: null }], + session: null, + }); + const text = lastFrame() ?? ""; + expect(text).toContain("Rate limit"); + expect(text).toContain("97%"); + expect(text).toContain("No session usage yet."); + }); +}); diff --git a/apps/ade-cli/src/tuiClient/__tests__/TerminalPane.test.tsx b/apps/ade-cli/src/tuiClient/__tests__/TerminalPane.test.tsx index dee6bff06..976c05ee9 100644 --- a/apps/ade-cli/src/tuiClient/__tests__/TerminalPane.test.tsx +++ b/apps/ade-cli/src/tuiClient/__tests__/TerminalPane.test.tsx @@ -8,6 +8,15 @@ function stripAnsi(value: string): string { return value.replace(/\u001b\[[0-?]*[ -/]*[@-~]/g, ""); } +/** Poll until `check()` is truthy (xterm write callbacks are async). */ +async function waitFor(check: () => boolean, timeoutMs = 1_000): Promise { + const deadline = Date.now() + timeoutMs; + while (!check()) { + if (Date.now() > deadline) return; + await new Promise((resolve) => setTimeout(resolve, 5)); + } +} + function row(text: string): TerminalSnapshotRow { return { text, @@ -362,4 +371,127 @@ describe("TerminalPane", () => { expect(frame).not.toContain("MCP server failed"); expect(frame).not.toContain("Resume this session"); }); + + it("shows the amber '↓ N new' jump chip when scrolled up and new output is pending", () => { + const result = render( + , + ); + const frame = stripAnsi(result.lastFrame() ?? ""); + expect(frame).toContain("↓ 3 new"); + }); + + it("shows a muted scrollback hint (no chip) when scrolled up with no new output", () => { + const result = render( + , + ); + const frame = stripAnsi(result.lastFrame() ?? ""); + expect(frame).not.toContain("new"); + expect(frame).toContain("scrollback"); + }); + + it("hides the new-output chip when attached (Claude owns the terminal, always follows bottom)", () => { + const result = render( + , + ); + const frame = stripAnsi(result.lastFrame() ?? ""); + expect(frame).not.toContain("9 new"); + expect(frame).toContain("CLAUDE CONTROL"); + }); + + it("reports viewport metrics (max scrollable rows + visible text) for keyboard scroll + copy", async () => { + const metrics: { maxScrollable: number; visibleText: string }[] = []; + render( + metrics.push(m)} + />, + ); + // xterm's write() callback is async; poll until the visible text settles. + await waitFor(() => metrics.some((m) => m.visibleText.includes("line three"))); + const last = metrics[metrics.length - 1]; + expect(last).toBeDefined(); + expect(typeof last?.maxScrollable).toBe("number"); + expect(metrics.some((m) => m.visibleText.includes("line one"))).toBe(true); + expect(metrics.some((m) => m.visibleText.includes("line three"))).toBe(true); + }); + + it("advances the write cursor past 500 chunks (desync regression) without dropping later output", async () => { + // Feed > 500 incremental chunks; every chunk must land in the xterm buffer. + // The old slice(-500) on every chunk pinned the buffer and froze the write + // cursor at 500, so later rows were silently dropped. + // + // We assert the REAL invariant — that all 540 rows were written to the + // terminal buffer/scrollback — rather than that row539 happens to sit in the + // ~6-row viewport at the instant we sample lastFrame() (that capture is + // viewport-bound and timing-dependent, which flaked in CI). The component + // reports `maxScrollable` = the buffer's scrollback extent (viewportY), which + // is a deterministic, monotonic measure of how far the write cursor advanced. + const CHUNK_COUNT = 540; + const HEIGHT = 6; + const chunks = Array.from({ length: CHUNK_COUNT }, (_unused, index) => `row${index}\r\n`); + // Each "rowN\r\n" writes one line; with HEIGHT visible rows the scrollback + // extent (maxScrollable) settles at (linesWritten - HEIGHT). If the >500 + // desync regression returns, the cursor stalls near 500 and maxScrollable + // can never reach this threshold, so the test still fails on regression. + const EXPECTED_MIN_SCROLLBACK = CHUNK_COUNT - HEIGHT; // 534 + + const metrics: { maxScrollable: number; visibleText: string }[] = []; + render( + metrics.push(m)} + />, + ); + + // xterm's write() callback is async; poll until the scrollback extent reports + // the full 540 rows landed. Generous timeout so a slow CI render still settles. + await waitFor( + () => metrics.some((m) => m.maxScrollable >= EXPECTED_MIN_SCROLLBACK), + 5_000, + ); + const maxReported = Math.max(0, ...metrics.map((m) => m.maxScrollable)); + expect(maxReported).toBeGreaterThanOrEqual(EXPECTED_MIN_SCROLLBACK); + }); }); diff --git a/apps/ade-cli/src/tuiClient/__tests__/TerminalScrollState.test.ts b/apps/ade-cli/src/tuiClient/__tests__/TerminalScrollState.test.ts new file mode 100644 index 000000000..10b02f9a0 --- /dev/null +++ b/apps/ade-cli/src/tuiClient/__tests__/TerminalScrollState.test.ts @@ -0,0 +1,113 @@ +import { describe, expect, it } from "vitest"; +import { + TERMINAL_SCROLL_AT_BOTTOM, + clampTerminalScrollOffset, + jumpTerminalToBottom, + noteTerminalNewRows, + readTerminalScroll, + scrollTerminalBy, + terminalPageStep, + type TerminalScrollBySessionId, +} from "../components/TerminalScrollState"; + +describe("readTerminalScroll", () => { + it("defaults to pinned-at-bottom for unknown / missing session", () => { + const map: TerminalScrollBySessionId = {}; + expect(readTerminalScroll(map, "missing")).toEqual(TERMINAL_SCROLL_AT_BOTTOM); + expect(readTerminalScroll(map, null)).toEqual(TERMINAL_SCROLL_AT_BOTTOM); + expect(readTerminalScroll(map, undefined)).toEqual(TERMINAL_SCROLL_AT_BOTTOM); + }); + + it("returns the stored state when present", () => { + const state = { scrollOffset: 7, pendingNewCount: 3 }; + expect(readTerminalScroll({ s1: state }, "s1")).toBe(state); + }); +}); + +describe("clampTerminalScrollOffset", () => { + it("clamps to [0, maxScrollable]", () => { + expect(clampTerminalScrollOffset(-5, 100)).toBe(0); + expect(clampTerminalScrollOffset(50, 100)).toBe(50); + expect(clampTerminalScrollOffset(500, 100)).toBe(100); + }); + + it("floors fractional offsets and tolerates non-finite input", () => { + expect(clampTerminalScrollOffset(3.9, 100)).toBe(3); + expect(clampTerminalScrollOffset(Number.NaN, 100)).toBe(0); + }); + + it("allows scrolling well past 500 rows (desync regression: cursor must keep advancing)", () => { + // The old slice(-500) pinned the buffer; scrollback must reach the full + // retained window (e.g. 2000 rows), not be capped near 500. + expect(clampTerminalScrollOffset(1_900, 2_000)).toBe(1_900); + expect(clampTerminalScrollOffset(2_000, 2_000)).toBe(2_000); + }); +}); + +describe("scrollTerminalBy", () => { + const max = 100; + + it("scrolls up (positive delta) toward older output, clamped", () => { + const a = scrollTerminalBy(TERMINAL_SCROLL_AT_BOTTOM, 10, max); + expect(a.scrollOffset).toBe(10); + const b = scrollTerminalBy(a, 200, max); + expect(b.scrollOffset).toBe(100); + }); + + it("scrolls down (negative delta) toward newest, clearing pending at the bottom", () => { + const up = { scrollOffset: 20, pendingNewCount: 5 }; + const mid = scrollTerminalBy(up, -5, max); + expect(mid).toEqual({ scrollOffset: 15, pendingNewCount: 5 }); + const bottom = scrollTerminalBy(mid, -100, max); + expect(bottom).toEqual({ scrollOffset: 0, pendingNewCount: 0 }); + }); + + it("returns the same reference when already pinned at the bottom and going further down", () => { + expect(scrollTerminalBy(TERMINAL_SCROLL_AT_BOTTOM, -10, max)).toBe(TERMINAL_SCROLL_AT_BOTTOM); + }); +}); + +describe("jumpTerminalToBottom", () => { + it("resets offset and pending counter", () => { + expect(jumpTerminalToBottom({ scrollOffset: 40, pendingNewCount: 9 })).toEqual( + TERMINAL_SCROLL_AT_BOTTOM, + ); + }); + + it("is a no-op (same ref) when already at the bottom", () => { + expect(jumpTerminalToBottom(TERMINAL_SCROLL_AT_BOTTOM)).toBe(TERMINAL_SCROLL_AT_BOTTOM); + }); +}); + +describe("noteTerminalNewRows", () => { + it("accumulates the counter AND advances scrollOffset to anchor content while scrolled up", () => { + // viewportY grows by 3, so scrollOffset must grow by 3 to keep + // viewportY - scrollOffset constant (content stays pinned, no drift). + const scrolled = { scrollOffset: 12, pendingNewCount: 2 }; + expect(noteTerminalNewRows(scrolled, 3)).toEqual({ scrollOffset: 15, pendingNewCount: 5 }); + }); + + it("clamps the advanced scrollOffset to maxScrollable", () => { + expect( + noteTerminalNewRows({ scrollOffset: 98, pendingNewCount: 0 }, 5, 100), + ).toEqual({ scrollOffset: 100, pendingNewCount: 5 }); + }); + + it("does not count new output while pinned to the bottom (no chip)", () => { + expect(noteTerminalNewRows(TERMINAL_SCROLL_AT_BOTTOM, 10)).toBe(TERMINAL_SCROLL_AT_BOTTOM); + }); + + it("ignores zero / negative arrivals", () => { + const scrolled = { scrollOffset: 4, pendingNewCount: 1 }; + expect(noteTerminalNewRows(scrolled, 0)).toBe(scrolled); + expect(noteTerminalNewRows(scrolled, -3)).toBe(scrolled); + }); +}); + +describe("terminalPageStep", () => { + it("pages by half the visible window, at least one row", () => { + expect(terminalPageStep(20)).toBe(10); + expect(terminalPageStep(1)).toBe(1); + expect(terminalPageStep(0)).toBe(1); + }); +}); diff --git a/apps/ade-cli/src/tuiClient/__tests__/adeApi.test.ts b/apps/ade-cli/src/tuiClient/__tests__/adeApi.test.ts index 54abfc45c..08d8b67f9 100644 --- a/apps/ade-cli/src/tuiClient/__tests__/adeApi.test.ts +++ b/apps/ade-cli/src/tuiClient/__tests__/adeApi.test.ts @@ -3,7 +3,7 @@ import os from "node:os"; import path from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; import type { AgentChatEventEnvelope } from "../../../../desktop/src/shared/types/chat"; -import { cancelSteerMessage, createChatSession, DEFAULT_CODEX_REASONING_EFFORT, dispatchSteerMessage, discoverProjectSlashCommands, editSteerMessage, getAvailableModels, latestGoal, latestTokenStats, listLaneDiffStats, listPrsByLane, listTerminalSessions, resumeTerminalSession, sendChatMessage, signalTerminal, startClaudeTerminalSession, steerChatMessage } from "../adeApi"; +import { archiveChatSession, cancelSteerMessage, createChatSession, DEFAULT_CODEX_REASONING_EFFORT, deleteChatSession, dispatchSteerMessage, discoverProjectSlashCommands, editSteerMessage, getAvailableModels, latestGoal, latestTokenStats, listChatSessions, listLaneDiffStats, listPrsByLane, listTerminalSessions, resumeTerminalSession, sendChatMessage, signalTerminal, startClaudeTerminalSession, steerChatMessage, unarchiveChatSession } from "../adeApi"; import type { AdeCodeConnection } from "../types"; const tmpPaths: string[] = []; @@ -56,6 +56,45 @@ describe("listLaneDiffStats", () => { }); }); +describe("chat session archive helpers", () => { + it("lists chats with archived sessions hidden by default", async () => { + const calls: Array<{ domain: string; action: string; argsList: unknown[] }> = []; + const connection = { + actionList: async (domain: string, action: string, argsList: unknown[]) => { + calls.push({ domain, action, argsList }); + return []; + }, + } as unknown as AdeCodeConnection; + + await listChatSessions(connection); + await listChatSessions(connection, "lane-1", { includeArchived: true }); + + expect(calls).toEqual([ + { domain: "chat", action: "listSessions", argsList: [null, { includeArchived: false }] }, + { domain: "chat", action: "listSessions", argsList: ["lane-1", { includeArchived: true }] }, + ]); + }); + + it("calls archive, unarchive, and delete chat actions with session ids", async () => { + const calls: Array<{ domain: string; action: string; args: Record | undefined }> = []; + const connection = { + action: async (domain: string, action: string, args?: Record) => { + calls.push({ domain, action, args }); + }, + } as unknown as AdeCodeConnection; + + await archiveChatSession(connection, "chat-1"); + await unarchiveChatSession(connection, "chat-2"); + await deleteChatSession(connection, "chat-3"); + + expect(calls).toEqual([ + { domain: "chat", action: "archiveSession", args: { sessionId: "chat-1" } }, + { domain: "chat", action: "unarchiveSession", args: { sessionId: "chat-2" } }, + { domain: "chat", action: "deleteSession", args: { sessionId: "chat-3" } }, + ]); + }); +}); + describe("latestTokenStats", () => { it("tracks streaming state, context percentage, token counts, and cost", () => { const events = [ diff --git a/apps/ade-cli/src/tuiClient/__tests__/appInput.test.ts b/apps/ade-cli/src/tuiClient/__tests__/appInput.test.ts index c187858a8..8aae7ebb2 100644 --- a/apps/ade-cli/src/tuiClient/__tests__/appInput.test.ts +++ b/apps/ade-cli/src/tuiClient/__tests__/appInput.test.ts @@ -10,7 +10,12 @@ import { drawerMouseHitForLine, encodeTerminalPromptSubmit, encodeTerminalPromptSubmitConfirm, + applyCoalescedPromptInput, + firstUrlInText, footerControlsForAvailability, + inlineRowCellOrder, + formatGitConflictReport, + formatLaneDeleteRisk, formFieldUsesPromptInput, isChatSessionAnimating, isPromptLineBackspace, @@ -29,6 +34,8 @@ import { chatSelectionFromAnchor, chatSessionToOptimisticSummary, chatSelectionPointFromVisibleRows, + codexApprovalSandboxLabel, + cursorModeIdsForState, moveChatSelectionFocusByRows, mergeOptimisticChatSessions, insertPromptText, @@ -39,7 +46,6 @@ import { promptDisplayRows, promptDisplayRowsWithCursor, promptHitLine, - modelPickerSurfaceForSetupPane, resolveContextDefault, resolveDrawerPaneWidth, resolveModelPickerEscape, @@ -360,23 +366,15 @@ describe("right pane context defaults", () => { }); expect(pane).toMatchObject({ - kind: "new-chat-setup", + kind: "model-picker", + surface: "new-chat", laneId: "lane-1", laneLabel: "Lane one", + selection: { kind: "provider", provider: "claude" }, }); }); }); -describe("model setup picker routing", () => { - it("opens the rich picker against the current chat from /model or /effort setup panes", () => { - expect(modelPickerSurfaceForSetupPane("model-setup")).toBe("chat"); - }); - - it("keeps the new-chat picker scoped to the draft setup pane", () => { - expect(modelPickerSurfaceForSetupPane("new-chat-setup")).toBe("new-chat"); - }); -}); - describe("drawer mouse hit testing", () => { it("widens the drawer responsively on larger terminals", () => { expect(resolveDrawerPaneWidth(100, false)).toBe(0); @@ -542,12 +540,153 @@ describe("footer control ordering", () => { }); }); +describe("firstUrlInText", () => { + it("finds a bare URL with its index + width and strips trailing punctuation", () => { + const hit = firstUrlInText("see https://example.com/docs. thanks"); + expect(hit?.url).toBe("https://example.com/docs"); + expect(hit?.index).toBe(4); + expect(hit?.width).toBe("https://example.com/docs".length); + }); + it("resolves a markdown link to its href but spans the visible label", () => { + const hit = firstUrlInText("[the docs](https://example.com/x)"); + expect(hit?.url).toBe("https://example.com/x"); + expect(hit?.index).toBe(0); + expect(hit?.width).toBe("the docs".length); + }); + it("returns null when there is no link", () => { + expect(firstUrlInText("just some plain text")).toBeNull(); + }); +}); + +describe("inlineRowCellOrder", () => { + it("includes fast + reasoning only when supported, and provider/subagents per context", () => { + expect(inlineRowCellOrder({ providerLocked: false, fastSupported: true, reasoningSupported: true, subagentsVisible: true })) + .toEqual(["provider", "model", "fast", "reasoning", "permission", "subagents"]); + // No fast/reasoning support → those cells are absent (not dead focus stops). + expect(inlineRowCellOrder({ providerLocked: false, fastSupported: false, reasoningSupported: false, subagentsVisible: false })) + .toEqual(["provider", "model", "permission"]); + // Provider locked (chat underway) drops the provider cell. + expect(inlineRowCellOrder({ providerLocked: true, fastSupported: true, reasoningSupported: false, subagentsVisible: false })) + .toEqual(["model", "fast", "permission"]); + }); +}); + +describe("provider permission helpers", () => { + it("summarizes Codex approval and sandbox as a footer detail", () => { + expect(codexApprovalSandboxLabel({ + codexApprovalPolicy: "on-request", + codexSandbox: "workspace-write", + })).toBe("on-request · workspace-write"); + }); + + it("uses Cursor runtime snapshot modes before falling back to static modes", () => { + expect(cursorModeIdsForState({ cursorAvailableModeIds: ["ask", "plan"] })).toEqual(["ask", "plan"]); + expect(cursorModeIdsForState({ cursorAvailableModeIds: [] })).toContain("agent"); + }); +}); + +describe("formatGitConflictReport", () => { + it("lists conflicted files and the continue/abort actions for a rebase", () => { + const report = formatGitConflictReport({ + laneId: "lane-1", + kind: "rebase", + inProgress: true, + conflictedFiles: ["src/a.ts", "src/b.ts"], + canContinue: true, + canAbort: true, + }); + expect(report.title).toBe("Rebase conflict"); + expect(report.body).toContain("2 files need resolution"); + expect(report.body).toContain("src/a.ts"); + expect(report.body).toContain("/pull --continue"); + expect(report.body).toContain("/pull --abort"); + expect(report.summary).toContain("Rebase conflict — 2 files"); + }); + + it("uses merge wording and falls back when no continue/abort is available", () => { + const report = formatGitConflictReport({ + laneId: "lane-1", + kind: "merge", + inProgress: true, + conflictedFiles: [], + canContinue: false, + canAbort: false, + }); + expect(report.title).toBe("Merge conflict"); + expect(report.body).toContain("0 files need resolution"); + expect(report.body).toContain("git did not report specific files"); + expect(report.body).toContain("Resolve the conflicts in your editor"); + expect(report.body).not.toContain("/pull --continue"); + }); +}); + +describe("applyCoalescedPromptInput", () => { + const DEL = "\u007f"; + it("inserts pure printable input unchanged", () => { + expect(applyCoalescedPromptInput("", 0, "abc")).toEqual({ value: "abc", cursor: 3 }); + expect(applyCoalescedPromptInput("ac", 1, "b")).toEqual({ value: "abc", cursor: 2 }); + }); + it("applies a backspace that was coalesced with a typed char (the bug)", () => { + // "x" typed then immediately backspaced, delivered as one chunk. + expect(applyCoalescedPromptInput("", 0, `x${DEL}`)).toEqual({ value: "", cursor: 0 }); + }); + it("applies multiple coalesced backspaces", () => { + expect(applyCoalescedPromptInput("ab", 2, `${DEL}${DEL}`)).toEqual({ value: "", cursor: 0 }); + }); + it("interleaves deletes and inserts in order", () => { + expect(applyCoalescedPromptInput("", 0, `a${DEL}b`)).toEqual({ value: "b", cursor: 1 }); + expect(applyCoalescedPromptInput("yz", 2, `${DEL}x`)).toEqual({ value: "yx", cursor: 2 }); + }); + it("strips other control bytes but keeps text", () => { + expect(applyCoalescedPromptInput("", 0, "a\u0000b")).toEqual({ value: "ab", cursor: 2 }); + }); +}); + +describe("formatLaneDeleteRisk", () => { + const base = { + laneId: "lane-1", + branchRef: "feat/x", + dirty: false, + hasUnpushedCommits: false, + unpushedCommitCount: 0, + remoteBranchExists: false, + runningProcessCount: 0, + activePtyCount: 0, + activeWatcherCount: 0, + envInitialized: false, + }; + + it("summarizes everything that would be lost, pluralizing correctly", () => { + const summary = formatLaneDeleteRisk({ + ...base, + dirty: true, + hasUnpushedCommits: true, + unpushedCommitCount: 1, + runningProcessCount: 2, + activePtyCount: 1, + remoteBranchExists: true, + }); + expect(summary).toContain("uncommitted changes"); + expect(summary).toContain("1 unpushed commit"); + expect(summary).not.toContain("1 unpushed commits"); + expect(summary).toContain("2 running processes"); + expect(summary).toContain("1 terminal"); + expect(summary).toContain("remote branch exists"); + expect(summary.startsWith("⚠")).toBe(true); + }); + + it("reports a clean lane when there is nothing at risk", () => { + expect(formatLaneDeleteRisk(base)).toBe("Clean — no unpushed work or running processes."); + }); +}); + describe("model picker escape handling", () => { const picker = { kind: "model-picker" as const, surface: "chat" as const, query: "", searchMode: false, + showAll: false, selection: { kind: "favorites" as const }, focusedIndex: 3, }; diff --git a/apps/ade-cli/src/tuiClient/__tests__/commands.test.ts b/apps/ade-cli/src/tuiClient/__tests__/commands.test.ts index 0e2d1771f..1c3feec6c 100644 --- a/apps/ade-cli/src/tuiClient/__tests__/commands.test.ts +++ b/apps/ade-cli/src/tuiClient/__tests__/commands.test.ts @@ -40,6 +40,27 @@ describe("commands", () => { })); }); + it("parses the /lane management commands as multi-word ADE commands", () => { + const rename = parseCommand("/lane rename My New Lane"); + expect(rename?.name).toBe("/lane rename"); + expect(rename?.args).toBe("My New Lane"); + expect(rename ? commandPlacement(rename) : null).toBe("right"); + + const archive = parseCommand("/lane archive"); + expect(archive?.name).toBe("/lane archive"); + expect(archive?.args).toBe(""); + + const unarchive = parseCommand("/lane unarchive feat/x"); + expect(unarchive?.name).toBe("/lane unarchive"); + expect(unarchive?.args).toBe("feat/x"); + + // /lane delete must still match (longest-name-first ordering). + expect(parseCommand("/lane delete")?.name).toBe("/lane delete"); + expect(paletteCommands("/lane").map((c) => c.name)).toEqual( + expect.arrayContaining(["/lane rename", "/lane archive", "/lane unarchive", "/lane archived", "/lane delete"]), + ); + }); + it("routes /effort to the ADE Code right pane", () => { const parsed = parseCommand("/effort"); expect(parsed?.spec?.name).toBe("/effort"); @@ -187,7 +208,7 @@ describe("commands", () => { }); it("filters provider-specific ADE commands outside supported chats", () => { - expect(paletteCommands("/context", [], { provider: "codex" })).not.toContainEqual( + expect(paletteCommands("/context", [], { provider: "codex" })).toContainEqual( expect.objectContaining({ name: "/context" }), ); expect(paletteCommands("/output-style", [], { provider: "codex" })).not.toContainEqual( diff --git a/apps/ade-cli/src/tuiClient/__tests__/connection.test.ts b/apps/ade-cli/src/tuiClient/__tests__/connection.test.ts index fe07ff001..107b03140 100644 --- a/apps/ade-cli/src/tuiClient/__tests__/connection.test.ts +++ b/apps/ade-cli/src/tuiClient/__tests__/connection.test.ts @@ -621,6 +621,46 @@ describe("JsonRpcClient", () => { fs.rmSync(tmpDir, { recursive: true, force: true }); } }); + + it("fires onClose when the socket drops unexpectedly", async () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "ade-code-jsonrpc-")); + const socketPath = path.join(tmpDir, "rpc.sock"); + let resolveServerSocket: (socket: net.Socket) => void = () => {}; + const serverSocketReady = new Promise((resolve) => { + resolveServerSocket = resolve; + }); + const server = net.createServer((socket) => resolveServerSocket(socket)); + await listenRpc(server, socketPath); + const client = await JsonRpcClient.connect(socketPath); + const socket = await serverSocketReady; + try { + const closed = new Promise((resolve) => client.onClose(resolve)); + socket.destroy(); + await expect(closed).resolves.toBeUndefined(); + } finally { + client.close(); + await closeServer(server); + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }); + + it("does not fire onClose on an intentional close()", async () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "ade-code-jsonrpc-")); + const socketPath = path.join(tmpDir, "rpc.sock"); + const server = net.createServer(() => {}); + await listenRpc(server, socketPath); + const client = await JsonRpcClient.connect(socketPath); + try { + const onClose = vi.fn(); + client.onClose(onClose); + client.close(); + await new Promise((resolve) => setTimeout(resolve, 20)); + expect(onClose).not.toHaveBeenCalled(); + } finally { + await closeServer(server); + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }); }); async function loadStateModule(home: string): Promise { diff --git a/apps/ade-cli/src/tuiClient/__tests__/feedbackForm.test.ts b/apps/ade-cli/src/tuiClient/__tests__/feedbackForm.test.ts new file mode 100644 index 000000000..db46c7af4 --- /dev/null +++ b/apps/ade-cli/src/tuiClient/__tests__/feedbackForm.test.ts @@ -0,0 +1,236 @@ +import { describe, it, expect } from "vitest"; +import { + FEEDBACK_TYPES, + feedbackFormInitialState, + feedbackFormReducer, + feedbackFormCanSubmit, + feedbackFormToFormValues, + feedbackTypeToCategory, + feedbackSummaryLine, + serializeContextFooter, + cycleFeedbackType, + type ContextFooterInfo, + type FeedbackType, +} from "../feedbackForm.js"; +import { buildFeedbackDraftInput } from "../feedback.js"; + +describe("feedbackFormInitialState", () => { + it("defaults to bug type, empty text, context enabled", () => { + const s = feedbackFormInitialState(); + expect(s.type).toBe("bug"); + expect(s.text).toBe(""); + expect(s.showContext).toBe(true); + expect(s.context).toEqual({}); + }); + + it("captures the provided context snapshot by copy", () => { + const ctx: ContextFooterInfo = { provider: "anthropic", model: "opus" }; + const s = feedbackFormInitialState(ctx); + expect(s.context).toEqual(ctx); + ctx.provider = "mutated"; + expect(s.context.provider).toBe("anthropic"); + }); +}); + +describe("feedbackTypeToCategory", () => { + it("maps UI types onto existing daemon categories", () => { + expect(feedbackTypeToCategory("bug")).toBe("bug"); + expect(feedbackTypeToCategory("idea")).toBe("feature"); + expect(feedbackTypeToCategory("praise")).toBe("enhancement"); + }); +}); + +describe("cycleFeedbackType", () => { + it("cycles forward with wrap", () => { + expect(cycleFeedbackType("bug", 1)).toBe("idea"); + expect(cycleFeedbackType("idea", 1)).toBe("praise"); + expect(cycleFeedbackType("praise", 1)).toBe("bug"); + }); + it("cycles backward with wrap", () => { + expect(cycleFeedbackType("bug", -1)).toBe("praise"); + expect(cycleFeedbackType("praise", -1)).toBe("idea"); + }); + it("covers every declared type", () => { + let t: FeedbackType = FEEDBACK_TYPES[0]; + const seen = new Set(); + for (let i = 0; i < FEEDBACK_TYPES.length; i++) { + seen.add(t); + t = cycleFeedbackType(t, 1); + } + expect(seen.size).toBe(FEEDBACK_TYPES.length); + }); +}); + +describe("feedbackFormReducer", () => { + const base = feedbackFormInitialState(); + + it("setType changes the type", () => { + expect(feedbackFormReducer(base, { kind: "setType", type: "idea" }).type).toBe("idea"); + }); + it("setType is a no-op (same reference) when unchanged", () => { + expect(feedbackFormReducer(base, { kind: "setType", type: "bug" })).toBe(base); + }); + + it("cycleType moves through types", () => { + expect(feedbackFormReducer(base, { kind: "cycleType", direction: 1 }).type).toBe("idea"); + }); + + it("setText replaces the body", () => { + expect(feedbackFormReducer(base, { kind: "setText", text: "hello" }).text).toBe("hello"); + }); + + it("appendChar / appendNewline / backspace edit the body", () => { + let s = feedbackFormReducer(base, { kind: "appendChar", char: "a" }); + s = feedbackFormReducer(s, { kind: "appendNewline" }); + s = feedbackFormReducer(s, { kind: "appendChar", char: "b" }); + expect(s.text).toBe("a\nb"); + s = feedbackFormReducer(s, { kind: "backspace" }); + expect(s.text).toBe("a\n"); + }); + + it("backspace on empty text is a no-op (same reference)", () => { + expect(feedbackFormReducer(base, { kind: "backspace" })).toBe(base); + }); + + it("toggleContext flips the flag", () => { + const off = feedbackFormReducer(base, { kind: "toggleContext" }); + expect(off.showContext).toBe(false); + expect(feedbackFormReducer(off, { kind: "toggleContext" }).showContext).toBe(true); + }); + + it("setContext replaces and copies context", () => { + const ctx: ContextFooterInfo = { lane: "my-lane" }; + const s = feedbackFormReducer(base, { kind: "setContext", context: ctx }); + expect(s.context).toEqual(ctx); + expect(s.context).not.toBe(ctx); + }); + + it("reset returns to initial state preserving context", () => { + let s = feedbackFormReducer(base, { kind: "setText", text: "abc" }); + s = feedbackFormReducer(s, { kind: "setContext", context: { lane: "L" } }); + const r = feedbackFormReducer(s, { kind: "reset" }); + expect(r.text).toBe(""); + expect(r.type).toBe("bug"); + expect(r.showContext).toBe(true); + expect(r.context).toEqual({ lane: "L" }); + }); +}); + +describe("feedbackFormCanSubmit", () => { + it("is false for empty / whitespace-only text", () => { + expect(feedbackFormCanSubmit(feedbackFormInitialState())).toBe(false); + expect( + feedbackFormCanSubmit({ ...feedbackFormInitialState(), text: " \n\t " }), + ).toBe(false); + }); + it("is true once there is real content", () => { + expect( + feedbackFormCanSubmit({ ...feedbackFormInitialState(), text: " x " }), + ).toBe(true); + }); +}); + +describe("feedbackSummaryLine", () => { + it("uses the first non-empty line of a multiline body", () => { + expect(feedbackSummaryLine("\n\n the title \nmore detail")).toBe("the title"); + }); + it("is empty when the body is blank", () => { + expect(feedbackSummaryLine(" \n ")).toBe(""); + }); +}); + +describe("serializeContextFooter", () => { + it("returns empty string when nothing useful is present", () => { + expect(serializeContextFooter({})).toBe(""); + expect(serializeContextFooter({ provider: "", model: null, lane: " " })).toBe(""); + }); + it("combines provider and model on one line", () => { + expect(serializeContextFooter({ provider: "anthropic", model: "opus" })).toContain( + "Provider/Model: anthropic / opus", + ); + }); + it("emits only the fields that are present", () => { + const out = serializeContextFooter({ model: "opus", lane: "lane-1" }); + expect(out).toContain("Provider/Model: opus"); + expect(out).toContain("Lane: lane-1"); + expect(out).not.toContain("Last error"); + }); + it("includes last error/notice", () => { + expect(serializeContextFooter({ lastError: "boom failed" })).toContain( + "Last error/notice: boom failed", + ); + }); +}); + +describe("feedbackFormToFormValues", () => { + it("derives summary + multiline details and maps the category", () => { + const s = { + ...feedbackFormInitialState(), + type: "idea" as const, + text: "the headline\nline two\nline three", + showContext: false, + }; + const values = feedbackFormToFormValues(s); + expect(values.category).toBe("feature"); + expect(values.summary).toBe("the headline"); + expect(values.details).toBe("the headline\nline two\nline three"); + expect(values.additionalContext).toBeUndefined(); + }); + + it("puts the context footer into additionalContext when enabled", () => { + const s = { + ...feedbackFormInitialState(), + text: "it broke", + showContext: true, + context: { provider: "anthropic", model: "opus", lane: "L", lastError: "E" }, + }; + const values = feedbackFormToFormValues(s); + expect(values.additionalContext).toContain("--- Context ---"); + expect(values.additionalContext).toContain("Provider/Model: anthropic / opus"); + expect(values.additionalContext).toContain("Lane: L"); + expect(values.additionalContext).toContain("Last error/notice: E"); + }); + + it("omits additionalContext when context is toggled off", () => { + const s = { + ...feedbackFormInitialState(), + text: "it broke", + showContext: false, + context: { provider: "anthropic" }, + }; + expect(feedbackFormToFormValues(s).additionalContext).toBeUndefined(); + }); +}); + +describe("integration with buildFeedbackDraftInput (existing daemon path)", () => { + it("multiline body survives into a bug draft and context lands in additionalContext", () => { + const s = { + ...feedbackFormInitialState(), + type: "bug" as const, + text: "crash on launch\nstep 1\nstep 2", + showContext: true, + context: { provider: "anthropic", model: "opus", lane: "L" }, + }; + const draft = buildFeedbackDraftInput(feedbackFormToFormValues(s)); + expect(draft.category).toBe("bug"); + expect(draft.summary).toBe("crash on launch"); + // details -> stepsToReproduce for bug category; newlines preserved. + if (draft.category === "bug") { + expect(draft.stepsToReproduce).toBe("crash on launch\nstep 1\nstep 2"); + } + expect(draft.additionalContext).toContain("--- Context ---"); + expect(draft.additionalContext).toContain("Provider/Model: anthropic / opus"); + }); + + it("idea maps to a feature draft", () => { + const s = { + ...feedbackFormInitialState(), + type: "idea" as const, + text: "add dark mode\nwould be nice", + showContext: false, + }; + const draft = buildFeedbackDraftInput(feedbackFormToFormValues(s)); + expect(draft.category).toBe("feature"); + expect(draft.summary).toBe("add dark mode"); + }); +}); diff --git a/apps/ade-cli/src/tuiClient/__tests__/format.test.ts b/apps/ade-cli/src/tuiClient/__tests__/format.test.ts index 012473af3..4aae37756 100644 --- a/apps/ade-cli/src/tuiClient/__tests__/format.test.ts +++ b/apps/ade-cli/src/tuiClient/__tests__/format.test.ts @@ -1,5 +1,21 @@ import { describe, expect, it } from "vitest"; -import { latestExpandableFailureId, parseAssistantMarkdown, parseInlineRuns, renderChatLines, renderObject } from "../format"; +import { diffLineKind, latestExpandableFailureId, parseAssistantMarkdown, parseInlineRuns, renderChatLines, renderObject } from "../format"; + +describe("diffLineKind", () => { + it("classifies hunk, meta, add, del, and context lines", () => { + expect(diffLineKind("@@ -1,4 +1,6 @@ fn main()")).toBe("hunk"); + expect(diffLineKind("diff --git a/x b/x")).toBe("meta"); + expect(diffLineKind("index 1234..5678 100644")).toBe("meta"); + expect(diffLineKind("--- a/x")).toBe("meta"); + expect(diffLineKind("+++ b/x")).toBe("meta"); + expect(diffLineKind("new file mode 100644")).toBe("meta"); + expect(diffLineKind("\\ No newline at end of file")).toBe("meta"); + expect(diffLineKind("+added content")).toBe("add"); + expect(diffLineKind("-removed content")).toBe("del"); + expect(diffLineKind(" unchanged context")).toBe("context"); + expect(diffLineKind("")).toBe("context"); + }); +}); describe("renderChatLines", () => { it("parses assistant markdown into stable blocks", () => { diff --git a/apps/ade-cli/src/tuiClient/__tests__/helpIndex.test.ts b/apps/ade-cli/src/tuiClient/__tests__/helpIndex.test.ts new file mode 100644 index 000000000..852265095 --- /dev/null +++ b/apps/ade-cli/src/tuiClient/__tests__/helpIndex.test.ts @@ -0,0 +1,216 @@ +import { describe, it, expect } from "vitest"; +import { + buildHelpIndex, + buildHelpRows, + flattenHelpRows, + formatChordForDisplay, + getKeybindForCommand, + helpMatchScore, + pushRecent, + type HelpGroup, +} from "../helpIndex"; +import { BUILTIN_COMMANDS, COMMAND_CATEGORY_ORDER } from "../commands"; +import type { ClaudeKeybinding } from "../keybindings"; + +function rowNames(groups: HelpGroup[]): string[] { + return flattenHelpRows(groups).map((r) => r.name); +} + +// A minimal live-registry double: /help bound to ctrl+/, /model to ctrl+o ctrl+m. +const BINDINGS: ClaudeKeybinding[] = [ + { context: "Global", key: "ctrl+/", action: "app:help", rawAction: "app:help", implemented: true }, + { context: "Global", key: "ctrl+o ctrl+m", action: "chat:modelPicker", rawAction: "chat:modelPicker", implemented: true }, + { context: "Global", key: "ctrl+z", action: "app:redraw", rawAction: "app:redraw", implemented: false }, +]; + +describe("buildHelpIndex", () => { + it("groups every built-in command into a category bucket", () => { + const groups = buildHelpIndex(); + const total = groups.reduce((sum, g) => sum + g.rows.length, 0); + expect(total).toBe(BUILTIN_COMMANDS.length); + }); + + it("only emits non-empty groups, in category order", () => { + const groups = buildHelpIndex(); + for (const g of groups) expect(g.rows.length).toBeGreaterThan(0); + const order = groups.map((g) => g.category); + const expectedOrder = COMMAND_CATEGORY_ORDER.filter((c) => order.includes(c)); + expect(order).toEqual(expectedOrder); + }); + + it("preserves command metadata (name + description)", () => { + const groups = buildHelpIndex(); + const commit = flattenHelpRows(groups).find((r) => r.name === "/commit"); + expect(commit).toBeDefined(); + expect(commit?.description).toBe("Commit lane changes"); + expect(commit?.category).toBe("Lanes"); + }); + + it("places /commit under Lanes and /pr under PRs", () => { + const groups = buildHelpIndex(); + const lanes = groups.find((g) => g.category === "Lanes"); + const prs = groups.find((g) => g.category === "PRs"); + expect(lanes?.rows.some((r) => r.name === "/commit")).toBe(true); + expect(prs?.rows.some((r) => r.name === "/pr")).toBe(true); + }); + + it("attaches keybinds for commands bound in the live registry", () => { + const groups = buildHelpIndex(BUILTIN_COMMANDS, BINDINGS); + const help = flattenHelpRows(groups).find((r) => r.name === "/help"); + expect(help?.keybind).toBe("Ctrl+/"); + const model = flattenHelpRows(groups).find((r) => r.name === "/model"); + expect(model?.keybind).toBe("Ctrl+O Ctrl+M"); + }); + + it("leaves unbound commands without a keybind", () => { + const groups = buildHelpIndex(BUILTIN_COMMANDS, BINDINGS); + const commit = flattenHelpRows(groups).find((r) => r.name === "/commit"); + expect(commit?.keybind).toBeUndefined(); + }); + + it("renders no keybinds when the registry is empty (the default)", () => { + const groups = buildHelpIndex(BUILTIN_COMMANDS, []); + expect(flattenHelpRows(groups).every((r) => r.keybind === undefined)).toBe(true); + }); +}); + +describe("getKeybindForCommand", () => { + it("returns the formatted chord for a bound command", () => { + expect(getKeybindForCommand("/help", BINDINGS)).toBe("Ctrl+/"); + }); + + it("returns undefined for an unbound command", () => { + expect(getKeybindForCommand("/commit", BINDINGS)).toBeUndefined(); + }); + + it("ignores unimplemented bindings", () => { + // app:redraw is present but implemented:false, and no command maps to it anyway. + expect(getKeybindForCommand("/quit", BINDINGS)).toBeUndefined(); + }); + + it("degrades to undefined when no registry is provided", () => { + expect(getKeybindForCommand("/help", null)).toBeUndefined(); + expect(getKeybindForCommand("/help", [])).toBeUndefined(); + }); +}); + +describe("formatChordForDisplay", () => { + it("capitalizes modifiers and single keys", () => { + expect(formatChordForDisplay("ctrl+p")).toBe("Ctrl+P"); + expect(formatChordForDisplay("shift+tab")).toBe("Shift+Tab"); + }); + + it("handles multi-stroke chords", () => { + expect(formatChordForDisplay("ctrl+o ctrl+m")).toBe("Ctrl+O Ctrl+M"); + }); +}); + +describe("helpMatchScore", () => { + const row = { name: "/push", description: "Push the active lane branch", source: "ade" as const, category: "Lanes" as const }; + + it("scores exact name highest", () => { + expect(helpMatchScore("/push", row)).toBe(1000); + expect(helpMatchScore("push", row)).toBe(1000); + }); + + it("scores prefix above description", () => { + const prefix = helpMatchScore("pus", row); + const desc = helpMatchScore("branch", row); + expect(prefix).toBeGreaterThan(desc); + }); + + it("returns 0 for an empty query", () => { + expect(helpMatchScore("", row)).toBe(0); + }); + + it("disqualifies rows where a token matches nowhere", () => { + expect(helpMatchScore("zzzqqq", row)).toBe(-1); + }); + + it("is case-insensitive", () => { + expect(helpMatchScore("PUSH", row)).toBe(1000); + }); +}); + +describe("buildHelpRows", () => { + const index = buildHelpIndex(BUILTIN_COMMANDS, BINDINGS); + + it("with an empty filter keeps every command", () => { + const rows = buildHelpRows(index, "", []); + const total = rows.reduce((sum, g) => sum + g.rows.length, 0); + expect(total).toBe(BUILTIN_COMMANDS.length); + }); + + it("narrows to matching commands and drops emptied groups", () => { + const rows = buildHelpRows(index, "push", []); + const names = rowNames(rows); + expect(names).toContain("/push"); + // No Linear command contains "push", so the Linear group must be gone. + expect(rows.some((g) => g.category === "Linear")).toBe(false); + }); + + it("is case-insensitive in filtering", () => { + const rows = buildHelpRows(index, "CHAT", []); + const names = rowNames(rows); + expect(names).toContain("/chat rename"); + }); + + it("ranks prefix matches before non-prefix matches within a group", () => { + const rows = buildHelpRows(index, "pr", []); + const prGroup = rows.find((g) => g.category === "PRs"); + expect(prGroup).toBeDefined(); + // "/pr" is an exact/prefix hit; it must sort to the top of the PRs group. + expect(prGroup!.rows[0].name).toBe("/pr"); + }); + + it("floats a recent command above a non-recent peer at the same score", () => { + // Empty filter ⇒ all scores equal 0; recents decide order within a group. + const rows = buildHelpRows(index, "", ["/push"]); + const lanes = rows.find((g) => g.category === "Lanes"); + expect(lanes).toBeDefined(); + expect(lanes!.rows[0].name).toBe("/push"); + }); + + it("orders multiple recents most-recent-first", () => { + const rows = buildHelpRows(index, "", ["/commit", "/push"]); + const lanes = rows.find((g) => g.category === "Lanes")!; + const commitIdx = lanes.rows.findIndex((r) => r.name === "/commit"); + const pushIdx = lanes.rows.findIndex((r) => r.name === "/push"); + expect(commitIdx).toBeLessThan(pushIdx); + }); + + it("does not break ranking with an empty recents list", () => { + const rows = buildHelpRows(index, "", []); + const lanes = rows.find((g) => g.category === "Lanes")!; + // Pure alphabetical fallback. + const names = lanes.rows.map((r) => r.name); + const sorted = [...names].sort((a, b) => a.localeCompare(b)); + expect(names).toEqual(sorted); + }); +}); + +describe("flattenHelpRows", () => { + it("flattens groups in order", () => { + const groups = buildHelpRows(buildHelpIndex(), "", []); + const flat = flattenHelpRows(groups); + expect(flat.length).toBe(BUILTIN_COMMANDS.length); + // First flat row belongs to the first group. + expect(flat[0].category).toBe(groups[0].category); + }); +}); + +describe("pushRecent", () => { + it("adds to the front, de-dupes, and caps length", () => { + let recents: string[] = []; + recents = pushRecent(recents, "/a"); + recents = pushRecent(recents, "/b"); + recents = pushRecent(recents, "/a"); // re-run /a moves it to front + expect(recents).toEqual(["/a", "/b"]); + }); + + it("respects the limit", () => { + let recents: string[] = []; + for (const n of ["/1", "/2", "/3", "/4", "/5", "/6"]) recents = pushRecent(recents, n, 5); + expect(recents).toEqual(["/6", "/5", "/4", "/3", "/2"]); + }); +}); diff --git a/apps/ade-cli/src/tuiClient/__tests__/multiChatLayout.test.ts b/apps/ade-cli/src/tuiClient/__tests__/multiChatLayout.test.ts index 520b47260..18be57639 100644 --- a/apps/ade-cli/src/tuiClient/__tests__/multiChatLayout.test.ts +++ b/apps/ade-cli/src/tuiClient/__tests__/multiChatLayout.test.ts @@ -26,6 +26,33 @@ describe("multi chat layout", () => { ]); }); + it("fills the full pane with no dead margin when dimensions don't divide evenly", () => { + // 100 / 3 cols and 31 / 2 rows do not divide evenly — the old floor math + // left a dead column/row. Every grid row must now span the full width, and + // every column must span the full height. + for (const count of [2, 3, 4, 5, 6] as const) { + const rects = computeTileRects(count, 100, 31); + // Rightmost edge across each pattern row reaches the full width. + const byRow = new Map(); + for (const rect of rects) { + const bucket = byRow.get(rect.y) ?? []; + bucket.push(rect); + byRow.set(rect.y, bucket); + } + for (const row of byRow.values()) { + const rightmost = Math.max(...row.map((r) => r.x + r.w)); + expect(rightmost).toBe(100); + // No gaps/overlaps within a row: sorted edges are contiguous. + const sorted = [...row].sort((a, b) => a.x - b.x); + for (let i = 1; i < sorted.length; i += 1) { + expect(sorted[i]!.x).toBe(sorted[i - 1]!.x + sorted[i - 1]!.w); + } + } + // Bottom edge reaches the full height. + expect(Math.max(...rects.map((r) => r.y + r.h))).toBe(31); + } + }); + it("flags layouts that are too narrow or too short for readable tiles", () => { expect(canRenderMultiChatGrid(6, 120, 24)).toBe(true); expect(canRenderMultiChatGrid(6, 80, 24)).toBe(false); diff --git a/apps/ade-cli/src/tuiClient/__tests__/multilinePaste.test.ts b/apps/ade-cli/src/tuiClient/__tests__/multilinePaste.test.ts new file mode 100644 index 000000000..8555b8fea Binary files /dev/null and b/apps/ade-cli/src/tuiClient/__tests__/multilinePaste.test.ts differ diff --git a/apps/ade-cli/src/tuiClient/__tests__/pendingInput.test.ts b/apps/ade-cli/src/tuiClient/__tests__/pendingInput.test.ts index 6f363fb19..14906bc11 100644 --- a/apps/ade-cli/src/tuiClient/__tests__/pendingInput.test.ts +++ b/apps/ade-cli/src/tuiClient/__tests__/pendingInput.test.ts @@ -103,4 +103,32 @@ describe("pendingInput", () => { highStakes: true, })); }); + + it("keeps plan approvals on the one-key approval path", () => { + const request: PendingInputRequest = { + ...baseRequest, + kind: "plan_approval", + title: "Approve plan", + questions: [], + allowsFreeform: false, + }; + const events: AgentChatEventEnvelope[] = [{ + sessionId: "s1", + timestamp: "2026-01-01T00:00:00.000Z", + sequence: 1, + event: { + type: "approval_request", + itemId: "item-plan", + kind: "tool_call", + description: "Approve plan", + detail: { request }, + }, + }]; + + expect(latestPendingApproval(events)).toEqual(expect.objectContaining({ + itemId: "item-plan", + mode: "approval", + highStakes: false, + })); + }); }); diff --git a/apps/ade-cli/src/tuiClient/__tests__/planMode.test.ts b/apps/ade-cli/src/tuiClient/__tests__/planMode.test.ts index 90705882d..a7aba0888 100644 --- a/apps/ade-cli/src/tuiClient/__tests__/planMode.test.ts +++ b/apps/ade-cli/src/tuiClient/__tests__/planMode.test.ts @@ -20,6 +20,7 @@ function baseModelState(overrides: Partial): AdeCodeModelStat opencodePermissionMode: "edit", droidPermissionMode: "auto-low", cursorModeId: "agent", + cursorAvailableModeIds: [], cursorConfigValues: {}, ...overrides, }; diff --git a/apps/ade-cli/src/tuiClient/adeApi.ts b/apps/ade-cli/src/tuiClient/adeApi.ts index 8824c951d..3ef845b44 100644 --- a/apps/ade-cli/src/tuiClient/adeApi.ts +++ b/apps/ade-cli/src/tuiClient/adeApi.ts @@ -50,9 +50,12 @@ import type { AdeCodeConnection, ChatHistorySnapshot, CreatedChat, NavigateReque export const DEFAULT_CODEX_REASONING_EFFORT = "low"; -export async function listLanes(connection: AdeCodeConnection): Promise { +export async function listLanes( + connection: AdeCodeConnection, + options: { includeArchived?: boolean } = {}, +): Promise { return await connection.action("lane", "list", { - includeArchived: false, + includeArchived: options.includeArchived ?? false, includeStatus: true, }); } @@ -69,11 +72,34 @@ export async function listLaneDiffStats( export async function listChatSessions( connection: AdeCodeConnection, laneId?: string | null, + options: { includeArchived?: boolean } = {}, ): Promise { - const argsList = laneId ? [laneId] : []; + const listOptions = { includeArchived: options.includeArchived ?? false }; + const argsList = laneId ? [laneId, listOptions] : [null, listOptions]; return await connection.actionList("chat", "listSessions", argsList); } +export async function archiveChatSession( + connection: AdeCodeConnection, + sessionId: string, +): Promise { + await connection.action("chat", "archiveSession", { sessionId }); +} + +export async function unarchiveChatSession( + connection: AdeCodeConnection, + sessionId: string, +): Promise { + await connection.action("chat", "unarchiveSession", { sessionId }); +} + +export async function deleteChatSession( + connection: AdeCodeConnection, + sessionId: string, +): Promise { + await connection.action("chat", "deleteSession", { sessionId }); +} + const CHAT_BACKED_TERMINAL_TOOL_TYPES = new Set([ "codex-chat", "claude-chat", diff --git a/apps/ade-cli/src/tuiClient/app.tsx b/apps/ade-cli/src/tuiClient/app.tsx index aa9cd4aa4..9efb90e99 100644 --- a/apps/ade-cli/src/tuiClient/app.tsx +++ b/apps/ade-cli/src/tuiClient/app.tsx @@ -22,7 +22,6 @@ import type { AgentChatCodexSandbox, AgentChatClaudePlugin, AgentChatReloadClaudePluginsResult, - AgentChatContextUsage, AgentChatEventEnvelope, AgentChatFileRef, AgentChatModelCatalog, @@ -36,15 +35,17 @@ import type { CodexThreadGoal, } from "../../../desktop/src/shared/types/chat"; import type { AiSettingsStatus, OpenCodeRuntimeSnapshot } from "../../../desktop/src/shared/types/config"; -import type { DiffLineStats } from "../../../desktop/src/shared/types/git"; -import type { LaneSummary } from "../../../desktop/src/shared/types/lanes"; +import type { DiffLineStats, GitConflictState } from "../../../desktop/src/shared/types/git"; +import type { LaneDeleteRisk, LaneSummary } from "../../../desktop/src/shared/types/lanes"; import type { FeedbackPreparedDraft, FeedbackSubmission } from "../../../desktop/src/shared/types/feedback"; import type { ChatTerminalPreviewResult, ChatTerminalSession } from "../../../desktop/src/shared/types"; import { DEFAULT_CODEX_REASONING_EFFORT, approveToolUse, + archiveChatSession, cancelSteerMessage, createChatSession, + deleteChatSession, discoverProjectSlashCommands, dispatchSteerMessage, editSteerMessage, @@ -86,13 +87,15 @@ import { startClaudeTerminalSession, steerChatMessage, tagChat, + unarchiveChatSession, updateChatModel, writeTerminal, type TokenStats, } from "./adeApi"; -import { derivePendingSteers } from "./aggregate"; +import { aggregateChatBlocks, derivePendingSteers, type AggregatedBlock } from "./aggregate"; import { deriveChatInfoSnapshot } from "./chatInfo"; -import { paletteCommands, parseCommand } from "./commands"; +import { BUILTIN_COMMANDS, paletteCommands, parseCommand } from "./commands"; +import { buildHelpIndex, buildHelpRows, flattenHelpRows, pushRecent } from "./helpIndex"; import { hasFirstUserMessage, isPlanMode } from "./planMode"; import { connectToAde } from "./connection"; import { Drawer, visibleDrawerChatCount, visibleDrawerLaneCount, type DrawerPrSummary } from "./components/Drawer"; @@ -106,11 +109,22 @@ import { type ChatTextSelection, } from "./components/ChatView"; import { TerminalPane, clampTerminalPaneCols } from "./components/TerminalPane"; +import { + type TerminalScrollBySessionId, + clampTerminalScrollOffset, + jumpTerminalToBottom, + noteTerminalNewRows, + readTerminalScroll, + scrollTerminalBy, + terminalPageStep, +} from "./components/TerminalScrollState"; import { Header } from "./components/Header"; -import { computeLaneChatCounts, LANE_DETAIL_ACTIONS, LANE_DETAIL_PR_ACTION_INDEX, laneDetailsInteractionLayout, RightPane } from "./components/RightPane"; +import { computeLaneChatCounts, DETAILS_BODY_MAX_LINES, LANE_DETAIL_ACTIONS, LANE_DETAIL_PR_ACTION_INDEX, laneDetailsInteractionLayout, rightPaneScrollableRowCount, RightPane } from "./components/RightPane"; import { buildModelPickerLayout, defaultSelectionFor, railEntrySelection } from "./components/ModelPicker/modelPickerLayout"; -import { SlashPalette, SLASH_PALETTE_ROWS } from "./components/SlashPalette"; +import { modelPickerGeometry } from "./components/ModelPicker/modelPickerGeometry"; +import { SlashPalette, slashPaletteReservedRows } from "./components/SlashPalette"; import { MentionPalette, MENTION_PALETTE_ROWS } from "./components/MentionPalette"; +import { CommandPalette, COMMAND_PALETTE_ROWS, type CommandPaletteItem } from "./components/CommandPalette"; import { ApprovalPrompt } from "./components/ApprovalPrompt"; import { ModelStatus } from "./components/ModelStatus"; import { FooterControls } from "./components/FooterControls"; @@ -123,7 +137,7 @@ import { sortLanesForStackGraph } from "./laneTree"; import { latestExpandableFailureId, renderObject, summarizeDiffChanges } from "./format"; import { startTuiHeartbeat, type TuiHeartbeat } from "./heartbeat"; import { isImageFilePath, latestOpenableImageTarget, readClipboardImageAttachment, readImageDimensions } from "./imageTargets"; -import { appendDedupedTuiEvent, appendReservedTuiEvent, dedupeTuiEvents, reserveTuiEventDedupKey, syncTuiEventDedupKeys } from "./eventDedup"; +import { appendReservedTuiEvent, dedupeTuiEvents, reserveTuiEventDedupKey, syncTuiEventDedupKeys } from "./eventDedup"; import { loadAdeCodeState, saveAdeCodeProjectState, scopedAdeCodeState } from "./state"; import { SpinTickProvider } from "./spinTick"; import { buildLinearToolRequest } from "./linearCommands"; @@ -143,6 +157,13 @@ import { feedbackSubmissionNotice, type FeedbackFormValues, } from "./feedback"; +import { + cycleFeedbackType, + feedbackFormCanSubmit, + feedbackFormToFormValues, + type FeedbackFormState, + type FeedbackType, +} from "./feedbackForm"; import { buildPendingInputAnswers, latestPendingApproval } from "./pendingInput"; import { claudeHomePath, defaultKeybindingsPath, dispatchKeybinding, openKeybindingsFile, readClaudeKeybindingsFile, type KeybindingDispatchState, type TuiKeybindingAction } from "./keybindings"; import { buildDeeplinkForRow, type DeeplinkRow } from "./deeplinkRow"; @@ -174,6 +195,7 @@ import type { PendingApproval, ProviderReadinessRow, ProjectLaunchContext, + FeedbackContextMeta, RightPaneContent, SetupPaneRow, SetupPaneRowKind, @@ -206,10 +228,92 @@ export type FooterControl = "drawer" | "details" | "agents"; type DrawerLaneAction = "new-lane"; type DrawerChatAction = "new-chat"; +// Streaming chat events are coalesced into a single React render per frame +// (~40fps) instead of one render per token. Lifecycle edges (turn start/stop, +// done, user messages, subagent/error) force an immediate flush so the spinner, +// interrupt flags, and right pane stay responsive. +const CHAT_EVENT_FLUSH_MS = 24; + +function isChatFlushEdge(eventType: string): boolean { + // Only the event types that drive an immediate side-effect below (spinner / + // interrupt / right-pane) force a synchronous flush. Notably NOT the broad + // "subagent" prefix — subagent_progress is high-frequency and would defeat + // coalescing; only subagent_started opens the right pane. + return ( + eventType === "status" + || eventType === "done" + || eventType === "user_message" + || eventType === "error" + || eventType === "subagent_started" + || eventType === "subagent.started" + ); +} + export function footerControlsForAvailability(agentsAvailable: boolean): FooterControl[] { return agentsAvailable ? ["agents", "drawer", "details"] : ["drawer", "details"]; } +export type InlineRowCellName = "provider" | "model" | "fast" | "reasoning" | "permission" | "subagents"; + +// Single source of truth for the footer's inline cells. A cell only appears (and +// is focusable by keyboard/mouse) when it applies — so fast mode and reasoning +// are reachable exactly when supported, and neither is a dead focus stop. +export function inlineRowCellOrder(opts: { + providerLocked: boolean; + fastSupported: boolean; + reasoningSupported: boolean; + subagentsVisible: boolean; +}): InlineRowCellName[] { + const cells: InlineRowCellName[] = []; + if (!opts.providerLocked) cells.push("provider"); + cells.push("model"); + if (opts.fastSupported) cells.push("fast"); + if (opts.reasoningSupported) cells.push("reasoning"); + cells.push("permission"); + if (opts.subagentsVisible) cells.push("subagents"); + return cells; +} + +// Turn a git conflict into a right-pane detail body + a one-line notice. Used by +// /pull and /reparent so a rebase/merge conflict is surfaced instead of being +// silently reported as success. +export function formatGitConflictReport(state: GitConflictState): { title: string; body: string; summary: string } { + const label = state.kind === "rebase" ? "Rebase" : "Merge"; + const count = state.conflictedFiles.length; + const plural = count === 1 ? "" : "s"; + const fileList = count + ? state.conflictedFiles.map((file) => ` • ${file}`).join("\n") + : " (git did not report specific files)"; + const resolveActions = [ + state.canContinue ? "/pull --continue to finish" : null, + state.canAbort ? "/pull --abort to back out" : null, + ].filter((value): value is string => Boolean(value)); + const actionsLine = resolveActions.length + ? `Resolve the conflicts, then run ${resolveActions.join(", or ")}.` + : "Resolve the conflicts in your editor to continue."; + return { + title: `${label} conflict`, + body: [`${count} file${plural} need resolution:`, fileList, "", actionsLine].join("\n"), + summary: `${label} conflict — ${count} file${plural} need resolution. ${actionsLine}`, + }; +} + +// One-line summary of what deleting a lane would lose, shown in the delete form +// so the confirmation isn't blind (mirrors the desktop's delete-risk surface). +export function formatLaneDeleteRisk(risk: LaneDeleteRisk): string { + const parts: string[] = []; + if (risk.dirty) parts.push("uncommitted changes"); + if (risk.hasUnpushedCommits) { + parts.push(`${risk.unpushedCommitCount} unpushed commit${risk.unpushedCommitCount === 1 ? "" : "s"}`); + } + if (risk.runningProcessCount > 0) { + parts.push(`${risk.runningProcessCount} running process${risk.runningProcessCount === 1 ? "" : "es"}`); + } + if (risk.activePtyCount > 0) parts.push(`${risk.activePtyCount} terminal${risk.activePtyCount === 1 ? "" : "s"}`); + if (risk.remoteBranchExists) parts.push("remote branch exists"); + return parts.length ? `⚠ ${parts.join(" · ")}` : "Clean — no unpushed work or running processes."; +} + export type ModelPickerEscapeAction = | { kind: "clear-search"; pane: Extract } | { kind: "return-new-chat" } @@ -258,6 +362,90 @@ export function isTerminalSessionFastPollActive(session: TerminalSessionActivity && isProcessLikelyAlive(session.pid); } +const URL_IN_TEXT_RE = /\bhttps?:\/\/[^\s<>"')\]]+/gi; +const MARKDOWN_LINK_IN_TEXT_RE = /\[([^\]]+)\]\((https?:\/\/[^)\s]+)\)/i; + +export function firstUrlInText(value: string): { url: string; index: number; width: number } | null { + const markdownMatch = MARKDOWN_LINK_IN_TEXT_RE.exec(value); + const markdownCandidate = markdownMatch?.[1] && markdownMatch[2] + ? { + url: markdownMatch[2].replace(/[.,;:!?]+$/u, ""), + index: markdownMatch.index, + width: markdownMatch[1].length, + } + : null; + URL_IN_TEXT_RE.lastIndex = 0; + const match = URL_IN_TEXT_RE.exec(value); + const rawCandidate = match?.[0] + ? { + url: match[0].replace(/[.,;:!?]+$/u, ""), + index: match.index, + width: match[0].replace(/[.,;:!?]+$/u, "").length, + } + : null; + if (markdownCandidate && rawCandidate) { + return markdownCandidate.index <= rawCandidate.index ? markdownCandidate : rawCandidate; + } + return markdownCandidate ?? rawCandidate; +} + +function paletteMatchScore(item: CommandPaletteItem, query: string): number | null { + const trimmed = query.trim().toLowerCase(); + if (!trimmed) return 0; + const haystack = `${item.label} ${item.detail}`.toLowerCase(); + if (haystack.includes(trimmed)) return haystack.indexOf(trimmed); + let cursor = 0; + let score = 0; + for (const char of trimmed) { + const found = haystack.indexOf(char, cursor); + if (found < 0) return null; + score += found - cursor; + cursor = found + 1; + } + return score + haystack.length; +} + +// Rebuild the framework-free FeedbackFormState (feedbackForm.ts) from the +// FeedbackContextMeta carried on the feedback form content. Keeps validation + +// serialization (feedbackFormCanSubmit / feedbackFormToFormValues) in lock-step +// with what the right pane renders. +function feedbackStateFromMeta(meta: FeedbackContextMeta): FeedbackFormState { + const rawType = (meta.type ?? "bug") as FeedbackType; + const type: FeedbackType = rawType === "bug" || rawType === "idea" || rawType === "praise" ? rawType : "bug"; + return { + type, + text: meta.body ?? "", + showContext: meta.showContext !== false, + context: { + provider: meta.provider ?? null, + model: meta.model ?? null, + lane: meta.lane ?? null, + lastError: meta.lastError ?? null, + }, + }; +} + +function openExternalUrl(url: string, notice: (message: string, tone?: LocalNotice["tone"]) => void): boolean { + const trimmed = url.trim(); + if (!/^https?:\/\//i.test(trimmed)) return false; + const bridge = (globalThis as { window?: { ade?: { app?: { openExternal?: (url: string) => unknown } } } }).window; + const opener = bridge?.ade?.app?.openExternal; + if (typeof opener === "function") { + try { + opener(trimmed); + notice("Opening link in browser…", "info"); + return true; + } catch { + // Fall through to the platform opener. + } + } + const command = process.platform === "darwin" ? "open" : process.platform === "linux" ? "xdg-open" : null; + if (!command) return false; + spawn(command, [trimmed], { stdio: "ignore", detached: true }).unref(); + notice("Opening link in browser…", "info"); + return true; +} + export function isTerminalSessionResumable(session: ChatTerminalSession | null | undefined): boolean { return Boolean( session @@ -434,6 +622,7 @@ function initialModelState(): AdeCodeModelState { opencodePermissionMode: "edit", droidPermissionMode: "auto-low", cursorModeId: "agent", + cursorAvailableModeIds: [], cursorConfigValues: {}, }; } @@ -538,6 +727,10 @@ function resolveCodexPreset(modelState: AdeCodeModelState): CodexPreset | "custo return "custom"; } +export function codexApprovalSandboxLabel(modelState: Pick): string { + return `${modelState.codexApprovalPolicy} · ${modelState.codexSandbox}`; +} + function codexPresetPatch(preset: CodexPreset): Pick { if (preset === "full-auto") { return { @@ -591,6 +784,13 @@ function cursorModeLabel(modeId: string | null | undefined): string { return CURSOR_MODE_LABELS[normalized] ?? normalized; } +export function cursorModeIdsForState(modelState: Pick): string[] { + const snapshotIds = modelState.cursorAvailableModeIds + .map((modeId) => modeId.trim()) + .filter(Boolean); + return snapshotIds.length ? snapshotIds : [...CURSOR_AVAILABLE_MODE_IDS]; +} + function permissionSummary(modelState: AdeCodeModelState): string { if (modelState.provider === "codex") return resolveCodexPreset(modelState); if (modelState.provider === "claude") { @@ -637,7 +837,7 @@ function permissionOptionsDetail(modelState: AdeCodeModelState): string { if (modelState.provider === "claude") return "default · plan · auto · bypass"; if (modelState.provider === "opencode") return OPENCODE_PERMISSION_OPTIONS.join(" · "); if (modelState.provider === "droid") return DROID_PERMISSION_OPTIONS.join(" · "); - return CURSOR_AVAILABLE_MODE_IDS.map((modeId) => cursorModeLabel(modeId)).join(" · "); + return cursorModeIdsForState(modelState).map((modeId) => cursorModeLabel(modeId)).join(" · "); } function applyProviderPermissionMode(modelState: AdeCodeModelState): Partial { @@ -741,22 +941,6 @@ function formatGoalBannerLine(goal: CodexThreadGoal | null): string | null { return right.length ? `◎ ${objective} ${right.join(" · ")}` : `◎ ${objective}`; } -function formatContextUsage(usage: AgentChatContextUsage | null): string { - if (!usage) return "Context usage is not available for this session yet."; - const total = compactNumber(usage.totalTokens); - const max = compactNumber(usage.maxTokens); - const header = `Context usage: ${total} / ${max} tokens (${usage.percentage.toFixed(0)}%)`; - const rows = usage.categories.map((category) => { - const pct = category.percentage < 10 && category.percentage > 0 - ? category.percentage.toFixed(1) - : category.percentage.toFixed(0); - return `${category.name.padEnd(22)} ${compactNumber(category.tokens).padStart(7)} ${pct.padStart(5)}%`; - }); - return [header, usage.model ? `Model: ${usage.model}` : null, "", ...rows] - .filter((line): line is string => line != null) - .join("\n"); -} - import { subagentSnapshotsFromEvents } from "../../../desktop/src/shared/chatSubagents"; export { subagentSnapshotsFromEvents }; @@ -890,10 +1074,18 @@ export function resolveContextDefault(args: ContextDefaultArgs): RightPaneConten return seedLaneDetails(nav.lane, !args.unavailableLaneIds.has(nav.lane.id)); case "new-chat": return { - kind: "new-chat-setup", + kind: "model-picker", + surface: "new-chat", + query: "", + searchMode: false, + showAll: true, + selection: { kind: "provider", provider: args.provider }, + providerTabKey: null, + focusedIndex: 0, + footerFocus: "apply", + settingsRows: nav.rows, laneId: nav.laneId, laneLabel: nav.laneLabel, - rows: nav.rows, }; case "chat": return { kind: "chat-info", info: nav.info }; @@ -905,10 +1097,18 @@ export function resolveContextDefault(args: ContextDefaultArgs): RightPaneConten && !args.unavailableLaneIds.has(args.newChatSetup.laneId) ) { return { - kind: "new-chat-setup", + kind: "model-picker", + surface: "new-chat", + query: "", + searchMode: false, + showAll: true, + selection: { kind: "provider", provider: args.provider }, + providerTabKey: null, + focusedIndex: 0, + footerFocus: "apply", + settingsRows: args.newChatSetup.rows, laneId: args.newChatSetup.laneId, laneLabel: args.newChatSetup.laneLabel, - rows: args.newChatSetup.rows, }; } if (args.drawerMode === "lanes" && args.highlightedDrawerLane) { @@ -1224,7 +1424,7 @@ function buildSetupRows(args: { if (args.includeApply) { rows.push({ kind: "apply", - label: "Use these settings", + label: "Confirm", value: "ready", detail: "returns focus to the chat composer", }); @@ -1499,6 +1699,43 @@ export function deletePromptForward(value: string, cursor: number): PromptEditRe }; } +// Apply a possibly-coalesced input chunk to the prompt, character by character. +// Ink emits multiple fast keystrokes as ONE chunk and only recognizes a *lone* +// DEL/BS byte as backspace — so a burst like "x" (type then delete) arrives +// as plain text with no backspace flag, and naive insertion would drop the +// Like printableInput but keeps tabs (0x09) and newlines (0x0a), normalizing +// CR/LF to "\n", so a pasted multi-line / tabbed block survives verbatim in the +// feedback body editor. Strips the remaining C0 controls and DEL. +function printableMultilineInput(input: string): string { + return input + .replace(/\r\n/g, "\n") + .replace(/\r/g, "\n") + .replace(/[- -]/g, ""); +} + +// delete. Here we walk the chunk: printable runs are inserted, embedded +// DEL/BS bytes delete backward, all in order. Fixes intermittent "backspace +// does nothing" when typing quickly. +export function applyCoalescedPromptInput(value: string, cursor: number, input: string, preserveMultiline = false): PromptEditResult { + let result: PromptEditResult = { value, cursor: clampPromptCursor(value, cursor) }; + let buffer = ""; + const flush = () => { + const printable = preserveMultiline ? printableMultilineInput(buffer) : printableInput(buffer); + buffer = ""; + if (printable) result = insertPromptText(result.value, result.cursor, printable); + }; + for (const ch of input) { + if (ch === "\u007f" || ch === "\b") { + flush(); + result = deletePromptBackward(result.value, result.cursor, "char"); + } else { + buffer += ch; + } + } + flush(); + return result; +} + function inputBeforeLineBreak(input: string): string | null { const index = input.search(/[\r\n]/); return index === -1 ? null : input.slice(0, index); @@ -1895,12 +2132,6 @@ export function formFieldUsesPromptInput(command: string, fieldName: string): bo return true; } -export function modelPickerSurfaceForSetupPane( - paneKind: "new-chat-setup" | "model-setup", -): "new-chat" | "chat" { - return paneKind === "new-chat-setup" ? "new-chat" : "chat"; -} - export function clampChatScrollOffsetRows(value: number, maxOffset: number): number { const safeMax = Number.isFinite(maxOffset) ? Math.max(0, Math.floor(maxOffset)) : 0; if (Number.isNaN(value)) return 0; @@ -2238,6 +2469,9 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } useTerminalMouseTracking(); const [connection, setConnection] = useState(null); const [mode, setMode] = useState("connecting"); + // True after an attached socket drops unexpectedly, until we re-attach. Drives + // the reconnect probe below and a one-shot "reconnecting…" notice. + const [connectionLost, setConnectionLost] = useState(false); const [lanes, setLanes] = useState([]); const [prByLaneId, setPrByLaneId] = useState>({}); const [diffByLaneId, setDiffByLaneId] = useState>({}); @@ -2246,6 +2480,24 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } const [terminalPreview, setTerminalPreview] = useState(null); const [attachedTerminalId, setAttachedTerminalId] = useState(null); const [terminalLiveChunks, setTerminalLiveChunks] = useState>({}); + // Scrollback position + "↓ N new" counter per Claude PTY session. + const [terminalScrollBySessionId, setTerminalScrollBySessionId] = useState({}); + // Pending pty_data chunks buffered off-React; flushed on a ~16ms timer so the + // write cursor keeps advancing (no per-chunk O(n) array rebuild + slice(-500) + // that would pin the buffer at 500 and freeze TerminalPane's incremental write). + const pendingPtyChunksRef = useRef>(new Map()); + const ptyFlushTimerRef = useRef | null>(null); + // Owns the feedback success auto-close timer so it can be cleared on + // unmount / re-open and never fire against a different right pane. + const feedbackCloseTimerRef = useRef | null>( + null, + ); + // Latest visible text + max scrollback rows reported by the live TerminalPane, + // so keyboard scroll can clamp and copy can grab the visible region. + const terminalViewportMetricsRef = useRef<{ maxScrollable: number; visibleText: string }>({ + maxScrollable: 0, + visibleText: "", + }); const [activeLaneId, setActiveLaneId] = useState(null); const [activeSessionId, setActiveSessionId] = useState(null); const [events, setEvents] = useState([]); @@ -2287,6 +2539,10 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } const [interruptedBySessionId, setInterruptedBySessionId] = useState>({}); const [eventsBySessionId, setEventsBySessionId] = useState>({}); const [multiView, setMultiView] = useState(null); + // "Grid exists" (multiView) is decoupled from "grid is showing" (gridViewActive). + // Creating/opening a non-grid chat hides the grid without destroying it, so it + // stays resumable; navigating back to one of its tiles re-shows it. + const [gridViewActive, setGridViewActive] = useState(false); const [scrollBySessionId, setScrollBySessionId] = useState>({}); const [selectionBySessionId, setSelectionBySessionId] = useState>({}); const [promptHistoryBySessionId, setPromptHistoryBySessionId] = useState>({}); @@ -2298,12 +2554,27 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } const [clearedAt, setClearedAt] = useState(null); const [expandedLineIds, setExpandedLineIds] = useState>(() => new Set()); const [chatScrollOffsetRows, setChatScrollOffsetRows] = useState(0); + const [drawerScrollOffsetRows, setDrawerScrollOffsetRows] = useState(0); + const [rightPaneScrollOffsetRows, setRightPaneScrollOffsetRows] = useState(0); const [inspectedSubagentId, setInspectedSubagentId] = useState(null); const [mentionSuggestions, setMentionSuggestions] = useState([]); const [mentionIndex, setMentionIndex] = useState(0); const [selectedMentions, setSelectedMentions] = useState([]); const [attachmentFocusIndex, setAttachmentFocusIndex] = useState(null); const [slashIndex, setSlashIndex] = useState(0); + const [commandPaletteOpen, setCommandPaletteOpen] = useState(false); + const [commandPaletteQuery, setCommandPaletteQuery] = useState(""); + const [commandPaletteIndex, setCommandPaletteIndex] = useState(0); + // /help command reference: live filter, focused row, and a small in-memory + // recents list (most-recent-first) that floats lately-run commands to the top. + const [helpFilterQuery, setHelpFilterQuery] = useState(""); + const [helpSelectedIndex, setHelpSelectedIndex] = useState(0); + const [helpRecents, setHelpRecents] = useState([]); + const helpRecentsRef = useRef([]); + helpRecentsRef.current = helpRecents; + // Indexed (grouped, keybind-enriched) command reference. Rebuilt only when the + // user's Claude keybinding registry changes, so keybind chips reflect config. + const helpIndexGroups = useMemo(() => buildHelpIndex(BUILTIN_COMMANDS, keybindings), [keybindings]); const [drawerSection, setDrawerSection] = useState<"lanes" | "chats">("lanes"); const [drawerPreviewSessionId, setDrawerPreviewSessionId] = useState(null); const [drawerPreviewEvents, setDrawerPreviewEvents] = useState([]); @@ -2314,7 +2585,7 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } const [selectedDrawerChatAction, setSelectedDrawerChatAction] = useState(null); const [, setFormDiscardArmedState] = useState(false); const [footerControl, setFooterControl] = useState(null); - const [inlineRowFocus, setInlineRowFocus] = useState<{ cell: 'provider' | 'model' | 'reasoning' | 'permission' | 'subagents' | null }>({ cell: null }); + const [inlineRowFocus, setInlineRowFocus] = useState<{ cell: InlineRowCellName | null }>({ cell: null }); const inlineRowFocused = inlineRowFocus.cell !== null; // Cross-surface model picker favorites/recents — authoritative copy lives in ade-cli. const [modelPickerFavorites, setModelPickerFavorites] = useState([]); @@ -2322,9 +2593,18 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } const [modelCatalog, setModelCatalog] = useState(null); const connectionRef = useRef(null); + const connectionLostRef = useRef(false); const activeLaneIdRef = useRef(null); const activeSessionIdRef = useRef(null); const multiViewRef = useRef(null); + const gridViewActiveRef = useRef(false); + // Show/hide the grid without destroying it. Sets the ref synchronously so the + // input/submit paths read the new value immediately (before the re-render). + // Declared early because navigation handlers above the grid helpers use it. + const setGridView = useCallback((active: boolean) => { + gridViewActiveRef.current = active; + setGridViewActive(active); + }, []); const addModeRef = useRef(null); const streamingBySessionIdRef = useRef>({}); const interruptedBySessionIdRef = useRef>({}); @@ -2334,6 +2614,10 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } const hitTestRegistryRef = useRef(createHitTestRegistry()); const hoveredTargetRef = useRef(null); const appHitTargetIdsRef = useRef([]); + // Chat link click-targets are registered in their own effect (keyed on the + // visible rows) so they track scrolling/streaming without rebuilding the + // whole app hit-target set on every coalesced flush. + const chatLinkTargetIdsRef = useRef([]); const previousDimensionsRef = useRef<[number, number]>([columns, rows]); const draftChatActiveRef = useRef(false); const formDiscardArmedRef = useRef(false); @@ -2359,6 +2643,10 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } const eventCountRef = useRef(0); const eventDedupKeysRef = useRef>(new Set()); const eventDedupKeyOrderRef = useRef([]); + // Streaming-event coalescing: buffer incoming envelopes and flush them in one + // batched render on a short timer (see flushPendingChatEvents / scheduleChatFlush). + const pendingChatEnvelopesRef = useRef([]); + const chatFlushTimerRef = useRef | null>(null); const refreshGenerationRef = useRef(0); const chatScrollOffsetRowsRef = useRef(0); const chatScrollMaxOffsetRef = useRef(0); @@ -2472,7 +2760,7 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } }, [project.projectRoot]); const setChatScrollOffset = useCallback((value: number | ((previous: number) => number)) => { - const multiSessionId = focusedSessionIdForMultiView(multiViewRef.current); + const multiSessionId = (gridViewActiveRef.current ? focusedSessionIdForMultiView(multiViewRef.current) : null); if (multiSessionId) { setScrollBySessionId((prev) => { const previous = prev[multiSessionId] ?? 0; @@ -2610,7 +2898,7 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } useEffect(() => { chatMouseSelectionRef.current = chatMouseSelection; - const focusedSessionId = focusedSessionIdForMultiView(multiViewRef.current); + const focusedSessionId = (gridViewActiveRef.current ? focusedSessionIdForMultiView(multiViewRef.current) : null); if (focusedSessionId) { setSelectionBySessionId((prev) => ({ ...prev, [focusedSessionId]: chatMouseSelection })); } @@ -2794,14 +3082,15 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } focusDetails(); }, [focusChat, focusDetails, rightOpen, rightPane.kind, selectFooterControl]); - const cyclePaneFocus = useCallback(() => { + const cyclePaneFocus = useCallback((direction: 1 | -1 = 1) => { const order: PaneFocus[] = [ ...(drawerOpen ? (["drawer"] as PaneFocus[]) : []), "chat", ...(rightOpen ? (["details"] as PaneFocus[]) : []), ]; const currentIndex = order.indexOf(activePaneRef.current); - const nextPane = order[(currentIndex >= 0 ? currentIndex + 1 : 0) % order.length] ?? "chat"; + const startIndex = currentIndex >= 0 ? currentIndex : direction > 0 ? -1 : 0; + const nextPane = order[(startIndex + direction + order.length) % order.length] ?? "chat"; if (nextPane === "drawer") { focusDrawerOnly(); } else if (nextPane === "details") { @@ -2845,7 +3134,7 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } ); const activeTerminalProvider = terminalSessionProvider(activeTerminalSession); const displaySessions = useMemo( - () => [...sessions, ...terminalSessions.map(terminalSessionToChatSummary)] + () => [...sessions.filter((session) => !session.archivedAt), ...terminalSessions.map(terminalSessionToChatSummary)] .sort((left, right) => { const rightMs = Date.parse(right.lastActivityAt ?? right.startedAt); const leftMs = Date.parse(left.lastActivityAt ?? left.startedAt); @@ -2858,7 +3147,7 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } for (const session of displaySessions) out[session.sessionId] = session; return out; }, [displaySessions]); - const tileableSessionIds = useMemo(() => new Set(sessions.map((session) => session.sessionId)), [sessions]); + const tileableSessionIds = useMemo(() => new Set(sessions.filter((session) => !session.archivedAt).map((session) => session.sessionId)), [sessions]); const tileableDisplaySessions = useMemo( () => displaySessions.filter((session) => tileableSessionIds.has(session.sessionId)), [displaySessions, tileableSessionIds], @@ -2891,6 +3180,25 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } useEffect(() => { providerLockedRef.current = providerLocked; }, [providerLocked]); + // Whether the active model supports fast mode / reasoning effort — drives which + // footer cells exist and are focusable (refs let the input handler read current + // values without stale closures). + const footerFastSupported = useMemo(() => { + const descriptor = modelState.modelId ? getModelById(modelState.modelId) : undefined; + const activeModel = models.find((entry) => entry.id === modelState.modelId || entry.modelId === modelState.modelId); + return Boolean(activeModel?.serviceTiers?.some((tier) => tier.trim().toLowerCase() === "fast")) + || modelSupportsFastMode(descriptor); + }, [models, modelState.modelId]); + const footerReasoningSupported = useMemo( + () => modelReasoningEfforts(modelState, models).length > 0, + [modelState, models], + ); + const footerFastSupportedRef = useRef(false); + const footerReasoningSupportedRef = useRef(false); + useEffect(() => { + footerFastSupportedRef.current = footerFastSupported; + footerReasoningSupportedRef.current = footerReasoningSupported; + }, [footerFastSupported, footerReasoningSupported]); const latestFailedLineId = useMemo(() => latestExpandableFailureId(events), [events]); const subagentSnapshots = useMemo(() => subagentSnapshotsFromEvents(events), [events]); const liveAgentCount = useMemo( @@ -2949,14 +3257,15 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } selectFooterControl(null); } }, [footerControl, selectFooterControl, subagentPaneCommandAvailable]); - useEffect(() => { - if (rightPaneKindRef.current !== rightPane.kind) { - if (rightPane.kind === "chat-info") { - setRightSelectionIndex(0); - } - rightPaneKindRef.current = rightPane.kind; - } - }, [rightPane.kind]); + useEffect(() => { + if (rightPaneKindRef.current !== rightPane.kind) { + if (rightPane.kind === "chat-info") { + setRightSelectionIndex(0); + } + setRightPaneScrollOffsetRows(0); + rightPaneKindRef.current = rightPane.kind; + } + }, [rightPane.kind]); useEffect(() => { const content = subagentPaneContentFromRightPane(rightPane); if (!content) return; @@ -3059,10 +3368,14 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } () => sortLanesForStackGraph(lanes), [lanes], ); - const drawerLaneRows = useMemo( - () => orderedDrawerLanes.slice(0, visibleDrawerLaneCount(chatRowBudget, orderedDrawerLanes.length)), - [chatRowBudget, orderedDrawerLanes], - ); + const drawerLaneRows = useMemo( + () => { + const count = visibleDrawerLaneCount(chatRowBudget, orderedDrawerLanes.length); + const start = Math.max(0, Math.min(drawerScrollOffsetRows, Math.max(0, orderedDrawerLanes.length - count))); + return orderedDrawerLanes.slice(start, start + count); + }, + [chatRowBudget, drawerScrollOffsetRows, orderedDrawerLanes], + ); const diffLaneIdsKey = useMemo( () => lanes.filter((lane) => !lane.archivedAt).map((lane) => lane.id).sort().join("\n"), [lanes], @@ -3121,6 +3434,7 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } newChatPreviewLaneIdRef.current = laneId; draftChatActiveRef.current = true; setDraftChatMode(true); + setGridView(false); selectActiveSessionId(null); clearLoadedTranscript(); return; @@ -3129,12 +3443,25 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } if (!selection.session) { draftChatActiveRef.current = false; setDraftChatMode(false); + setGridView(false); selectActiveSessionId(null); clearLoadedTranscript(); return; } const session = selection.session; + // If the selected chat is one of the (possibly hidden) grid's tiles, re-enter + // the grid focused on it; otherwise show it as a normal single chat and leave + // the grid resumable in the background. + const gridTileIndex = multiViewRef.current?.tiles.findIndex((tile) => tile.sessionId === session.sessionId) ?? -1; + if (gridTileIndex >= 0) { + draftChatActiveRef.current = false; + setDraftChatMode(false); + setMultiView((prev) => (prev ? { ...prev, focusedIndex: gridTileIndex } : prev)); + setGridView(true); + return; + } + setGridView(false); newChatPreviewLaneIdRef.current = null; draftChatActiveRef.current = false; setDraftChatMode(false); @@ -3198,7 +3525,7 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } // Best-effort preview hydration; leave prior content on transient errors. } })(); - }, [selectActiveLaneId, selectActiveSessionId, setDraftChatMode, setSessionInterrupted, setSessionStreaming, setStreaming]); + }, [selectActiveLaneId, selectActiveSessionId, setDraftChatMode, setGridView, setSessionInterrupted, setSessionStreaming, setStreaming]); const enterDrawerChatListForLane = useCallback((lane: LaneSummary) => { const laneSessions = displaySessions.filter((entry) => entry.laneId === lane.id); const visibleSessions = laneSessions.slice(0, visibleDrawerChatCount(laneSessions.length)); @@ -3219,11 +3546,42 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } const activeMentionRange = useMemo(() => ( activePane === "chat" ? activeMention(prompt) : null ), [activePane, prompt]); - const slashRows = useMemo(() => ( - activePane === "chat" && prompt.startsWith("/") - ? paletteCommands(prompt, slashCommands, { provider: activeCommandProvider }) - : [] - ), [activeCommandProvider, activePane, prompt, slashCommands]); + const slashRows = useMemo(() => ( + activePane === "chat" && prompt.startsWith("/") + ? paletteCommands(prompt, slashCommands, { provider: activeCommandProvider }) + : [] + ), [activeCommandProvider, activePane, prompt, slashCommands]); + const commandPaletteItems = useMemo(() => { + if (!commandPaletteOpen) return []; + const commandItems = paletteCommands("", slashCommands, { provider: activeCommandProvider }).map((command) => ({ + key: `command:${command.name}`, + kind: "command" as const, + label: command.argumentHint ? `${command.name} ${command.argumentHint}` : command.name, + detail: command.description, + })); + const laneItems = lanes.map((lane) => ({ + key: `lane:${lane.id}`, + kind: "lane" as const, + label: lane.name, + detail: lane.branchRef ?? lane.id, + })); + const chatItems = displaySessions.map((session) => ({ + key: `chat:${session.sessionId}`, + kind: "chat" as const, + label: session.title ?? session.sessionId, + detail: `${lanes.find((lane) => lane.id === session.laneId)?.name ?? session.laneId} · ${session.provider}`, + })); + return [...commandItems, ...laneItems, ...chatItems] + .map((item) => ({ item, score: paletteMatchScore(item, commandPaletteQuery) })) + .filter((entry): entry is { item: CommandPaletteItem; score: number } => entry.score != null) + .sort((a, b) => a.score - b.score || a.item.label.localeCompare(b.item.label)) + .map((entry) => entry.item) + .slice(0, 80); + }, [activeCommandProvider, commandPaletteOpen, commandPaletteQuery, displaySessions, lanes, slashCommands]); + useEffect(() => { + if (!commandPaletteOpen) return; + setCommandPaletteIndex((index) => Math.max(0, Math.min(index, Math.max(0, commandPaletteItems.length - 1)))); + }, [commandPaletteItems.length, commandPaletteOpen]); const pendingApproval = useMemo(() => latestPendingApproval(events), [events]); const pendingSteers = useMemo(() => derivePendingSteers(events), [events]); const activeFormField = rightPane.kind === "form" @@ -3239,6 +3597,19 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } : events ), [activeSession, events, selectedAgentSnapshot]); const displayNotices = useMemo(() => (selectedAgentSnapshot ? [] : notices), [notices, selectedAgentSnapshot]); + // Aggregate the transcript exactly once per render and thread the result into + // every consumer (scroll math, selection rows, selectable text, and ChatView + // itself). Previously each of those re-walked the full event list, so a single + // token caused ~4 full-transcript passes. + const displayBlocks = useMemo( + () => aggregateChatBlocks({ + events: displayEvents, + notices: displayNotices, + activeSession, + expandedLineIds, + }), + [activeSession, displayEvents, displayNotices, expandedLineIds], + ); const displayStreaming = selectedAgentSnapshot ? selectedAgentSnapshot.status === "running" : streaming; const displayInterrupted = selectedAgentSnapshot ? false : interrupted && !displayStreaming; useEffect(() => { @@ -3253,6 +3624,7 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } || liveAgentCount > 0; const showChatWorkingIndicator = modelState.provider !== "claude" && activeSession?.provider !== "claude"; const chatScrollMaxOffset = useMemo(() => computeChatScrollMaxOffset({ + blocks: displayBlocks, events: displayEvents, notices: displayNotices, activeSession, @@ -3262,7 +3634,7 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } interrupted: displayInterrupted, showWorkingIndicator: showChatWorkingIndicator, width: chatWrapWidth, - }), [activeSession, chatRowBudget, chatWrapWidth, displayEvents, displayInterrupted, displayNotices, displayStreaming, expandedLineIds, showChatWorkingIndicator]); + }), [activeSession, chatRowBudget, chatWrapWidth, displayBlocks, displayEvents, displayInterrupted, displayNotices, displayStreaming, expandedLineIds, showChatWorkingIndicator]); chatScrollMaxOffsetRef.current = chatScrollMaxOffset; const effectiveChatScrollOffsetRows = clampChatScrollOffsetRows(chatScrollOffsetRows, chatScrollMaxOffset); chatScrollOffsetRowsRef.current = effectiveChatScrollOffsetRows; @@ -3276,6 +3648,7 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } ? Math.max(0, displayEvents.length - lastSeenAtBottomEventCountRef.current) : 0; const visibleChatSelectionRows = useMemo(() => renderChatVisibleSelectionRows({ + blocks: displayBlocks, events: displayEvents, notices: displayNotices, activeSession, @@ -3291,6 +3664,7 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } activeSession, chatRowBudget, chatWrapWidth, + displayBlocks, displayEvents, displayInterrupted, displayNotices, @@ -3301,6 +3675,7 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } unseenMessageCount, ]); const selectableChatRowTexts = useMemo(() => renderChatSelectableRowTexts({ + blocks: displayBlocks, events: displayEvents, notices: displayNotices, activeSession, @@ -3312,6 +3687,7 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } }), [ activeSession, chatWrapWidth, + displayBlocks, displayEvents, displayInterrupted, displayNotices, @@ -3406,6 +3782,13 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } attachedTerminalIdRef.current = attachedTerminalId; }, [attachedTerminalId]); + // Mirror terminal scroll state into a ref so the pty subscription (bound only + // on reconnect) can read "is this session scrolled up?" without re-binding. + const terminalScrollBySessionIdRef = useRef(terminalScrollBySessionId); + useEffect(() => { + terminalScrollBySessionIdRef.current = terminalScrollBySessionId; + }, [terminalScrollBySessionId]); + useEffect(() => { if (!connection || !activeTerminalSession) return; const cols = clampTerminalPaneCols(claudeTerminalControlActive ? terminalPaneWidth - 2 : terminalPaneWidth); @@ -3631,7 +4014,7 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } if (prev.kind === "chat-info" && next.kind === "chat-info") { return next; } - if (prev.kind === "new-chat-setup" && next.kind === "new-chat-setup") { + if (prev.kind === "model-picker" && prev.surface === "new-chat" && next.kind === "model-picker" && next.surface === "new-chat") { return next; } // Avoid stomping on lane-details that has been hydrated with git data; @@ -3668,22 +4051,22 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } ]); useEffect(() => { - if (rightPane.kind === "new-chat-setup") { + if (rightPane.kind === "model-picker" && rightPane.surface === "new-chat") { setRightPane((prev) => { - if (prev.kind !== "new-chat-setup") return prev; + if (prev.kind !== "model-picker" || prev.surface !== "new-chat") return prev; if (drawerNavTarget?.kind === "new-chat") { return { ...prev, laneId: drawerNavTarget.laneId, laneLabel: drawerNavTarget.laneLabel, - rows: drawerNavTarget.rows, + settingsRows: drawerNavTarget.rows, }; } return { ...prev, laneId: activeLaneId ?? prev.laneId, laneLabel: activeLane?.name ?? prev.laneLabel, - rows: newChatSetupRows, + settingsRows: newChatSetupRows, }; }); } else if (rightPane.kind === "lane-details") { @@ -3693,9 +4076,9 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } chats: computeLaneChatCounts(displaySessions, prev.lane.id), } : prev); - } else if (rightPane.kind === "model-setup") { - setRightPane((prev) => prev.kind === "model-setup" - ? { ...prev, rows: providerLocked ? modelPickerRows : modelSetupRows } + } else if (rightPane.kind === "model-picker" && rightPane.surface === "chat") { + setRightPane((prev) => prev.kind === "model-picker" && prev.surface === "chat" + ? { ...prev, settingsRows: providerLocked ? modelPickerRows : modelSetupRows } : prev); } }, [activeLane?.name, activeLaneId, displaySessions, drawerNavTarget, modelPickerRows, modelSetupRows, newChatSetupRows, providerLocked, rightPane.kind]); @@ -4000,6 +4383,33 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } ]); }, []); + const activateLaneWithLastChat = useCallback((lane: LaneSummary, options: { notify?: boolean } = {}) => { + const laneSessions = displaySessions.filter((entry) => entry.laneId === lane.id); + const lastSessionId = lastChatByLaneRef.current.get(lane.id); + const session = + laneSessions.find((entry) => entry.sessionId === lastSessionId) + ?? newestSession(laneSessions); + const action: DrawerChatAction | null = session ? null : "new-chat"; + selectActiveLaneId(lane.id); + setDrawerLaneId(lane.id); + setSelectedDrawerLaneId(lane.id); + setSelectedDrawerLaneAction(null); + setSelectedDrawerChatId(session?.sessionId ?? null); + setSelectedDrawerChatAction(action); + applyDrawerChatSelection({ session: session ?? null, action }); + if (options.notify) addNotice(`Switched to lane ${lane.name}.`, "success"); + }, [addNotice, applyDrawerChatSelection, displaySessions, selectActiveLaneId]); + + const cycleActiveLane = useCallback((direction: 1 | -1) => { + if (!orderedDrawerLanes.length) return; + const currentLaneId = activeLaneIdRef.current; + const currentIndex = currentLaneId ? orderedDrawerLanes.findIndex((lane) => lane.id === currentLaneId) : -1; + const startIndex = currentIndex >= 0 ? currentIndex : direction > 0 ? -1 : 0; + const lane = orderedDrawerLanes[(startIndex + direction + orderedDrawerLanes.length) % orderedDrawerLanes.length]; + if (!lane) return; + activateLaneWithLastChat(lane, { notify: true }); + }, [activateLaneWithLastChat, orderedDrawerLanes]); + const flashMultiViewNotice = useCallback((text: string) => { setMultiViewNotice(text); setTimeout(() => { @@ -4043,8 +4453,9 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } const focusedIndex = Math.max(0, Math.min(index, prev.tiles.length - 1)); return focusedIndex === prev.focusedIndex ? prev : { ...prev, focusedIndex }; }); + setGridView(true); setPaneFocus("chat"); - }, [setPaneFocus]); + }, [setGridView, setPaneFocus]); const removeMultiViewTile = useCallback((index: number) => { const prev = multiViewRef.current; @@ -4053,11 +4464,13 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } const survivor = tiles.length < 2 ? tiles[0] ?? null : null; setMultiView(tiles.length < 2 ? null : { tiles, focusedIndex: Math.min(prev.focusedIndex, tiles.length - 1) }); if (survivor) { + // Grid collapsed to one chat → leave grid view into that single chat. + setGridView(false); selectActiveLaneId(survivor.laneId); selectActiveSessionId(survivor.sessionId); } setPaneFocus("chat"); - }, [selectActiveLaneId, selectActiveSessionId, setPaneFocus]); + }, [selectActiveLaneId, selectActiveSessionId, setGridView, setPaneFocus]); const isTileableChatSessionId = useCallback((sessionId: string | null | undefined) => { if (!sessionId) return false; @@ -4079,18 +4492,21 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } const existingIndex = tiles.findIndex((tile) => tile.sessionId === sessionId); if (existingIndex >= 0) { setMultiView({ tiles, focusedIndex: existingIndex }); + setGridView(true); setAddMode(null); setPaneFocus("chat"); return; } if (tiles.length >= 6) { flashMultiViewNotice("Multi-view full (max 6)"); + setGridView(true); setAddMode(null); setPaneFocus("chat"); return; } if (!tiles.length) { setMultiView(null); + setGridView(false); selectActiveLaneId(laneId); selectActiveSessionId(sessionId); } else { @@ -4098,11 +4514,13 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } tiles: [...tiles, { sessionId, laneId }], focusedIndex: Math.max(focusedIndex, tiles.length), }); + setGridView(true); } } else { const currentSessionId = activeSessionIdRef.current; const currentLaneId = activeLaneIdRef.current; if (!currentSessionId || !currentLaneId || !isTileableChatSessionId(currentSessionId)) { + setGridView(false); selectActiveLaneId(laneId); selectActiveSessionId(sessionId); } else if (currentSessionId !== sessionId) { @@ -4113,6 +4531,7 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } ], focusedIndex: 1, }); + setGridView(true); } } void hydrateTileHistory(sessionId).catch((err) => addNotice(err instanceof Error ? err.message : String(err), "error")); @@ -4122,7 +4541,7 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } } setAddMode(null); setPaneFocus("chat"); - }, [addNotice, flashMultiViewNotice, hydrateTileHistory, isTileableChatSessionId, selectActiveLaneId, selectActiveSessionId, setPaneFocus]); + }, [addNotice, flashMultiViewNotice, hydrateTileHistory, isTileableChatSessionId, selectActiveLaneId, selectActiveSessionId, setGridView, setPaneFocus]); const startAddMode = useCallback(() => { const firstLane = orderedDrawerLanes[0] ?? null; @@ -4176,8 +4595,44 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } addTileToGrid(current.cursorChatId, current.cursorLaneId); }, [addNotice, addTileToGrid]); + // The grid toggle (Ctrl+G / footer button). Behavior depends on context: + // - in the grid -> open the "add chat" picker + // - on a chat that's a tile of a resumable grid -> re-enter that grid + // - on a non-grid chat with a resumable grid -> add this chat to the grid + // (errors if full) + // - no grid yet -> open the add-mode picker to build one + const toggleGridView = useCallback(() => { + if (gridViewActiveRef.current) { + startAddMode(); + return; + } + const grid = multiViewRef.current; + if (grid) { + const sessionId = activeSessionIdRef.current; + const tileIndex = sessionId ? grid.tiles.findIndex((tile) => tile.sessionId === sessionId) : -1; + if (tileIndex >= 0) { + setMultiView({ ...grid, focusedIndex: tileIndex }); + setGridView(true); + setPaneFocus("chat"); + return; + } + const laneId = activeLaneIdRef.current; + if (sessionId && laneId && isTileableChatSessionId(sessionId)) { + addTileToGrid(sessionId, laneId); + return; + } + // Current view isn't a tileable chat (draft/terminal) — just resume the grid. + setGridView(true); + setPaneFocus("chat"); + return; + } + startAddMode(); + }, [addTileToGrid, isTileableChatSessionId, setGridView, setPaneFocus, startAddMode]); + useEffect(() => { - if (!multiView) return; + // Only the *shown* grid drives the active lane/session. A dormant (hidden but + // resumable) grid must not hijack the single chat the user is viewing. + if (!multiView || !gridViewActive) return; const tile = multiView.tiles[multiView.focusedIndex] ?? multiView.tiles[0] ?? null; if (!tile) return; if (tile.laneId !== activeLaneIdRef.current) { @@ -4193,7 +4648,7 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } if (!eventsBySessionIdRef.current[tile.sessionId]) { void hydrateTileHistory(tile.sessionId).catch(() => undefined); } - }, [hydrateTileHistory, multiView, selectActiveLaneId, selectActiveSessionId]); + }, [gridViewActive, hydrateTileHistory, multiView, selectActiveLaneId, selectActiveSessionId]); useEffect(() => { if (!connection || !attachedTerminalId) return; @@ -4345,6 +4800,12 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } }, []); const openForm = useCallback((content: Extract) => { + // Cancel any pending feedback-success auto-close so a stale timer can't fire + // against this freshly opened pane. + if (feedbackCloseTimerRef.current) { + clearTimeout(feedbackCloseTimerRef.current); + feedbackCloseTimerRef.current = null; + } const previousPane = activePaneRef.current; stashActiveInput(); if (previousPane !== "details") { @@ -4398,8 +4859,8 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } }); }, [activeLane, focusDetails, lanes, openForm]); - const openLaneDeleteForm = useCallback(() => { - const laneId = activeLaneIdRef.current; + const openLaneDeleteForm = useCallback((laneIdArg?: string) => { + const laneId = laneIdArg ?? activeLaneIdRef.current; const lane = lanes.find((entry) => entry.id === laneId) ?? activeLane; if (!laneId || !lane) { setRightPane({ kind: "details", title: "Delete lane", body: "No active lane is selected." }); @@ -4411,52 +4872,125 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } focusDetails(); return; } + // Fetch the delete-risk first so the confirmation shows what would be lost + // (unpushed commits, dirty tree, running processes) rather than a blind prompt. + void (async () => { + let description: string | undefined; + const conn = connectionRef.current; + if (conn) { + try { + const risk = await conn.action("lane", "getDeleteRisk", { laneId }); + description = formatLaneDeleteRisk(risk); + } catch { + // Risk is advisory; fall back to the plain form if it can't be fetched. + } + } + openForm({ + kind: "form", + title: "Delete lane", + command: "lane-delete", + laneId, + description, + laneDelete: { + laneId, + laneName: lane.name, + branchRef: lane.branchRef, + dirty: lane.status?.dirty === true, + }, + fields: [ + { name: "scope", label: "Scope", initialValue: "worktree" }, + { name: "remoteName", label: "Remote name", placeholder: "origin", initialValue: "origin" }, + { name: "force", label: "Force delete", initialValue: "no" }, + { name: "confirm", label: "Type lane name", required: true, placeholder: lane.name }, + ], + }); + })(); + }, [activeLane, focusDetails, lanes, openForm]); + + const openLaneRenameForm = useCallback((laneIdArg?: string) => { + const laneId = laneIdArg ?? activeLaneIdRef.current; + const lane = lanes.find((entry) => entry.id === laneId) ?? activeLane; + if (!laneId || !lane) { + setRightPane({ kind: "details", title: "Rename lane", body: "No lane is selected." }); + focusDetails(); + return; + } + if (lane.laneType === "primary") { + setRightPane({ kind: "details", title: "Rename lane", body: "The primary lane can't be renamed." }); + focusDetails(); + return; + } openForm({ kind: "form", - title: "Delete lane", - command: "lane-delete", + title: "Rename lane", + command: "lane-rename", laneId, - laneDelete: { - laneId, - laneName: lane.name, - branchRef: lane.branchRef, - dirty: lane.status?.dirty === true, - }, + fields: [{ name: "name", label: "Lane name", required: true, initialValue: lane.name }], + }); + }, [activeLane, focusDetails, lanes, openForm]); + + const openChatDeleteForm = useCallback((sessionIdArg?: string) => { + const targetId = sessionIdArg ?? activeSessionIdRef.current; + const session = sessions.find((entry) => entry.sessionId === targetId) ?? activeSession; + if (!targetId || !session || session.sessionId !== targetId) { + setRightPane({ kind: "details", title: "Delete chat", body: "No runtime-backed chat is selected." }); + focusDetails(); + return; + } + const title = session.title ?? session.goal ?? session.sessionId; + openForm({ + kind: "form", + title: "Delete chat", + command: "chat-delete", + sessionId: targetId, + chatDelete: { sessionId: targetId, title }, + description: "This removes the chat session and its transcript from ADE.", fields: [ - { - name: "scope", - label: "Scope", - initialValue: "worktree", - }, - { - name: "remoteName", - label: "Remote name", - placeholder: "origin", - initialValue: "origin", - }, - { - name: "force", - label: "Force delete", - initialValue: "no", - }, - { - name: "confirm", - label: "Type lane name", - required: true, - placeholder: lane.name, - }, + { name: "confirm", label: "Type chat title", required: true, placeholder: title }, ], }); - }, [activeLane, focusDetails, lanes, openForm]); + }, [activeSession, focusDetails, openForm, sessions]); + + const openChatRenameForm = useCallback((sessionIdArg?: string) => { + const targetId = sessionIdArg ?? activeSessionIdRef.current; + const session = sessions.find((entry) => entry.sessionId === targetId) ?? activeSession; + if (!targetId || !session || session.sessionId !== targetId) { + setRightPane({ kind: "details", title: "Rename chat", body: "No runtime-backed chat is selected." }); + focusDetails(); + return; + } + openForm({ + kind: "form", + title: "Rename chat", + command: "rename", + sessionId: targetId, + fields: [ + { name: "title", label: "Title", required: true, initialValue: session.title ?? "" }, + ], + }); + }, [activeSession, focusDetails, openForm, sessions]); const openFeedbackForm = useCallback(() => { + // Seed the multiline feedback form's serializable state (feedbackForm.ts) + // onto content.feedback: context = active provider/model + lane + last error, + // with the context footer toggled ON by default so reports are actionable. + const lastError = [...notices].reverse().find((entry) => entry.tone === "error")?.text ?? null; openForm({ kind: "form", title: "Feedback", command: "feedback", fields: feedbackFormFields(buildFeedbackEnvironment(project, activeLane ?? null)), + feedback: { + provider: modelState.provider ?? null, + model: modelState.modelId ?? null, + lane: activeLane?.name ?? null, + lastError, + type: "bug", + showContext: true, + body: "", + }, }); - }, [activeLane, openForm, project]); + }, [activeLane, modelState.modelId, modelState.provider, notices, openForm, project]); const openNewChatSetup = useCallback((title?: string | null) => { const laneId = activeLaneIdRef.current; @@ -4487,6 +5021,9 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } paneBeforeDetailsRef.current = previousPane; } setDraftChatMode(true); + // Creating a new chat leaves the grid (shown as a single draft chat) but + // keeps the grid resumable — navigating back to a tile re-enters it. + setGridView(false); selectActiveSessionId(null); setAttachedTerminalId(null); // New-chat-setup is part of the context default; let the resolver drive it. @@ -4501,36 +5038,24 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } setRightSelectionIndex(defaultSetupSelectionIndex(newChatSetupRows)); setFormDiscardArmed(false); setRightPane({ - kind: "new-chat-setup", + kind: "model-picker", + surface: "new-chat", + query: "", + searchMode: false, + showAll: true, + selection: { kind: "provider", provider: modelState.provider }, + providerTabKey: null, + focusedIndex: 0, + footerFocus: "apply", + settingsRows: newChatSetupRows, laneId, laneLabel: lane.name, - rows: newChatSetupRows, }); setRightOpen(true); setPaneFocus("details"); void refreshAiSetupStatus().catch(() => undefined); void loadProviderModels(modelState.provider, { applyDefault: false }).catch(() => undefined); - }, [activeLane, addNotice, focusDetails, lanes, loadProviderModels, modelState.provider, newChatSetupRows, refreshAiSetupStatus, selectActiveSessionId, setDraftChatMode, setPaneFocus, stashActiveInput]); - - // /model opens the right-pane model picker. Provider stays editable on a fresh - // chat; once the thread has user messages the provider row is locked to the - // active chat family. - const openModelRow = useCallback((options: { forceRefresh?: boolean; focusKind?: SetupPaneRowKind } = {}) => { - const rows = providerLockedRef.current ? modelPickerRows : modelSetupRows; - userDismissedRightPaneRef.current = false; - lastUserOpenedPaneRef.current = "details"; - setRightSelectionIndex(setupSelectionIndexForKind(rows, options.focusKind)); - setRightPane({ kind: "model-setup", rows }); - setRightOpen(true); - focusDetails(); - void refreshAiSetupStatus({ force: options.forceRefresh === true }).catch((err) => { - addNotice(err instanceof Error ? err.message : String(err), "error"); - }); - void loadProviderModels( - (activeSessionRef.current?.provider ?? modelState.provider) as AdeCodeProvider, - { applyDefault: false }, - ).catch(() => undefined); - }, [addNotice, focusDetails, loadProviderModels, modelPickerRows, modelSetupRows, modelState.provider, refreshAiSetupStatus]); + }, [activeLane, addNotice, focusDetails, lanes, loadProviderModels, modelState.provider, newChatSetupRows, refreshAiSetupStatus, selectActiveSessionId, setDraftChatMode, setGridView, setPaneFocus, stashActiveInput]); // Hydrate favorites/recents from the ade-cli RPC once the connection is up. useEffect(() => { @@ -4559,7 +5084,7 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } // via /model or new-chat. Reuses the same data the inline row uses (models) // plus favorites/recents sourced from ade-cli for cross-surface sync. const openModelPicker = useCallback( - (options: { surface?: "chat" | "new-chat" } = {}) => { + (options: { surface?: "chat" | "new-chat"; forceRefresh?: boolean; focusKind?: SetupPaneRowKind } = {}) => { void refreshModelCatalog(); const surface = options.surface ?? "chat"; // Build a starter selection from current activeModelId/recents so the @@ -4569,13 +5094,21 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } models, catalog: modelCatalogRef.current ?? modelCatalog, favorites: modelPickerFavorites, - recents: modelPickerRecents, + recents: modelPickerRecents, activeModelId: modelState.modelId, + activeReasoningEffort: modelState.reasoningEffort, + aiStatus, + showAll: true, query: "", selection: { kind: "provider", provider }, providerTabKey: null, focusedIndex: 0, - searchMode: false, + searchMode: false, + settingsRows: surface === "new-chat" ? newChatSetupRows : (providerLockedRef.current ? modelPickerRows : modelSetupRows), + footerFocus: options.focusKind ?? null, + laneLabel: surface === "new-chat" + ? (lanes.find((entry) => entry.id === activeLaneIdRef.current)?.name ?? activeLane?.name ?? null) + : null, }); const selection = defaultSelectionFor( modelState.modelId, @@ -4586,25 +5119,40 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } kind: "model-picker", surface, query: "", + showAll: true, searchMode: false, selection, providerTabKey: null, focusedIndex: 0, + footerFocus: options.focusKind ?? null, + settingsRows: surface === "new-chat" ? newChatSetupRows : (providerLockedRef.current ? modelPickerRows : modelSetupRows), + ...(surface === "new-chat" + ? { + laneId: activeLaneIdRef.current, + laneLabel: lanes.find((entry) => entry.id === activeLaneIdRef.current)?.name ?? activeLane?.name ?? null, + } + : {}), }); setRightOpen(true); setPaneFocus("details"); lastUserOpenedPaneRef.current = "model-picker"; - void refreshAiSetupStatus().catch(() => undefined); + void refreshAiSetupStatus({ force: options.forceRefresh === true }).catch(() => undefined); void loadProviderModels(provider, { applyDefault: false }).catch(() => undefined); }, [ + activeLane?.name, + aiStatus, + lanes, loadProviderModels, + modelPickerRows, modelPickerFavorites, modelPickerRecents, + modelSetupRows, modelState.modelId, - modelState.provider, - models, - modelCatalog, + modelState.provider, + models, + newChatSetupRows, + modelCatalog, refreshAiSetupStatus, refreshModelCatalog, setPaneFocus, @@ -4881,13 +5429,19 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } if (launchToNewChatPreview) { initialNewChatPreviewRef.current = false; newChatPreviewLaneIdRef.current = nextLaneId; - setDraftChatMode(false); + // Start as a true draft new chat so the center always shows the splash and + // NO existing chat is resolved/hydrated. Draft mode cascades through the + // drawer-selection + preview effects (they keep "new-chat", skip the + // history preview), so opening the drawer below can't activate a chat. + setDraftChatMode(true); setDrawerSection("chats"); setDrawerLaneId(nextLaneId); setSelectedDrawerLaneId(nextLaneId); setSelectedDrawerLaneAction(null); setSelectedDrawerChatId(null); setSelectedDrawerChatAction(nextLaneId ? "new-chat" : null); + // Open BOTH side panes so `ade code` launches with the full layout. + setDrawerOpen(true); setRightOpen(true); } if (nextTerminalSession && nextSessionId) { @@ -4937,6 +5491,7 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } opencodePermissionMode: configSession.opencodePermissionMode ?? prev.opencodePermissionMode, droidPermissionMode: configSession.droidPermissionMode ?? prev.droidPermissionMode, cursorModeId: configSession.cursorModeId ?? configSession.cursorModeSnapshot?.currentModeId ?? prev.cursorModeId, + cursorAvailableModeIds: configSession.cursorModeSnapshot?.availableModeIds ?? prev.cursorAvailableModeIds, cursorConfigValues: configSession.cursorConfigValues ?? prev.cursorConfigValues, })); } @@ -4944,6 +5499,143 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } } }, [clearedAt, drawerLaneId, loadProviderModels, modelState.provider, project, selectActiveLaneId, selectActiveSessionId, selectedDrawerChatAction, setDraftChatMode, setSessionInterrupted, setSessionStreaming, setStreaming]); + const renameLane = useCallback(async (laneIdArg: string | null, name: string) => { + const conn = connectionRef.current; + const targetId = laneIdArg ?? activeLaneIdRef.current; + if (!conn || !targetId) { + addNotice("No lane is selected.", "error"); + return; + } + const trimmed = name.trim(); + if (!trimmed) { + addNotice("Lane name can't be empty.", "error"); + return; + } + try { + await conn.action("lane", "rename", { laneId: targetId, name: trimmed }); + addNotice(`Renamed lane to "${trimmed}".`, "success"); + await refreshState(); + } catch (err) { + addNotice(err instanceof Error ? err.message : String(err), "error"); + } + }, [addNotice, refreshState]); + + const archiveLane = useCallback(async (laneIdArg?: string) => { + const conn = connectionRef.current; + const targetId = laneIdArg ?? activeLaneIdRef.current; + const lane = lanes.find((entry) => entry.id === targetId) ?? null; + if (!conn || !targetId || !lane) { + addNotice("No lane is selected.", "error"); + return; + } + if (lane.laneType === "primary") { + addNotice("The primary lane can't be archived.", "error"); + return; + } + try { + await conn.action("lane", "archive", { laneId: targetId }); + addNotice(`Archived lane ${lane.name}.`, "success"); + // If we archived the lane we were on, fall back to another live lane. + if (activeLaneIdRef.current === targetId) { + const fallback = lanes.find((entry) => entry.id !== targetId && !entry.archivedAt) ?? null; + selectActiveLaneId(fallback?.id ?? null); + } + await refreshState(); + } catch (err) { + addNotice(err instanceof Error ? err.message : String(err), "error"); + } + }, [addNotice, lanes, refreshState, selectActiveLaneId]); + + const unarchiveLane = useCallback(async (query: string) => { + const conn = connectionRef.current; + if (!conn) return; + const term = query.trim(); + if (!term) { + addNotice("Usage: /lane unarchive ", "error"); + return; + } + try { + const archived = (await listLanes(conn, { includeArchived: true })).filter((entry) => entry.archivedAt); + const lower = term.toLowerCase(); + const match = archived.find((entry) => entry.id === term) + ?? archived.find((entry) => entry.name.toLowerCase() === lower) + ?? archived.find((entry) => entry.name.toLowerCase().includes(lower)); + if (!match) { + addNotice(`No archived lane matched "${term}".`, "error"); + return; + } + await conn.action("lane", "unarchive", { laneId: match.id }); + addNotice(`Unarchived lane ${match.name}.`, "success"); + await refreshState(); + selectActiveLaneId(match.id); + } catch (err) { + addNotice(err instanceof Error ? err.message : String(err), "error"); + } + }, [addNotice, refreshState, selectActiveLaneId]); + + const selectFallbackChatAfterRemoval = useCallback((removedSession: AgentChatSessionSummary) => { + const fallback = displaySessions.find((entry) => ( + entry.laneId === removedSession.laneId + && entry.sessionId !== removedSession.sessionId + && !entry.archivedAt + )) ?? null; + setSelectedDrawerChatId(fallback?.sessionId ?? null); + setSelectedDrawerChatAction(fallback ? null : "new-chat"); + applyDrawerChatSelection({ session: fallback, action: fallback ? null : "new-chat" }); + }, [applyDrawerChatSelection, displaySessions]); + + const archiveChat = useCallback(async (sessionIdArg?: string) => { + const conn = connectionRef.current; + const targetId = sessionIdArg ?? activeSessionIdRef.current; + const session = sessions.find((entry) => entry.sessionId === targetId) ?? null; + if (!conn || !targetId || !session) { + addNotice("No runtime-backed chat is selected.", "error"); + return; + } + try { + await archiveChatSession(conn, targetId); + if (activeSessionIdRef.current === targetId) { + selectFallbackChatAfterRemoval(session); + } + addNotice(`Archived chat ${session.title ?? session.sessionId}.`, "success"); + await refreshState(); + } catch (err) { + addNotice(err instanceof Error ? err.message : String(err), "error"); + } + }, [addNotice, refreshState, selectFallbackChatAfterRemoval, sessions]); + + const unarchiveChat = useCallback(async (query: string) => { + const conn = connectionRef.current; + if (!conn) return; + const term = query.trim(); + if (!term) { + addNotice("Usage: /chat unarchive ", "error"); + return; + } + try { + const archived = (await listChatSessions(conn, null, { includeArchived: true })).filter((entry) => entry.archivedAt); + const lower = term.toLowerCase(); + const match = archived.find((entry) => entry.sessionId === term) + ?? archived.find((entry) => (entry.title ?? "").toLowerCase() === lower) + ?? archived.find((entry) => (entry.title ?? "").toLowerCase().includes(lower) || entry.sessionId.toLowerCase().includes(lower)); + if (!match) { + addNotice(`No archived chat matched "${term}".`, "error"); + return; + } + await unarchiveChatSession(conn, match.sessionId); + addNotice(`Unarchived chat ${match.title ?? match.sessionId}.`, "success"); + await refreshState(); + selectActiveLaneId(match.laneId); + setDrawerLaneId(match.laneId); + setSelectedDrawerLaneId(match.laneId); + setSelectedDrawerChatId(match.sessionId); + setSelectedDrawerChatAction(null); + applyDrawerChatSelection({ session: match, action: null }); + } catch (err) { + addNotice(err instanceof Error ? err.message : String(err), "error"); + } + }, [addNotice, applyDrawerChatSelection, refreshState, selectActiveLaneId]); + const commitModelStateToSession = useCallback(async (nextState: AdeCodeModelState) => { const conn = connectionRef.current; const sessionId = activeSessionIdRef.current; @@ -5084,6 +5776,10 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } clearTimeout(pendingModelCommitTimerRef.current); pendingModelCommitTimerRef.current = null; } + if (feedbackCloseTimerRef.current) { + clearTimeout(feedbackCloseTimerRef.current); + feedbackCloseTimerRef.current = null; + } pendingModelCommitStateRef.current = null; const conn = connectionRef.current; connectionRef.current = null; @@ -5091,43 +5787,116 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } }; }, [forceEmbedded, project, requireSocket, signalActiveTerminalForExit, signalActiveTerminalForExitSync, socketPath]); + // Stable handle to the latest refreshState so the chat-event subscription can + // call it without listing refreshState as a dependency (its identity churns on + // drawer/lane/model changes, which would needlessly re-bind the subscription). + const refreshStateRef = useRef(refreshState); useEffect(() => { - if (!connection) return; - return connection.onChatEvent((envelope) => { - const currentMultiView = multiViewRef.current; - const openSessionIds = new Set( - currentMultiView - ? currentMultiView.tiles.map((tile) => tile.sessionId) - : [activeSessionIdRef.current].filter((value): value is string => Boolean(value)), - ); + refreshStateRef.current = refreshState; + }, [refreshState]); + + const flushPendingChatEvents = useCallback(() => { + if (chatFlushTimerRef.current) { + clearTimeout(chatFlushTimerRef.current); + chatFlushTimerRef.current = null; + } + const buffered = pendingChatEnvelopesRef.current; + if (buffered.length === 0) return; + pendingChatEnvelopesRef.current = []; + // Re-apply the clearedAt guard at flush time: if the transcript was cleared + // after these envelopes were buffered (e.g. /clear armed the timer), drop the + // stale ones so they don't re-materialize in the cleared transcript. + const clearedAtValue = clearedAtRef.current; + const pending = clearedAtValue + ? buffered.filter((envelope) => envelope.timestamp > clearedAtValue) + : buffered; + if (pending.length === 0) return; + + // (1) Per-session transcript map — append all buffered envelopes for each + // affected session in a single deduped update. Previously this ran one O(n) + // dedupe per token; now it runs once per flush per session. + setEventsBySessionId((prev) => { + const grouped = new Map(); + for (const envelope of pending) { + const arr = grouped.get(envelope.sessionId); + if (arr) arr.push(envelope); + else grouped.set(envelope.sessionId, [envelope]); + } + const next = { ...prev }; + for (const [sessionId, envelopes] of grouped) { + next[sessionId] = dedupeTuiEvents([...(prev[sessionId] ?? []), ...envelopes]); + } + return next; + }); + + // (2) Active-session transcript — incremental reserve/append, batched into a + // single setState so a burst of tokens triggers one React render, not N. + const activeId = activeSessionIdRef.current; + if (activeId) { + const reserved: Array<{ envelope: AgentChatEventEnvelope; key: string }> = []; + for (const envelope of pending) { + if (envelope.sessionId !== activeId) continue; + const key = reserveTuiEventDedupKey(envelope, eventDedupKeysRef.current); + if (key !== null) reserved.push({ envelope, key }); + } + if (reserved.length > 0) { + setEvents((prev) => { + let events = prev; + let order = eventDedupKeyOrderRef.current; + for (const { envelope, key } of reserved) { + const appended = appendReservedTuiEvent(events, envelope, eventDedupKeysRef.current, order, key); + events = appended.events; + order = appended.eventKeys; + } + eventDedupKeyOrderRef.current = order; + eventCountRef.current = events.length; + return events; + }); + } + } + // Note: stable deps ([]) — uses only refs + stable setters. This keeps the + // onChatEvent subscription from re-binding (and discarding the buffer) every + // time refreshState's identity churns. + }, []); + + const scheduleChatFlush = useCallback(() => { + if (chatFlushTimerRef.current) return; + chatFlushTimerRef.current = setTimeout(() => { + chatFlushTimerRef.current = null; + flushPendingChatEvents(); + }, CHAT_EVENT_FLUSH_MS); + }, [flushPendingChatEvents]); + + useEffect(() => { + if (!connection) return; + const unsubscribe = connection.onChatEvent((envelope) => { + const currentMultiView = multiViewRef.current; + const openSessionIds = new Set( + currentMultiView + ? currentMultiView.tiles.map((tile) => tile.sessionId) + : [activeSessionIdRef.current].filter((value): value is string => Boolean(value)), + ); if (!openSessionIds.has(envelope.sessionId)) { - void refreshState({ hydrateHistory: false }).catch(() => undefined); + // Event for a session we're not displaying — refresh summaries (cheap, + // dedup-guarded). Only the open-session token stream below is coalesced. + void refreshStateRef.current({ hydrateHistory: false }).catch(() => undefined); return; } - if (clearedAt && envelope.timestamp <= clearedAt) return; + const clearedAtNow = clearedAtRef.current; + if (clearedAtNow && envelope.timestamp <= clearedAtNow) return; const event = envelope.event as Record; const isActiveSessionEvent = envelope.sessionId === activeSessionIdRef.current; - setEventsBySessionId((prev) => ({ - ...prev, - [envelope.sessionId]: appendDedupedTuiEvent(prev[envelope.sessionId] ?? [], envelope), - })); - if (isActiveSessionEvent) { - const reservedKey = reserveTuiEventDedupKey(envelope, eventDedupKeysRef.current); - if (reservedKey !== null) { - setEvents((prev) => { - const next = appendReservedTuiEvent( - prev, - envelope, - eventDedupKeysRef.current, - eventDedupKeyOrderRef.current, - reservedKey, - ); - eventDedupKeyOrderRef.current = next.eventKeys; - eventCountRef.current = next.events.length; - return next.events; - }); - } - } + + // Buffer the envelope; the transcript state is applied on the next flush. + // High-frequency token deltas coalesce on the timer; lifecycle edges flush + // immediately so the transcript is consistent with the side-effects below. + pendingChatEnvelopesRef.current.push(envelope); + const eventType = typeof event.type === "string" ? event.type : ""; + if (isChatFlushEdge(eventType)) flushPendingChatEvents(); + else scheduleChatFlush(); + + // Lifecycle side-effects stay immediate (low-frequency): they drive the + // streaming spinner, interrupt flags, and the right pane. if (event.type === "status" && event.turnStatus === "started") { setSessionStreaming(envelope.sessionId, true); setSessionInterrupted(envelope.sessionId, false); @@ -5171,22 +5940,85 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } }); } }); - }, [clearedAt, connection, refreshState, setSessionInterrupted, setSessionStreaming]); + return () => { + unsubscribe(); + // Flush whatever is buffered so a reconnect doesn't strand token deltas. + // flushPendingChatEvents() already clears chatFlushTimerRef and resets + // pendingChatEnvelopesRef after draining, mirroring the PTY cleanup. + flushPendingChatEvents(); + }; + // Re-bind only when the connection itself changes (reconnect). clearedAt and + // refreshState are read via refs so their churn doesn't drop the buffer. + }, [connection, flushPendingChatEvents, scheduleChatFlush, setSessionInterrupted, setSessionStreaming]); useEffect(() => { if (!connection) return; let disposed = false; let unsubscribe: (() => void) | null = null; + + // Cap on retained chunks. We append monotonically (so TerminalPane's + // chunkIndexRef keeps advancing — the desync fix) and only trim well past + // 500 so trims are rare; on a trim TerminalPane resets + replays the tail. + const MAX_RETAINED_CHUNKS = 4_000; + const TRIM_TO_CHUNKS = 2_000; + + const flushPendingPty = () => { + ptyFlushTimerRef.current = null; + const pending = pendingPtyChunksRef.current; + if (pending.size === 0) return; + const drained = new Map(pending); + pending.clear(); + // Bump "↓ N new" for sessions the user has scrolled away from. + const scrollMap = terminalScrollBySessionIdRef.current; + let scrollPatch: TerminalScrollBySessionId | null = null; + for (const [sid, chunks] of drained) { + const current = readTerminalScroll(scrollMap, sid); + if (current.scrollOffset > 0) { + const arrivedRows = chunks.reduce( + (sum, chunk) => sum + Math.max(0, (chunk.match(/\n/g)?.length ?? 0)), + 0, + ); + // Anchor scrolled-up content to its absolute buffer line: advance + // scrollOffset by arrivedRows (clamped) so the viewed window does not + // drift down as viewportY grows. The next onViewportMetrics report + // re-clamps on commit. + const maxScrollable = + terminalViewportMetricsRef.current?.maxScrollable ?? + Number.POSITIVE_INFINITY; + const next = noteTerminalNewRows(current, arrivedRows, maxScrollable); + if (next !== current) { + scrollPatch = { ...(scrollPatch ?? scrollMap), [sid]: next }; + } + } + } + if (scrollPatch) setTerminalScrollBySessionId(scrollPatch); + setTerminalLiveChunks((prev) => { + const next: Record = { ...prev }; + for (const [sid, chunks] of drained) { + if (chunks.length === 0) continue; + let merged = [...(next[sid] ?? []), ...chunks]; + if (merged.length > MAX_RETAINED_CHUNKS) merged = merged.slice(-TRIM_TO_CHUNKS); + next[sid] = merged; + } + return next; + }); + }; + + const scheduleFlush = () => { + if (ptyFlushTimerRef.current) return; // timer only while chunks pending → idle cost zero + ptyFlushTimerRef.current = setTimeout(flushPendingPty, 16); + }; + void connection.subscribeRuntimeEvents({ category: "pty", cursor: 0, limit: 50, replay: false }, (event) => { const payload = event.payload as { type?: unknown; event?: unknown }; const terminalEvent = payload.event as { sessionId?: unknown; data?: unknown } | undefined; const sessionId = typeof terminalEvent?.sessionId === "string" ? terminalEvent.sessionId : null; if (!sessionId) return; if (payload.type === "pty_data" && typeof terminalEvent?.data === "string") { - setTerminalLiveChunks((prev) => { - const nextChunks = [...(prev[sessionId] ?? []), terminalEvent.data as string].slice(-500); - return { ...prev, [sessionId]: nextChunks }; - }); + const buf = pendingPtyChunksRef.current.get(sessionId); + if (buf) buf.push(terminalEvent.data); + else pendingPtyChunksRef.current.set(sessionId, [terminalEvent.data]); + scheduleFlush(); return; } if (payload.type === "pty_exit") { @@ -5202,6 +6034,12 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } return () => { disposed = true; unsubscribe?.(); + if (ptyFlushTimerRef.current) { + clearTimeout(ptyFlushTimerRef.current); + ptyFlushTimerRef.current = null; + } + // Flush whatever is buffered so a reconnect doesn't strand chunks. + flushPendingPty(); }; }, [connection, refreshState]); @@ -5296,10 +6134,29 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } }; }, [connection]); + // Detect an attached socket dropping mid-session. Without this the UI freezes + // on a stale snapshot (and a spinner that never resolves) with no retry. useEffect(() => { - if (!connection || mode === "attached" || forceEmbedded) return; + if (!connection?.onConnectionClose) return; + return connection.onConnectionClose(() => { + if (connectionLostRef.current) return; + connectionLostRef.current = true; + setConnectionLost(true); + // Clear streaming across all sessions so the spinner doesn't hang and the + // reconnect probe below isn't gated on a turn that can never complete. + setStreamingBySessionId({}); + addNotice("Connection to the ADE runtime dropped — reconnecting…", "error"); + }); + }, [addNotice, connection]); + + useEffect(() => { + if (!connection || forceEmbedded) return; + // Reconnect when running embedded (try to upgrade to the shared daemon) OR + // when an attached socket dropped (connectionLost). A healthy attached + // connection needs no probe. + if (mode === "attached" && !connectionLost) return; const timer = setInterval(() => { - if (streaming || attachProbeInFlightRef.current) return; + if ((streaming && !connectionLostRef.current) || attachProbeInFlightRef.current) return; attachProbeInFlightRef.current = true; void (async () => { let attached: AdeCodeConnection | null = null; @@ -5318,6 +6175,11 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } connectionRef.current = attached; setConnection(attached); setMode(attached.mode); + if (connectionLostRef.current) { + connectionLostRef.current = false; + setConnectionLost(false); + addNotice("Reconnected to the ADE runtime.", "success"); + } await previous?.close().catch(() => {}); await refreshState(); } catch { @@ -5328,7 +6190,7 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } })(); }, 3_000); return () => clearInterval(timer); - }, [connection, forceEmbedded, mode, project, refreshState, socketPath, streaming]); + }, [addNotice, connection, connectionLost, forceEmbedded, mode, project, refreshState, socketPath, streaming]); const ensureActiveSession = useCallback(async (): Promise => { const conn = connectionRef.current; @@ -5695,6 +6557,33 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } } }, [addNotice, recordPromptHistoryForSession, refreshState, sessions, setSessionStreaming]); + // Build the help RightPaneContent for the given filter/selection/recents and + // push it into the right pane. Centralised so /help open + every keystroke in + // the pane recompute the grouped+ranked rows the same way. + const renderHelpPane = useCallback( + (query: string, selectedIndex: number, recents: string[]) => { + const groupedRows = buildHelpRows(helpIndexGroups, query, recents); + const total = flattenHelpRows(groupedRows).length; + const clamped = total === 0 ? 0 : Math.max(0, Math.min(selectedIndex, total - 1)); + setRightPane({ + kind: "help", + title: "Help", + filterQuery: query, + selectedIndex: clamped, + groupedRows, + }); + }, + [helpIndexGroups], + ); + + const openHelpPane = useCallback(() => { + setHelpFilterQuery(""); + setHelpSelectedIndex(0); + renderHelpPane("", 0, helpRecentsRef.current); + setRightOpen(true); + setPaneFocus("details"); + }, [renderHelpPane, setPaneFocus]); + const runRightCommand = useCallback(async (name: string, args: string) => { const conn = connectionRef.current; const laneId = activeLaneIdRef.current; @@ -5708,7 +6597,7 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } if (!conn) { if (name === "/help") { - setRightPane({ kind: "help", title: "Help" }); + renderHelpPane("", 0, helpRecentsRef.current); return; } if (name === "/status") { @@ -5733,7 +6622,7 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } return; } if (name === "/effort") { - openModelRow({ focusKind: "reasoning" }); + openModelPicker({ focusKind: "reasoning" }); return; } if (name === "/info") { @@ -5765,7 +6654,7 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } } if (name === "/help") { - setRightPane({ kind: "help", title: "Help" }); + renderHelpPane("", 0, helpRecentsRef.current); return; } if (name === "/keybindings") { @@ -5825,27 +6714,65 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } } if (name === "/context") { if (!sessionId) { - setRightPane({ kind: "details", title: "Context", body: "No active chat is selected." }); + setRightPane({ kind: "context-usage", title: "Context", usage: null, error: "No active chat is selected." }); return; } - if (activeSession?.provider !== "claude") { - setRightPane({ kind: "details", title: "Context", body: "/context is only available for Claude chats." }); + // The runtime only computes context usage for Claude sessions today; give a + // clean message for other providers rather than surfacing a raw exception. + if (activeCommandProvider !== "claude") { + setRightPane({ kind: "context-usage", title: "Context", usage: null, error: "Context usage is currently only available for Claude sessions." }); return; } - setRightPane({ kind: "details", title: "Context", body: "Loading Claude context usage..." }); + setRightPane({ kind: "context-usage", title: "Context", usage: null }); try { const usage = await getContextUsage(conn, sessionId); - setRightPane({ kind: "details", title: "Context", body: formatContextUsage(usage) }); + setRightPane({ kind: "context-usage", title: "Context", usage }); } catch (error) { const message = error instanceof Error ? error.message : String(error); setRightPane({ - kind: "details", + kind: "context-usage", title: "Context", - body: `Claude context usage is not available for this session.\n\n${message}`, + usage: null, + error: `Context usage is not available for this ${activeSession?.provider ?? "chat"} session.\n\n${message}`, }); } return; } + if (name === "/usage") { + if (!sessionId) { + addNotice("Usage needs an active chat.", "error"); + return; + } + if (activeCommandProvider !== "claude") { + addNotice("Usage is available for Claude chats.", "error"); + return; + } + // Session tokens/cost come straight off the local event stream — always + // available even when the daemon snapshot carries no quota window. Mirror + // the token-summary effect's fallback-context resolution. + const usageFallbackContext = activeSession?.modelId + ? getModelById(activeSession.modelId)?.contextWindow ?? null + : null; + const stats = latestTokenStats(events, usageFallbackContext); + const sessionBlock = { + input: stats.inputTokens, + output: stats.outputTokens, + cost: stats.costUsd, + }; + // Quota windows are reuse-only: the daemon exposes at most a single + // rate-limit window (parsed into stats.rateLimit). Render it when present, + // otherwise degrade to the session block. + const quotaWindows = stats.rateLimit?.usedPercentage != null + ? [{ + id: "rate-limit", + label: "Rate limit", + percent: stats.rateLimit.usedPercentage, + resetAt: stats.rateLimit.resetsAt, + }] + : undefined; + setRightPane({ kind: "usage", title: "Usage", quotaWindows, session: sessionBlock }); + return; + } if (name === "/agents") { if (activeCommandProvider !== "claude") { setRightPane({ kind: "details", title: "Agents", body: "/agents is only available for Claude chats." }); @@ -5952,20 +6879,13 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } setSelectedDrawerLaneAction(null); return; } - if (name === "/rename") { + if (name === "/rename" || name === "/chat rename") { if (!sessionId) { setRightPane({ kind: "details", title: "Rename chat", body: "No active chat is selected." }); return; } if (!args) { - openForm({ - kind: "form", - title: "Rename chat", - command: "rename", - fields: [ - { name: "title", label: "Title", required: true, initialValue: activeSession?.title ?? "" }, - ], - }); + openChatRenameForm(sessionId); return; } await renameChat(conn, sessionId, args); @@ -5973,6 +6893,38 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } await refreshState(); return; } + if (name === "/chat archive") { + await archiveChat(); + return; + } + if (name === "/chat unarchive") { + await unarchiveChat(args); + return; + } + if (name === "/chat archived") { + const archived = (await listChatSessions(conn, null, { includeArchived: true })) + .filter((session) => session.archivedAt); + const query = args.trim().toLowerCase(); + const rows = archived + .filter((session) => !query + || session.sessionId.toLowerCase().includes(query) + || (session.title ?? "").toLowerCase().includes(query) + || (lanes.find((lane) => lane.id === session.laneId)?.name ?? session.laneId).toLowerCase().includes(query)) + .map((session) => { + const laneName = lanes.find((lane) => lane.id === session.laneId)?.name ?? session.laneId; + return `${session.title ?? session.sessionId} · ${laneName} · archived ${session.archivedAt ?? ""}`; + }); + setRightPane({ + kind: "details", + title: "Archived chats", + body: rows.length ? rows.join("\n") : "No archived chats matched.", + }); + return; + } + if (name === "/chat delete") { + openChatDeleteForm(); + return; + } if (name === "/tag") { if (!sessionId) { setRightPane({ kind: "details", title: "Tag chat", body: "No active chat is selected." }); @@ -6056,6 +7008,19 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } newParentLaneId: parent.id, ...(stackBaseBranchRef ? { stackBaseBranchRef } : {}), }); + // Reparent rebases the lane onto its new parent, which can stop on + // conflicts. Surface them instead of reporting an unconditional success. + const reparentConflict = await conn + .action("git", "getConflictState", { laneId }) + .catch(() => null); + if (reparentConflict?.inProgress && reparentConflict.kind) { + const report = formatGitConflictReport(reparentConflict); + setRightPane({ kind: "details", title: report.title, body: report.body }); + setRightOpen(true); + addNotice(report.summary, "error"); + await refreshState(); + return; + } showReparentDetails(renderObject(result, 20)); addNotice( `Reparented ${lane.name} under ${parent.name}${stackBaseBranchRef ? ` using ${stackBaseBranchRef}` : ""}.`, @@ -6064,6 +7029,43 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } await refreshState(); return; } + if (name === "/lane rename") { + if (args.trim()) { + await renameLane(laneId, args); + return; + } + openLaneRenameForm(); + return; + } + if (name === "/lane archive") { + await archiveLane(); + return; + } + if (name === "/lane unarchive") { + await unarchiveLane(args); + return; + } + if (name === "/lane archived") { + if (!conn) return; + try { + const archived = (await listLanes(conn, { includeArchived: true })).filter((entry) => entry.archivedAt); + if (!archived.length) { + setRightPane({ kind: "details", title: "Archived lanes", body: "No archived lanes." }); + } else { + setRightPane({ + kind: "list", + title: `Archived lanes (${archived.length}) · /lane unarchive `, + rows: archived.map((entry) => `${entry.name}${entry.branchRef ? ` · ${entry.branchRef}` : ""}`), + emptyText: "No archived lanes.", + }); + } + setRightOpen(true); + focusDetails(); + } catch (err) { + addNotice(err instanceof Error ? err.message : String(err), "error"); + } + return; + } if (name === "/lane delete") { openLaneDeleteForm(); return; @@ -6136,6 +7138,68 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } setRightPane({ kind: "details", title: "PR comments", body: formatPrComments(comments) }); return; } + const prRef = typeof activePr?.number === "number" ? `PR #${activePr.number}` : "the PR"; + if (name === "/pr land") { + const parts = args.trim().split(/\s+/).filter(Boolean); + const confirmed = parts[0]?.toLowerCase() === "confirm"; + const methodArg = (confirmed ? parts[1] : parts[0])?.toLowerCase(); + const method = (["merge", "squash", "rebase"].includes(methodArg ?? "") ? methodArg : "squash") as "merge" | "squash" | "rebase"; + if (!confirmed) { + // Merging is irreversible and runs post-merge cleanup, so require an + // explicit confirm step rather than landing on the first keystroke. + setRightPane({ + kind: "details", + title: "Land PR", + body: [ + `About to merge ${prRef} using the "${method}" method.`, + "", + "This merges on GitHub and runs post-merge cleanup (branch delete, child rebase). It cannot be undone.", + "", + `Run /pr land confirm ${method} to proceed.`, + "Choose a method: /pr land confirm merge | squash | rebase", + ].join("\n"), + }); + return; + } + try { + const landed = await conn.action("pr", "land", { prId, method }); + addNotice(`Merged ${prRef} (${method}).`, "success"); + setRightPane({ kind: "details", title: "PR landed", body: renderObject(landed, 24) }); + await refreshState(); + } catch (err) { + addNotice(err instanceof Error ? err.message : String(err), "error"); + } + return; + } + if (name === "/pr comment") { + if (!args.trim()) { + setRightPane({ kind: "details", title: "PR comment", body: "Usage: /pr comment " }); + return; + } + try { + await conn.action("pr", "addComment", { prId, body: args.trim() }); + addNotice(`Commented on ${prRef}.`, "success"); + } catch (err) { + addNotice(err instanceof Error ? err.message : String(err), "error"); + } + return; + } + if (name === "/pr approve" || name === "/pr request-changes") { + const event = name === "/pr approve" ? "APPROVE" : "REQUEST_CHANGES"; + const body = args.trim(); + if (event === "REQUEST_CHANGES" && !body) { + setRightPane({ kind: "details", title: "PR review", body: "Usage: /pr request-changes " }); + return; + } + try { + await conn.action("pr", "submitReview", { prId, event, ...(body ? { body } : {}) }); + addNotice(event === "APPROVE" ? `Approved ${prRef}.` : `Requested changes on ${prRef}.`, "success"); + await refreshState(); + } catch (err) { + addNotice(err instanceof Error ? err.message : String(err), "error"); + } + return; + } const review = await Promise.all([ conn.actionList("pr", "getReviews", [prId]).catch((err) => ({ error: err instanceof Error ? err.message : String(err) })), conn.actionList("pr", "getReviewThreads", [prId]).catch((err) => ({ error: err instanceof Error ? err.message : String(err) })), @@ -6164,7 +7228,7 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } setRightPane({ kind: "details", title: "Linear pull", body: `Linear issue ${args} was not found.` }); return; } - const targetSessionId = focusedSessionIdForMultiView(multiViewRef.current) ?? await ensureActiveSession(); + const targetSessionId = (gridViewActiveRef.current ? focusedSessionIdForMultiView(multiViewRef.current) : null) ?? await ensureActiveSession(); const issueContext = `Linear issue context:\n${renderObject(issue, 28)}`; if (targetSessionId) { await sendOrSteerChatMessage(targetSessionId, issueContext); @@ -6229,20 +7293,27 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } return; } if (name === "/chats") { - const laneSessions = displaySessions.filter((session) => session.laneId === laneId); + const query = args.trim().toLowerCase(); + const laneSessions = displaySessions + .filter((session) => session.laneId === laneId) + .filter((session) => !query + || session.sessionId.toLowerCase().includes(query) + || (session.title ?? "").toLowerCase().includes(query) + || (session.goal ?? "").toLowerCase().includes(query) + || session.provider.toLowerCase().includes(query)); const selectedIndex = Math.max(0, laneSessions.findIndex((session) => session.sessionId === sessionId)); setRightSelectionIndex(selectedIndex); setRightPane({ kind: "list", - title: "Chats", - rows: laneSessions.map((session) => `${session.sessionId === sessionId ? "●" : "○"} ${session.title ?? session.sessionId}`), - emptyText: "No chats in this lane.", + title: query ? `Chats · ${args.trim()}` : "Chats", + rows: laneSessions.map((session) => `${session.sessionId === sessionId ? "●" : "○"} ${session.title ?? session.sessionId} · ${session.provider}`), + emptyText: query ? "No chats matched this filter." : "No chats in this lane.", action: { kind: "switch-chat", ids: laneSessions.map((session) => session.sessionId) }, }); return; } if (name === "/switch") { - const query = args.toLowerCase(); + const query = args.trim().toLowerCase(); if (!query) { const selectedIndex = Math.max(0, lanes.findIndex((lane) => lane.id === laneId)); setRightSelectionIndex(selectedIndex); @@ -6255,17 +7326,28 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } }); return; } - const lane = lanes.find((entry) => entry.id.toLowerCase() === query || entry.name.toLowerCase().includes(query)); - if (lane) { - selectActiveLaneId(lane.id); - setDrawerLaneId(lane.id); - setSelectedDrawerLaneId(lane.id); - const session = newestSession(displaySessions.filter((entry) => entry.laneId === lane.id)); - selectActiveSessionId(session?.sessionId ?? null); - setSelectedDrawerChatId(session?.sessionId ?? null); - addNotice(`Switched to lane ${lane.name}.`, "success"); + const exactLane = lanes.find((entry) => entry.id.toLowerCase() === query || entry.name.toLowerCase() === query) ?? null; + const exactChat = displaySessions.find((entry) => ( + entry.sessionId.toLowerCase() === query + || (entry.title ?? "").toLowerCase() === query + )) ?? null; + const lane = exactLane ?? lanes.find((entry) => entry.id.toLowerCase().includes(query) || entry.name.toLowerCase().includes(query)) ?? null; + const chat = exactChat ?? displaySessions.find((entry) => ( + entry.sessionId.toLowerCase().includes(query) + || (entry.title ?? "").toLowerCase().includes(query) + )) ?? null; + if (exactLane || (lane && !exactChat)) { + activateLaneWithLastChat(exactLane ?? lane!, { notify: true }); + } else if (chat) { + selectActiveLaneId(chat.laneId); + setDrawerLaneId(chat.laneId); + setSelectedDrawerLaneId(chat.laneId); + setSelectedDrawerChatAction(null); + setSelectedDrawerChatId(chat.sessionId); + applyDrawerChatSelection({ session: chat, action: null }); + addNotice(`Switched to chat ${chat.title ?? chat.sessionId}.`, "success"); } else { - setRightPane({ kind: "details", title: "Switch", body: `No lane matched "${args}".` }); + setRightPane({ kind: "details", title: "Switch", body: `No lane or chat matched "${args}".` }); } return; } @@ -6274,7 +7356,7 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } return; } if (name === "/effort") { - openModelRow({ focusKind: "reasoning" }); + openModelPicker({ focusKind: "reasoning" }); return; } if (name === "/info") { @@ -6334,7 +7416,7 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } : result; setRightPane({ kind: "details", title: `ADE ${domain}.${action}`, body: renderObject(body, 24) }); } - }, [activeCommandProvider, activeLane?.name, activeSession?.provider, activeSession?.sessionId, activeSession?.title, addNotice, ensureActiveSession, focusDetails, lanes, mode, modelState.modelId, modelState.reasoningEffort, models, openFeedbackForm, openForm, openLaneDeleteForm, openModelRow, openNewChatSetup, openNewLaneForm, openSubagentsPane, pendingSteers, project, refreshState, selectActiveLaneId, selectActiveSessionId, sendOrSteerChatMessage, sessions, setChatScrollOffset, subagentPaneCommandAvailable]); + }, [activateLaneWithLastChat, activeCommandProvider, activeLane?.name, activeSession?.provider, activeSession?.sessionId, activeSession?.title, addNotice, applyDrawerChatSelection, archiveChat, archiveLane, ensureActiveSession, focusDetails, lanes, mode, modelState.modelId, modelState.reasoningEffort, models, openChatDeleteForm, openChatRenameForm, openFeedbackForm, openForm, openLaneDeleteForm, openLaneRenameForm, openModelPicker, openNewChatSetup, openNewLaneForm, openSubagentsPane, pendingSteers, project, refreshState, renameLane, selectActiveLaneId, selectActiveSessionId, sendOrSteerChatMessage, sessions, setChatScrollOffset, subagentPaneCommandAvailable, unarchiveChat, unarchiveLane]); const runInlineCommand = useCallback(async (name: string, args: string) => { if (name === "/quit") { @@ -6420,6 +7502,33 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } return; } const tokens = args.split(/\s+/).filter(Boolean); + + // Resolve an in-progress conflict from a previous pull/reparent. + if (tokens.includes("--continue") || tokens.includes("--abort")) { + const wantContinue = tokens.includes("--continue"); + const state = await conn.action("git", "getConflictState", { laneId }); + if (!state?.inProgress || !state.kind) { + addNotice("No merge or rebase conflict is in progress for this lane.", "info"); + return; + } + if (wantContinue) { + if (state.conflictedFiles.length > 0) { + const report = formatGitConflictReport(state); + setRightPane({ kind: "details", title: report.title, body: report.body }); + setRightOpen(true); + addNotice(`Still ${state.conflictedFiles.length} unresolved file(s). Resolve them, then /pull --continue.`, "error"); + return; + } + await conn.action("git", state.kind === "rebase" ? "rebaseContinue" : "mergeContinue", { laneId }); + addNotice(`${state.kind === "rebase" ? "Rebase" : "Merge"} continued.`, "success"); + } else { + await conn.action("git", state.kind === "rebase" ? "rebaseAbort" : "mergeAbort", { laneId }); + addNotice(`${state.kind === "rebase" ? "Rebase" : "Merge"} aborted.`, "success"); + } + await refreshState(); + return; + } + const modeFlags = tokens.filter((token) => token === "--ff-only" || token === "--rebase" || token === "--merge"); if (modeFlags.length > 1) { addNotice("Choose only one pull mode: --ff-only, --rebase, or --merge.", "error"); @@ -6439,6 +7548,18 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } return; } const result = await conn.action("git", "pull", { laneId, ...(mode ? { mode } : {}) }); + // Pull/rebase reports success even when it stops on conflicts (the working + // tree is left mid-merge). Check before claiming success. + const conflict = await conn + .action("git", "getConflictState", { laneId }) + .catch(() => null); + if (conflict?.inProgress && conflict.kind) { + const report = formatGitConflictReport(conflict); + setRightPane({ kind: "details", title: report.title, body: report.body }); + setRightOpen(true); + addNotice(report.summary, "error"); + return; + } addNotice(`Pull complete: ${renderObject(result, 4).replace(/\n/g, " ")}`, "success"); return; } @@ -6631,10 +7752,11 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } } if (form.command === "rename") { - if (!sessionId) return; + const targetSessionId = form.sessionId ?? sessionId; + if (!targetSessionId) return; const title = requireField("title", "Title"); if (!title) return; - await renameChat(conn, sessionId, title); + await renameChat(conn, targetSessionId, title); setRightOpen(false); setRightPane({ kind: "empty" }); lastUserOpenedPaneRef.current = null; @@ -6644,6 +7766,17 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } return; } + if (form.command === "lane-rename") { + const name = requireField("name", "Lane name"); + if (!name) return; + await renameLane(form.laneId ?? activeLaneIdRef.current, name); + setRightOpen(false); + setRightPane({ kind: "empty" }); + lastUserOpenedPaneRef.current = null; + focusAfterDetails(); + return; + } + if (form.command === "pr-open") { if (!laneId) return; const title = requireField("title", "Title"); @@ -6713,10 +7846,57 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } return; } + if (form.command === "chat-delete") { + const targetSessionId = form.chatDelete?.sessionId ?? form.sessionId ?? sessionId; + if (!targetSessionId) return; + const session = sessions.find((entry) => entry.sessionId === targetSessionId) ?? null; + if (!session) { + addNotice("Selected chat is no longer loaded.", "error"); + return; + } + const expected = form.chatDelete?.title ?? session.title ?? session.goal ?? session.sessionId; + const confirm = requireField("confirm", "Chat title"); + if (!confirm) return; + if (confirm !== expected) { + addNotice(`Type "${expected}" exactly to delete this chat.`, "error"); + return; + } + setRightPane({ kind: "details", title: "Delete chat", body: `Deleting ${expected}...` }); + await deleteChatSession(conn, targetSessionId); + setFormDiscardArmed(false); + setFormValues({}); + setFormFieldIndex(0); + setPrompt(""); + if (activeSessionIdRef.current === targetSessionId) { + selectFallbackChatAfterRemoval(session); + } + setRightOpen(false); + setRightPane({ kind: "empty" }); + lastUserOpenedPaneRef.current = null; + focusAfterDetails(); + addNotice(`Deleted chat ${expected}.`, "success"); + await refreshState(); + return; + } + if (form.command === "feedback") { - const summary = requireField("summary", "Summary"); - if (!summary) return; - const draftInput = buildFeedbackDraftInput({ ...values, summary } as FeedbackFormValues); + // Prefer the multiline feedback state carried on content.feedback (seeded + // by openFeedbackForm, edited in the right-pane input guard). Falls back to + // the legacy single-line formValues path when no meta is present. + let feedbackValues: FeedbackFormValues; + if (form.feedback) { + const state: FeedbackFormState = feedbackStateFromMeta(form.feedback); + if (!feedbackFormCanSubmit(state)) { + addNotice("Add some feedback before sending.", "error"); + return; + } + feedbackValues = feedbackFormToFormValues(state); + } else { + const summary = requireField("summary", "Summary"); + if (!summary) return; + feedbackValues = { ...values, summary } as FeedbackFormValues; + } + const draftInput = buildFeedbackDraftInput(feedbackValues); setRightPane({ kind: "details", title: "Feedback", body: "Posting feedback to GitHub..." }); try { const draft = await conn.action("feedback", "prepareDraft", { @@ -6730,6 +7910,46 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } body: draft.body, labels: draft.labels, }); + const notice = feedbackSubmissionNotice(submission); + if (notice.tone === "success" && form.feedback) { + // Flash the sanctioned green ✓ (rendered by FeedbackFormPane via the + // shared spin tick), then auto-close. A single one-shot timer is fine — + // the motion itself is gated on useShimmerTick, not a bare setInterval. + setRightPane({ + kind: "form", + title: "Feedback", + command: "feedback", + fields: form.fields, + feedback: { ...form.feedback, feedback: "submitted" }, + }); + addNotice(notice.text, notice.tone); + if (feedbackCloseTimerRef.current) { + clearTimeout(feedbackCloseTimerRef.current); + } + feedbackCloseTimerRef.current = setTimeout(() => { + feedbackCloseTimerRef.current = null; + setRightPane((prev) => { + // Only close if the submitted feedback form is still showing; a + // different pane may have been opened while the timer was pending. + if ( + prev.kind === "form" && + prev.command === "feedback" && + prev.feedback?.feedback === "submitted" + ) { + setFormDiscardArmed(false); + setFormValues({}); + setFormFieldIndex(0); + setPrompt(""); + setRightOpen(false); + lastUserOpenedPaneRef.current = null; + focusAfterDetails(); + return { kind: "empty" }; + } + return prev; + }); + }, 900); + return; + } setFormDiscardArmed(false); setFormValues({}); setFormFieldIndex(0); @@ -6738,7 +7958,6 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } setRightPane({ kind: "empty" }); lastUserOpenedPaneRef.current = null; focusAfterDetails(); - const notice = feedbackSubmissionNotice(submission); addNotice(notice.text, notice.tone); } catch (err) { const message = err instanceof Error ? err.message : String(err); @@ -6753,7 +7972,7 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } addNotice(`Feedback failed: ${message}`, "error"); } } - }, [addNotice, focusAfterDetails, lanes, refreshState, selectActiveLaneId, selectActiveSessionId]); + }, [addNotice, focusAfterDetails, lanes, refreshState, renameLane, selectActiveLaneId, selectActiveSessionId, selectFallbackChatAfterRemoval, sessions]); const openLatestImage = useCallback(() => { const target = latestOpenableImageTarget(events); @@ -6809,6 +8028,68 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } return false; }, [activeCommandProvider, addNotice, clearChatPromptDraft, runInlineCommand, runRightCommand, slashCommands]); + const openCommandPalette = useCallback(() => { + setCommandPaletteOpen(true); + setCommandPaletteQuery(""); + setCommandPaletteIndex(0); + selectFooterControl(null); + setInlineRowFocus({ cell: null }); + }, [selectFooterControl]); + + const runCommandPaletteItem = useCallback(async (item: CommandPaletteItem | null | undefined) => { + if (!item) return; + setCommandPaletteOpen(false); + setCommandPaletteQuery(""); + setCommandPaletteIndex(0); + if (item.kind === "command") { + const commandName = item.key.startsWith("command:") ? item.key.slice("command:".length) : item.label.split(/\s+/)[0] ?? ""; + const parsed = parseCommand(commandName, slashCommands); + if (parsed?.spec?.placement === "right") { + await runRightCommand(parsed.name, ""); + } else if (parsed?.spec?.placement === "inline") { + if (parsed.spec.argumentHint) { + const draft = `${parsed.name} `; + setPrompt(draft); + promptRef.current = draft; + chatDraftRef.current = draft; + focusChat(); + } else { + await runInlineCommand(parsed.name, ""); + } + } else if (parsed?.spec?.placement === "chat") { + const draft = `${parsed.name} `; + setPrompt(draft); + promptRef.current = draft; + chatDraftRef.current = draft; + focusChat(); + } else if (parsed?.name) { + const draft = `${parsed.name} `; + setPrompt(draft); + promptRef.current = draft; + chatDraftRef.current = draft; + focusChat(); + } + return; + } + if (item.kind === "lane") { + const laneId = item.key.slice("lane:".length); + const lane = lanes.find((entry) => entry.id === laneId) ?? null; + if (lane) activateLaneWithLastChat(lane, { notify: true }); + return; + } + const sessionId = item.key.slice("chat:".length); + const session = displaySessions.find((entry) => entry.sessionId === sessionId) ?? null; + if (session) { + selectActiveLaneId(session.laneId); + setDrawerLaneId(session.laneId); + setSelectedDrawerLaneId(session.laneId); + setSelectedDrawerChatAction(null); + setSelectedDrawerChatId(session.sessionId); + applyDrawerChatSelection({ session, action: null }); + addNotice(`Switched to chat ${session.title ?? session.sessionId}.`, "success"); + } + }, [activateLaneWithLastChat, addNotice, applyDrawerChatSelection, displaySessions, focusChat, lanes, runInlineCommand, runRightCommand, selectActiveLaneId, slashCommands]); + const submitPrompt = useCallback(async (value: string) => { const text = value.trim(); const submittedValue = value; @@ -6906,7 +8187,7 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } await submitClaudePromptToTerminal(activeTerminal, selected.name); return; } - const sessionId = focusedSessionIdForMultiView(multiViewRef.current) ?? await ensureActiveSession(); + const sessionId = (gridViewActiveRef.current ? focusedSessionIdForMultiView(multiViewRef.current) : null) ?? await ensureActiveSession(); if (sessionId) { await sendOrSteerChatMessage(sessionId, selected.name); } @@ -6950,7 +8231,7 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } } return; } - const focusedSessionId = focusedSessionIdForMultiView(multiViewRef.current); + const focusedSessionId = (gridViewActiveRef.current ? focusedSessionIdForMultiView(multiViewRef.current) : null); const sessionId = focusedSessionId ?? await ensureActiveSession(); if (!sessionId) { addNotice("No active lane is available for chat.", "error"); @@ -7158,26 +8439,23 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } .then((recents) => setModelPickerRecents(recents)) .catch(() => undefined); } - // If we were picking for a new-chat draft, return to the setup pane so - // the user can finish configuring and dispatch. Otherwise close the pane. - let restoreSetup = false; setRightPane((prev) => { if (prev.kind === "model-picker" && prev.surface === "new-chat") { - const laneId = activeLaneIdRef.current; - const lane = laneId ? lanes.find((entry) => entry.id === laneId) : null; - if (lane) { - restoreSetup = true; - return { - kind: "new-chat-setup", - laneId: lane.id, - laneLabel: lane.name, - rows: newChatSetupRows, - }; - } + // Picking a model drops focus DOWN into the settings (reasoning first) + // per the "pick → settings → Confirm" flow — the picker stays open. + const firstSetting = (prev.settingsRows ?? []).find( + (row) => row.kind !== "provider" && row.kind !== "model", + )?.kind ?? "apply"; + return { + ...prev, + selection: { kind: "provider", provider }, + focusedIndex: 0, + footerFocus: firstSetting, + }; } return { kind: "empty" }; }); - if (restoreSetup) { + if (rightPane.kind === "model-picker" && rightPane.surface === "new-chat") { setRightOpen(true); setPaneFocus("details"); } else { @@ -7190,7 +8468,7 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } } addNotice(`Model set to ${target.displayName}.`, "success"); }, - [addNotice, lanes, models, modelCatalog, newChatSetupRows, rightPane, scheduleModelStateCommit, sendClaudeModelCommandToTerminal, setPaneFocus], + [addNotice, models, modelCatalog, rightPane, scheduleModelStateCommit, sendClaudeModelCommandToTerminal, setPaneFocus], ); const selectProvider = useCallback((provider: AdeCodeProvider) => { @@ -7252,9 +8530,16 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } userInitiatedModeChangeRef.current = true; if (modelState.provider === "codex") { const current = resolveCodexPreset(modelState); - const index = Math.max(0, CODEX_PRESETS.findIndex((entry) => entry === current)); - const next = CODEX_PRESETS[(index + delta + CODEX_PRESETS.length) % CODEX_PRESETS.length] ?? "default"; - applyModelState((prev) => ({ ...prev, ...codexPresetPatch(next) })); + // A "custom" approval×sandbox combo isn't in CODEX_PRESETS (findIndex → -1). + // The old `Math.max(0, -1)` collapsed it to index 0, so cycling out of a + // custom combo always jumped to "default" and silently discarded it. + // Step deterministically into the preset list instead: forward → first, + // backward → last. + const found = CODEX_PRESETS.findIndex((entry) => entry === current); + const next = found === -1 + ? (delta >= 0 ? CODEX_PRESETS[0] : CODEX_PRESETS[CODEX_PRESETS.length - 1]) + : CODEX_PRESETS[(found + delta + CODEX_PRESETS.length) % CODEX_PRESETS.length]; + applyModelState((prev) => ({ ...prev, ...codexPresetPatch(next ?? "default") })); return; } if (modelState.provider === "claude") { @@ -7289,8 +8574,9 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } applyModelState((prev) => ({ ...prev, droidPermissionMode: next, permissionMode: droidPermissionToLegacy(next) })); return; } - const index = Math.max(0, CURSOR_AVAILABLE_MODE_IDS.findIndex((entry) => entry === modelState.cursorModeId)); - const next = CURSOR_AVAILABLE_MODE_IDS[(index + delta + CURSOR_AVAILABLE_MODE_IDS.length) % CURSOR_AVAILABLE_MODE_IDS.length] ?? "agent"; + const modeIds = cursorModeIdsForState(modelState); + const index = Math.max(0, modeIds.findIndex((entry) => entry === modelState.cursorModeId)); + const next = modeIds[(index + delta + modeIds.length) % modeIds.length] ?? "agent"; applyModelState((prev) => ({ ...prev, cursorModeId: next, @@ -7374,7 +8660,7 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } }, [addNotice, applyModelState, cycleModel, cyclePermission, cycleProvider, cycleReasoning, focusChat, refreshAiSetupStatus, refreshState, sendClaudeModelCommandToTerminal]); const recallPromptHistory = useCallback((direction: "previous" | "next"): boolean => { - const focusedSessionId = focusedSessionIdForMultiView(multiViewRef.current); + const focusedSessionId = (gridViewActiveRef.current ? focusedSessionIdForMultiView(multiViewRef.current) : null); const history = focusedSessionId ? promptHistoryBySessionIdRef.current[focusedSessionId] ?? [] : promptHistoryRef.current; @@ -7516,7 +8802,7 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } return true; } if (action === "app:help") { - setRightPane({ kind: "help", title: "Help" }); + renderHelpPane("", 0, helpRecentsRef.current); focusDetails(); return true; } @@ -7632,10 +8918,6 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } focusChat(); return true; } - if (action === "historySearch:cycleScope") { - addNotice("History search scope cycling is not available yet.", "info"); - return true; - } if (action === "pane:toggle") { toggleDetailsPane(); return true; @@ -7690,11 +8972,11 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } return true; } if (action === "tabs:next" || action === "footer:next") { - cyclePaneFocus(); + cyclePaneFocus(1); return true; } if (action === "tabs:previous" || action === "footer:previous") { - cyclePaneFocus(); + cyclePaneFocus(-1); return true; } if (action === "footer:up" || action === "footer:down") { @@ -7809,11 +9091,15 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } } return true; } + if (action === "app:openCommandPalette") { + openCommandPalette(); + return true; + } if (action.startsWith("selection:")) { return reportUnavailable(); } return reportUnavailable(); - }, [addNotice, applyModelState, attachClipboardImage, chatRowBudget, copyChatSelection, cycleFooterControl, cyclePaneFocus, cyclePermission, cycleReasoning, drawerOpen, focusAfterDetails, focusChat, focusDetails, footerControls, launchPromptInBackground, modelState.provider, openHistorySearch, openModelRow, prompt, recallPromptHistory, refreshState, requestAppExit, resolveFocusedDeeplinkRow, rightOpen, selectFooterControl, setChatScrollOffset, submitPrompt, toggleDetailsPane, toggleSubagentsPane]); + }, [addNotice, applyModelState, attachClipboardImage, chatRowBudget, copyChatSelection, cycleFooterControl, cyclePaneFocus, cyclePermission, cycleReasoning, drawerOpen, focusAfterDetails, focusChat, focusDetails, footerControls, launchPromptInBackground, modelState.provider, openCommandPalette, openHistorySearch, openModelPicker, prompt, recallPromptHistory, refreshState, requestAppExit, resolveFocusedDeeplinkRow, rightOpen, selectFooterControl, setChatScrollOffset, submitPrompt, toggleDetailsPane, toggleSubagentsPane]); const chatPointFromMouse = useCallback(( x: number | null, @@ -7862,6 +9148,49 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } } return; } + // Claude live PTY pane (not attached, single view): keyboard scrollback over + // the headless xterm buffer + copy of the visible region. PageUp/PageDown, + // Home/End and Shift+Up/Down (when not editing the prompt) drive scrollback; + // Ctrl/Cmd+C copies the visible window via the shared writeClipboardText. + { + const terminalForScroll = (activeTerminalSession ?? activeTerminalSessionRef.current); + const terminalPaneVisible = Boolean(terminalForScroll) && !multiViewRef.current; + if (terminalPaneVisible && terminalForScroll) { + const sid = terminalForScroll.terminalId; + const metrics = terminalViewportMetricsRef.current; + const step = terminalPageStep(Math.max(1, chatRowBudget)); + const isHome = input === "\x1b[H" || input === "\x1b[1~"; + const isEnd = input === "\x1b[F" || input === "\x1b[4~"; + const promptHasText = promptRef.current.trim().length > 0; + const wantsScrollUp = key.pageUp || (key.shift && key.upArrow && !promptHasText); + const wantsScrollDown = key.pageDown || (key.shift && key.downArrow && !promptHasText); + if (wantsScrollUp || wantsScrollDown || isHome || isEnd) { + setTerminalScrollBySessionId((prev) => { + const current = readTerminalScroll(prev, sid); + let next = current; + if (isEnd) next = jumpTerminalToBottom(current); + else if (isHome) { + next = { + scrollOffset: clampTerminalScrollOffset(metrics.maxScrollable, metrics.maxScrollable), + pendingNewCount: 0, + }; + } else if (wantsScrollUp) next = scrollTerminalBy(current, step, metrics.maxScrollable); + else if (wantsScrollDown) next = scrollTerminalBy(current, -step, metrics.maxScrollable); + if (next === current) return prev; + return { ...prev, [sid]: next }; + }); + return; + } + if (isCtrlInput(input, key, "c") && metrics.visibleText.trim().length > 0) { + if (writeClipboardText(metrics.visibleText)) { + addNotice("Copied terminal output", "success"); + } else { + addNotice("Clipboard unavailable", "error"); + } + return; + } + } + } const mouse = parseTerminalMouseInput(input); if (mouse) { const activeSelection = chatMouseSelectionRef.current; @@ -8050,15 +9379,45 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } return; } - const centerStart = drawerWidth + 1; - const centerEnd = columns - rightWidth; - 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) { - if (mouse.direction === "up") { - setChatScrollOffset((offset) => offset + 3); - } else if (mouse.direction === "down") { - setChatScrollOffset((offset) => offset - 3); + const centerStart = drawerWidth + 1; + const centerEnd = columns - rightWidth; + const inCenterPane = mouse.x == null || (mouse.x >= centerStart && mouse.x <= centerEnd); + const inRightPane = mouse.x != null && rightOpen && rightWidth > 0 && mouse.x >= rightStart; + const inTranscriptRows = mouse.y == null || mouse.y > 2; + if (mouse.kind === "wheel" && inDrawerPane) { + const visibleCount = visibleDrawerLaneCount(chatRowBudget, orderedDrawerLanes.length); + const maxOffset = Math.max(0, orderedDrawerLanes.length - visibleCount); + const delta = mouse.direction === "down" ? 3 : mouse.direction === "up" ? -3 : 0; + if (delta !== 0) { + setDrawerScrollOffsetRows((offset) => Math.max(0, Math.min(maxOffset, offset + delta))); + } + } else if (mouse.kind === "wheel" && inRightPane) { + const maxOffset = Math.max(0, rightPaneScrollableRowCount(rightPane) - DETAILS_BODY_MAX_LINES); + const delta = mouse.direction === "down" ? 3 : mouse.direction === "up" ? -3 : 0; + if (delta !== 0) { + setRightPaneScrollOffsetRows((offset) => Math.max(0, Math.min(maxOffset, offset + delta))); + } + } else if (mouse.kind === "wheel" && inCenterPane && inTranscriptRows) { + const delta = mouse.direction === "up" ? 3 : mouse.direction === "down" ? -3 : 0; + if (delta !== 0) { + // In a grid, scroll the tile under the cursor rather than only the + // focused one. ChatView clamps the upper bound per-tile, so a lower + // bound is enough here. + const grid = multiViewRef.current; + const TILE_PREFIX = "multi-chat:tile:"; + let scrolledTile = false; + if (grid && mouse.x != null && mouse.y != null) { + const hit = hitTestRegistryRef.current.hitTest(mouse.x, mouse.y); + const sessionId = hit?.id.startsWith(TILE_PREFIX) ? hit.id.slice(TILE_PREFIX.length) : null; + if (sessionId && sessionId !== focusedSessionIdForMultiView(grid)) { + setScrollBySessionId((prev) => ({ + ...prev, + [sessionId]: Math.max(0, (prev[sessionId] ?? 0) + delta), + })); + scrolledTile = true; + } + } + if (!scrolledTile) setChatScrollOffset((offset) => offset + delta); } } else if ( mouse.kind === "click" @@ -8086,18 +9445,144 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } const footerActive = footerControlRef.current != null; const textInputActive = (pane === "chat" && !footerActive) || detailsFormActive; - if (pane === "addMode" || addModeRef.current) { - if (key.escape) { - cancelAddMode(); + // Searchable /help command reference: filter type-ahead + ↑↓ navigation + ↵ + // run. Handled before the command palette so the help pane owns keystrokes + // while it is the active right pane. esc / Ctrl+K close it. + if (rightPane.kind === "help" && !isCtrlInput(input, key, "c")) { + const helpGroups = buildHelpRows(helpIndexGroups, helpFilterQuery, helpRecentsRef.current); + const helpFlatRows = flattenHelpRows(helpGroups); + const helpTotal = helpFlatRows.length; + if (key.escape || isCtrlInput(input, key, "k")) { + setHelpFilterQuery(""); + setHelpSelectedIndex(0); + setRightPane({ kind: "empty" }); + focusChat(); return; } - if (key.return) { - confirmAddMode(); + if (key.upArrow || (key.tab && key.shift)) { + const next = helpTotal === 0 ? 0 : (helpSelectedIndex - 1 + helpTotal) % helpTotal; + setHelpSelectedIndex(next); + renderHelpPane(helpFilterQuery, next, helpRecentsRef.current); return; } - if (key.upArrow || key.downArrow || key.leftArrow || key.rightArrow) { - if (key.upArrow) moveAddModeCursor("up"); - else if (key.downArrow) moveAddModeCursor("down"); + if (key.downArrow || key.tab) { + const next = helpTotal === 0 ? 0 : (helpSelectedIndex + 1) % helpTotal; + setHelpSelectedIndex(next); + renderHelpPane(helpFilterQuery, next, helpRecentsRef.current); + return; + } + if (key.return) { + const picked = helpFlatRows[helpSelectedIndex]; + if (picked) { + const nextRecents = pushRecent(helpRecentsRef.current, picked.name); + setHelpRecents(nextRecents); + setHelpFilterQuery(""); + setHelpSelectedIndex(0); + const parsed = parseCommand(picked.name, slashCommands); + const placement = parsed?.spec?.placement; + if (placement === "right") { + void runRightCommand(parsed!.name, "") + .catch((err) => addNotice(err instanceof Error ? err.message : String(err), "error")); + } else if (placement === "inline") { + setRightPane({ kind: "empty" }); + void runInlineCommand(parsed!.name, "") + .catch((err) => addNotice(err instanceof Error ? err.message : String(err), "error")); + } else { + // chat / unknown placement: seed the prompt so the user can complete it. + const draft = `${(parsed?.name ?? picked.name)} `; + setRightPane({ kind: "empty" }); + setPrompt(draft); + promptRef.current = draft; + chatDraftRef.current = draft; + focusChat(); + } + } + return; + } + if (isPromptLineBackspace(input, key) || key.backspace || key.delete) { + const nextQuery = isPromptLineBackspace(input, key) ? "" : helpFilterQuery.slice(0, -1); + setHelpFilterQuery(nextQuery); + setHelpSelectedIndex(0); + renderHelpPane(nextQuery, 0, helpRecentsRef.current); + return; + } + if (!key.ctrl && !key.meta) { + const suffix = printableInput(input); + if (suffix) { + const nextQuery = helpFilterQuery + suffix; + setHelpFilterQuery(nextQuery); + setHelpSelectedIndex(0); + renderHelpPane(nextQuery, 0, helpRecentsRef.current); + } + return; + } + return; + } + + if (commandPaletteOpen && !isCtrlInput(input, key, "c")) { + // Ctrl/Cmd+K toggles the palette shut (mirrors Esc) so the same chord + // opens and closes it — no need to reach for Escape. + if (key.escape || isCtrlInput(input, key, "k")) { + setCommandPaletteOpen(false); + setCommandPaletteQuery(""); + setCommandPaletteIndex(0); + return; + } + if (key.upArrow || (key.tab && key.shift)) { + setCommandPaletteIndex((index) => (commandPaletteItems.length ? (index - 1 + commandPaletteItems.length) % commandPaletteItems.length : 0)); + return; + } + if (key.downArrow || key.tab) { + setCommandPaletteIndex((index) => (commandPaletteItems.length ? (index + 1) % commandPaletteItems.length : 0)); + return; + } + if (key.return) { + void runCommandPaletteItem(commandPaletteItems[commandPaletteIndex] ?? commandPaletteItems[0]) + .catch((err) => addNotice(err instanceof Error ? err.message : String(err), "error")); + return; + } + if (isPromptLineBackspace(input, key)) { + setCommandPaletteQuery(""); + setCommandPaletteIndex(0); + return; + } + if (key.backspace || key.delete) { + setCommandPaletteQuery((query) => query.slice(0, -1)); + setCommandPaletteIndex(0); + return; + } + if (!key.ctrl && !key.meta) { + const suffix = printableInput(input); + if (suffix) { + setCommandPaletteQuery((query) => `${query}${suffix}`); + setCommandPaletteIndex(0); + } + return; + } + return; + } + + if (isCtrlInput(input, key, "k")) { + // Ctrl+K opens the command palette (it toggles shut via the + // command-palette branch above when the palette is already open). The + // searchable /help reference stays reachable via the `/help` slash command + // and the "help" entry inside the command palette. + openCommandPalette(); + return; + } + + if (pane === "addMode" || addModeRef.current) { + if (key.escape) { + cancelAddMode(); + return; + } + if (key.return) { + confirmAddMode(); + return; + } + if (key.upArrow || key.downArrow || key.leftArrow || key.rightArrow) { + if (key.upArrow) moveAddModeCursor("up"); + else if (key.downArrow) moveAddModeCursor("down"); else if (key.leftArrow) moveAddModeCursor("left"); else moveAddModeCursor("right"); return; @@ -8109,15 +9594,21 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } return; } - if (pane === "chat" && multiViewRef.current && isCtrlInput(input, key, "w")) { + if (pane === "chat" && gridViewActiveRef.current && multiViewRef.current && isCtrlInput(input, key, "w")) { removeMultiViewTile(multiViewRef.current.focusedIndex); return; } - if (pane === "chat" && multiViewRef.current && key.tab && !key.shift) { - setMultiView((prev) => prev - ? { ...prev, focusedIndex: (prev.focusedIndex + 1) % Math.max(1, prev.tiles.length) } - : prev); + if (pane === "chat" && gridViewActiveRef.current && multiViewRef.current && key.tab) { + // Tab focuses the next tile; Shift+Tab the previous. Scoped to a SHOWN grid + // so Shift+Tab still cycles permission mode in single-chat view and Ctrl+W + // doesn't remove a tile from a dormant (hidden) grid. + const direction = key.shift ? -1 : 1; + setMultiView((prev) => { + if (!prev) return prev; + const count = Math.max(1, prev.tiles.length); + return { ...prev, focusedIndex: (prev.focusedIndex + direction + count) % count }; + }); return; } @@ -8135,23 +9626,18 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } if (key.downArrow) { if (cell === "provider") cycleProvider(1); else if (cell === "model") cycleModel(1); + else if (cell === "fast") applyModelState((prev) => ({ ...prev, codexFastMode: !prev.codexFastMode })); else if (cell === "reasoning") cycleReasoning(1); else if (cell === "permission") cyclePermission(1); else if (cell === "subagents") openSubagentsPane(); return; } if (key.leftArrow || key.rightArrow) { - const fullOrder: Array<'provider' | 'model' | 'reasoning' | 'permission' | 'subagents'> = [ - "provider", - "model", - "reasoning", - "permission", - "subagents", - ]; - const order = fullOrder.filter((entry) => { - if (entry === "provider" && providerLockedRef.current) return false; - if (entry === "subagents" && !subagentsButtonVisibleRef.current) return false; - return true; + const order = inlineRowCellOrder({ + providerLocked: providerLockedRef.current, + fastSupported: footerFastSupportedRef.current, + reasoningSupported: footerReasoningSupportedRef.current, + subagentsVisible: subagentsButtonVisibleRef.current, }); const idx = cell ? order.indexOf(cell) : 0; const delta = key.rightArrow ? 1 : -1; @@ -8230,7 +9716,11 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } movePromptCursor(1); return; } - if (key.upArrow) { + // When the slash-command suggester or @-mention list is open, ↑/↓ belong + // exclusively to that palette (handled just below) — don't let cursor / + // history movement swallow them. + const slashOrMentionOpen = slashRows.length > 0 || (activeMentionRange != null && mentionSuggestions.length > 0); + if (key.upArrow && !slashOrMentionOpen) { if (prompt.length === 0 && attachedImageChips.length === 0) { recallPromptHistory("previous"); return; @@ -8239,15 +9729,28 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } return; } } - if (key.downArrow) { + if (key.downArrow && !slashOrMentionOpen) { movePromptCursorVerticalAndMaybeAttach(1); return; } } - if (pane === "chat" && textInputActive && (key.ctrl || key.meta) && (key.leftArrow || key.rightArrow)) { - movePromptCursor(key.leftArrow ? -1 : 1, "word"); - return; + if (pane === "chat" && textInputActive && (key.ctrl || key.meta)) { + // Option/Alt+Left/Right move by word. macOS sends these either as + // meta+arrow, or — with Option-as-Meta — as the emacs escape sequences + // ESC-b / ESC-f, which Ink surfaces as meta + input "b"/"f". Without this + // second case they fell through to the text-insert path and literally + // typed "b"/"f" instead of moving the cursor. + const optWordLeft = key.meta && !key.ctrl && input.toLowerCase() === "b"; + const optWordRight = key.meta && !key.ctrl && input.toLowerCase() === "f"; + if (key.leftArrow || optWordLeft) { + movePromptCursor(-1, "word"); + return; + } + if (key.rightArrow || optWordRight) { + movePromptCursor(1, "word"); + return; + } } if (pane === "chat") { @@ -8343,7 +9846,7 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } setDraftChatMode(false); setSelectedDrawerChatAction(null); clearChatPromptDraft(); - setRightPane((prev) => prev.kind === "new-chat-setup" ? { kind: "empty" } : prev); + setRightPane((prev) => prev.kind === "model-picker" && prev.surface === "new-chat" ? { kind: "empty" } : prev); setRightOpen(false); lastUserOpenedPaneRef.current = null; userDismissedRightPaneRef.current = true; @@ -8407,6 +9910,20 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } if (runKeybindingAction("app:copyAdeDeeplink")) return; } + if ( + !textInputActive + && !footerActive + && !key.ctrl + && !key.meta + && (input === "[" || input === "]") + // The model picker consumes [ ] for provider tabs (and as search text), so + // don't hijack them for lane cycling while it's focused. + && rightPane.kind !== "model-picker" + ) { + cycleActiveLane(input === "]" ? 1 : -1); + return; + } + if (footerActive) { if (key.leftArrow || key.rightArrow) { cycleFooterControl(key.rightArrow ? 1 : -1); @@ -8433,9 +9950,9 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } } if (key.backspace || key.delete) { selectFooterControl(null); - const next = key.delete && !key.backspace - ? deletePromptForward(prompt, promptCursorRef.current) - : deletePromptBackward(prompt, promptCursorRef.current); + // See the prompt handler below: Ink can't distinguish ⌫ from forward- + // delete, so always delete backward. + const next = deletePromptBackward(prompt, promptCursorRef.current); handlePromptChange(next.value, next.cursor); return; } @@ -8451,7 +9968,7 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } } if (pane === "chat" && textInputActive && isCtrlInput(input, key, "r")) { - openHistorySearch(); + recallPromptHistory("previous"); return; } @@ -8461,7 +9978,7 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } } if (pane === "chat" && isCtrlInput(input, key, "g")) { - startAddMode(); + toggleGridView(); return; } @@ -8524,19 +10041,7 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } return; } if (escapeAction.kind === "return-new-chat") { - const laneId = activeLaneIdRef.current; - const lane = laneId ? lanes.find((entry) => entry.id === laneId) : null; - if (lane) { - setRightPane({ - kind: "new-chat-setup", - laneId: lane.id, - laneLabel: lane.name, - rows: newChatSetupRows, - }); - setRightOpen(true); - setPaneFocus("details"); - return; - } + if (confirmOrDiscardChatDraft()) return; } setRightPane({ kind: "empty" }); setRightOpen(false); @@ -8559,7 +10064,7 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } return; } if (pane === "details" && rightOpen) { - if (rightPane.kind === "new-chat-setup" && confirmOrDiscardChatDraft()) { + if (rightPane.kind === "model-picker" && rightPane.surface === "new-chat" && confirmOrDiscardChatDraft()) { return; } if (rightPane.kind === "form") { @@ -8632,11 +10137,21 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } return; } - if (pendingApproval?.mode === "approval" && !pendingApproval.highStakes && (input === "a" || input === "d")) { - void resolvePendingApproval(pendingApproval, input === "a" ? "accept" : "decline") + if (pendingApproval?.mode === "approval" && !pendingApproval.highStakes && ["a", "d", "y", "n"].includes(input)) { + void resolvePendingApproval(pendingApproval, input === "a" || input === "y" ? "accept" : "decline") .catch((err) => addNotice(err instanceof Error ? err.message : String(err), "error")); return; } + if (pendingApproval?.mode === "question" && /^[1-6]$/.test(input)) { + const question = pendingApproval.request?.questions[0] ?? null; + const options = question?.options?.length ? question.options : pendingApproval.request?.options ?? []; + const option = options[Number(input) - 1] ?? null; + if (option) { + void answerPendingInput(pendingApproval, option.value) + .catch((err) => addNotice(err instanceof Error ? err.message : String(err), "error")); + return; + } + } if (pane === "details" && rightOpen && rightPane.kind === "form" && rightPane.command === "lane-delete") { const fields = rightPane.fields; @@ -8676,6 +10191,72 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } } } + // Feedback form: dedicated multiline editing handled ABOVE the generic form + // key handlers so the body gets a real text cursor (the shared prompt-input + // primitive) while type/context/validation/serialization go through + // feedbackForm.ts. Left/right cycle the type; Ctrl+T toggles the context + // footer; Ctrl+S submits; Enter inserts a newline; pasted blocks keep their + // embedded newlines and tabs verbatim (preserveMultiline). + if (pane === "details" && rightOpen && rightPane.kind === "form" && rightPane.command === "feedback") { + const form = rightPane; + const meta = form.feedback ?? {}; + // While the success check is showing, swallow keys (auto-close handles exit). + if (meta.feedback === "submitted") return; + const updateFeedback = (patch: Partial) => { + setRightPane({ ...form, feedback: { ...meta, ...patch } }); + setFormDiscardArmed(false); + }; + if (key.escape) { + if (formDiscardArmedRef.current) { + setFormDiscardArmed(false); + setFormValues({}); + setFormFieldIndex(0); + setRightPane({ kind: "empty" }); + setRightOpen(false); + focusAfterDetails(); + return; + } + setFormDiscardArmed(true); + return; + } + if (isCtrlInput(input, key, "s")) { + const state = feedbackStateFromMeta(meta); + if (!feedbackFormCanSubmit(state)) { + addNotice("Add some feedback before sending.", "error"); + return; + } + void submitRightForm(form, currentFormValues()) + .catch((err) => addNotice(err instanceof Error ? err.message : String(err), "error")); + return; + } + if (isCtrlInput(input, key, "t")) { + updateFeedback({ showContext: meta.showContext === false }); + return; + } + if (key.leftArrow || key.rightArrow) { + const nextType = cycleFeedbackType(feedbackStateFromMeta(meta).type, key.leftArrow ? -1 : 1); + updateFeedback({ type: nextType }); + return; + } + if (key.return) { + updateFeedback({ body: `${meta.body ?? ""}\n` }); + return; + } + const currentBody = meta.body ?? ""; + // preserveMultiline keeps embedded newlines and tabs from a pasted block so + // the body stays verbatim (a real Enter keypress is handled above). + const edit = applyCoalescedPromptInput(currentBody, currentBody.length, input, true); + if (edit.value !== currentBody) { + updateFeedback({ body: edit.value }); + return; + } + if (key.backspace || key.delete) { + if (currentBody.length > 0) updateFeedback({ body: currentBody.slice(0, -1) }); + return; + } + return; + } + if (pane === "details" && rightOpen && rightPane.kind === "form" && (key.upArrow || key.downArrow || key.return)) { const fields = rightPane.fields; const nextValues = currentFormValues(); @@ -8728,40 +10309,6 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } return; } - if ( - pane === "details" - && rightOpen - && (rightPane.kind === "new-chat-setup" || rightPane.kind === "model-setup") - && (key.upArrow || key.downArrow || key.leftArrow || key.rightArrow || key.return) - ) { - const rows = rightPane.rows; - const totalRows = rows.length; - if (key.upArrow || key.downArrow) { - const delta = key.upArrow ? -1 : 1; - setRightSelectionIndex((index) => totalRows ? (index + delta + totalRows) % totalRows : 0); - return; - } - if (key.return) { - // Enter on the model row opens the rich picker (favorites/recents/providers). - // Other rows still fall through to "apply" for parity with the prior flow. - const focusedRow = rows[rightSelectionIndex]; - if (focusedRow?.kind === "model" && !focusedRow.disabled) { - openModelPicker({ surface: modelPickerSurfaceForSetupPane(rightPane.kind) }); - return; - } - const applyRow = rows.find((entry) => entry.kind === "apply"); - if (applyRow) handleSetupRow(applyRow, 1); - return; - } - if (rightSelectionIndex >= rows.length) { - return; - } - const row = rows[rightSelectionIndex] ?? rows[0]; - if (!row) return; - handleSetupRow(row, key.leftArrow ? -1 : 1); - return; - } - if (pane === "details" && rightOpen && rightPane.kind === "model-picker") { const picker = rightPane; // Re-derive layout each keystroke so we never select stale indexes. @@ -8769,93 +10316,110 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } models, catalog: modelCatalogRef.current ?? modelCatalog, favorites: modelPickerFavorites, - recents: modelPickerRecents, - activeModelId: modelState.modelId, - query: picker.query, + recents: modelPickerRecents, + activeModelId: modelState.modelId, + activeReasoningEffort: modelState.reasoningEffort, + aiStatus, + showAll: picker.showAll, + settingsRows: picker.settingsRows ?? [], + footerFocus: picker.footerFocus ?? null, + laneLabel: picker.laneLabel ?? null, + query: picker.query, selection: picker.selection, providerTabKey: picker.providerTabKey ?? null, focusedIndex: picker.focusedIndex, searchMode: picker.searchMode, }); + const pickerSettingsRows = (picker.settingsRows ?? []).filter((row) => row.kind !== "provider" && row.kind !== "model"); + const lastModelIndex = Math.max(0, layout.entries.length - 1); + // Unified, single vertical flow: ↑/↓ runs the model list → the settings + // rows → the Confirm button. footerFocus !== null means focus is in the + // settings region; otherwise it's in the model list. + const inSettings = picker.footerFocus != null && pickerSettingsRows.length > 0; + const settingIndex = inSettings + ? Math.max(0, pickerSettingsRows.findIndex((row) => row.kind === picker.footerFocus)) + : -1; + + // Switch the provider/category rail. Tab does this from anywhere; ←/→ does + // it while focus is in the model list (where there's no value to cycle). + const switchRail = (delta: -1 | 1) => { + const total = layout.railEntries.length; + if (total === 0) return; + const nextIndex = (layout.railIndex + delta + total) % total; + const nextEntry = layout.railEntries[nextIndex]; + if (!nextEntry) return; + const nextSelection = + nextEntry.kind === "favorites" + ? ({ kind: "favorites" } as const) + : nextEntry.kind === "recents" + ? ({ kind: "recents" } as const) + : ({ kind: "provider", provider: nextEntry.provider } as const); + if (nextSelection.kind === "provider") { + const refreshProvider = + nextSelection.provider === "opencode" || nextSelection.provider === "cursor" || nextSelection.provider === "droid" + || nextSelection.provider === "lmstudio" || nextSelection.provider === "ollama" + ? nextSelection.provider + : null; + if (refreshProvider) void refreshModelCatalog({ refreshProvider }); + } + setRightPane({ ...picker, selection: nextSelection, providerTabKey: null, focusedIndex: 0, footerFocus: null, query: "", searchMode: false }); + }; + + if (key.tab) { + switchRail(key.shift ? -1 : 1); + return; + } if (key.upArrow) { - setRightPane((prev) => { - if (prev.kind !== "model-picker") return prev; - const currentLayout = buildModelPickerLayout({ - models, - catalog: modelCatalogRef.current ?? modelCatalog, - favorites: modelPickerFavorites, - recents: modelPickerRecents, - activeModelId: modelState.modelId, - query: prev.query, - selection: prev.selection, - providerTabKey: prev.providerTabKey ?? null, - focusedIndex: prev.focusedIndex, - searchMode: prev.searchMode, - }); - const next = Math.max(0, currentLayout.focusedIndex - 1); - return next === prev.focusedIndex ? prev : { ...prev, focusedIndex: next }; - }); + if (inSettings) { + if (settingIndex <= 0) setRightPane({ ...picker, footerFocus: null }); + else setRightPane({ ...picker, footerFocus: pickerSettingsRows[settingIndex - 1]?.kind ?? null }); + return; + } + const next = Math.max(0, layout.focusedIndex - 1); + if (next !== picker.focusedIndex) setRightPane({ ...picker, focusedIndex: next }); return; } if (key.downArrow) { - setRightPane((prev) => { - if (prev.kind !== "model-picker") return prev; - const currentLayout = buildModelPickerLayout({ - models, - catalog: modelCatalogRef.current ?? modelCatalog, - favorites: modelPickerFavorites, - recents: modelPickerRecents, - activeModelId: modelState.modelId, - query: prev.query, - selection: prev.selection, - providerTabKey: prev.providerTabKey ?? null, - focusedIndex: prev.focusedIndex, - searchMode: prev.searchMode, - }); - const maxIndex = Math.max(0, currentLayout.entries.length - 1); - const next = Math.min(maxIndex, currentLayout.focusedIndex + 1); - return next === prev.focusedIndex ? prev : { ...prev, focusedIndex: next }; - }); + if (inSettings) { + if (settingIndex < pickerSettingsRows.length - 1) { + setRightPane({ ...picker, footerFocus: pickerSettingsRows[settingIndex + 1]?.kind ?? null }); + } + return; + } + // Past the last model, drop focus down into the settings rows. + if (layout.focusedIndex >= lastModelIndex && pickerSettingsRows.length > 0) { + setRightPane({ ...picker, footerFocus: pickerSettingsRows[0]?.kind ?? null }); + return; + } + const next = Math.min(lastModelIndex, layout.focusedIndex + 1); + if (next !== picker.focusedIndex) setRightPane({ ...picker, focusedIndex: next }); return; } - if (key.tab || (key.shift && key.tab)) { - const total = layout.railEntries.length; - if (total === 0) return; - const delta = key.shift ? -1 : 1; - const nextIndex = (layout.railIndex + delta + total) % total; - const nextEntry = layout.railEntries[nextIndex]; - if (!nextEntry) return; - const nextSelection = - nextEntry.kind === "favorites" - ? ({ kind: "favorites" } as const) - : nextEntry.kind === "recents" - ? ({ kind: "recents" } as const) - : ({ kind: "provider", provider: nextEntry.provider } as const); - if (nextSelection.kind === "provider") { - const refreshProvider = - nextSelection.provider === "opencode" || nextSelection.provider === "cursor" || nextSelection.provider === "droid" - || nextSelection.provider === "lmstudio" || nextSelection.provider === "ollama" - ? nextSelection.provider - : null; - if (refreshProvider) void refreshModelCatalog({ refreshProvider }); - } - setRightPane({ - ...picker, - selection: nextSelection, - providerTabKey: null, - focusedIndex: 0, - query: "", - searchMode: false, - }); + if (key.leftArrow || key.rightArrow) { + if (inSettings) { + const row = pickerSettingsRows[settingIndex]; + if (row) handleSetupRow(row, key.leftArrow ? -1 : 1); + return; + } + switchRail(key.leftArrow ? -1 : 1); return; } - if (key.return) { - const target = layout.entries[layout.focusedIndex]; - if (target?.isAvailable) commitModelPickerSelection(target.modelId); - return; - } - if ((input === "[" || input === "]") && layout.providerTabs.length > 1) { + if (key.return) { + if (inSettings) { + const row = pickerSettingsRows[settingIndex]; + if (row) handleSetupRow(row, 1); + return; + } + const target = layout.entries[layout.focusedIndex]; + if (target?.isAvailable) commitModelPickerSelection(target.modelId); + return; + } + if (input === "s" && !picker.searchMode && !key.ctrl && !key.meta) { + setRightPane({ ...picker, showAll: !picker.showAll, focusedIndex: 0 }); + return; + } + if ((input === "[" || input === "]") && !picker.searchMode && layout.providerTabs.length > 1) { const delta = input === "[" ? -1 : 1; const nextIndex = (layout.providerTabIndex + delta + layout.providerTabs.length) % layout.providerTabs.length; const nextTab = layout.providerTabs[nextIndex]; @@ -8887,9 +10451,12 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } }); return; } - // Plain printable input either starts or extends the query. + // Printable input extends the query only once search is active (entered + // via '/'), so single-letter shortcuts (s/f) and bracket tab-cycling stay + // usable while browsing and aren't swallowed as the first search char. if ( - typeof input === "string" + picker.searchMode + && typeof input === "string" && input.length === 1 && !key.ctrl && !key.meta @@ -8949,25 +10516,7 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } } if (laneDetails.pr) { const url = laneDetails.pr.url; - const bridge = (globalThis as { window?: { ade?: { app?: { openExternal?: (url: string) => unknown } } } }).window; - const opener = bridge?.ade?.app?.openExternal; - if (typeof opener === "function") { - try { - opener(url); - addNotice("Opening PR in browser…", "info"); - return; - } catch { - // fall through to platform open - } - } - if (process.platform === "darwin" && url) { - spawn("open", [url], { stdio: "ignore", detached: true }).unref(); - addNotice("Opening PR in browser…", "info"); - return; - } - if (process.platform === "linux" && url) { - spawn("xdg-open", [url], { stdio: "ignore", detached: true }).unref(); - addNotice("Opening PR in browser…", "info"); + if (url && openExternalUrl(url, addNotice)) { return; } setPrompt("/pr open"); @@ -8995,13 +10544,7 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } if (rightPane.action.kind === "switch-lane") { const lane = lanes.find((entry) => entry.id === selectedId); if (!lane) return; - selectActiveLaneId(lane.id); - setDrawerLaneId(lane.id); - setSelectedDrawerLaneId(lane.id); - const session = newestSession(displaySessions.filter((entry) => entry.laneId === lane.id)); - selectActiveSessionId(session?.sessionId ?? null); - setSelectedDrawerChatId(session?.sessionId ?? null); - addNotice(`Switched to lane ${lane.name}.`, "success"); + activateLaneWithLastChat(lane, { notify: true }); return; } const session = displaySessions.find((entry) => entry.sessionId === selectedId); @@ -9079,6 +10622,45 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } return; } + // Per-lane hotkeys on the selected lane card: r=rename, a=archive, x=delete. + // Skips the primary lane and the "+ new lane" action row, and ignores + // modified keystrokes (e.g. Ctrl+R history search). + if ( + pane === "drawer" + && drawerOpen + && drawerSection === "lanes" + && selectedDrawerLaneAction !== "new-lane" + && !key.ctrl + && !key.meta + && (input === "r" || input === "R" || input === "a" || input === "A" || input === "x" || input === "X") + ) { + const selectedLane = drawerLaneRows[selectedLaneIndex] ?? null; + if (selectedLane && selectedLane.laneType !== "primary") { + const hotkey = input.toLowerCase(); + if (hotkey === "r") openLaneRenameForm(selectedLane.id); + else if (hotkey === "a") void archiveLane(selectedLane.id); + else openLaneDeleteForm(selectedLane.id); + return; + } + } + if ( + pane === "drawer" + && drawerOpen + && drawerSection === "chats" + && selectedDrawerChatAction !== "new-chat" + && !key.ctrl + && !key.meta + && (input === "r" || input === "R" || input === "a" || input === "A" || input === "x" || input === "X") + ) { + const selectedChat = drawerVisibleLaneSessions[selectedChatIndex] ?? null; + if (selectedChat && sessions.some((session) => session.sessionId === selectedChat.sessionId)) { + const hotkey = input.toLowerCase(); + if (hotkey === "r") openChatRenameForm(selectedChat.sessionId); + else if (hotkey === "a") void archiveChat(selectedChat.sessionId); + else openChatDeleteForm(selectedChat.sessionId); + return; + } + } if (pane === "drawer" && drawerOpen && key.tab) { setDrawerSection((section) => section === "lanes" ? "chats" : "lanes"); return; @@ -9246,16 +10828,22 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } return; } if (textInputActive && (key.backspace || key.delete)) { - const next = key.delete && !key.backspace - ? deletePromptForward(prompt, promptCursorRef.current) - : deletePromptBackward(prompt, promptCursorRef.current); + // Ink 5.x reports the macOS Backspace key (\x7f) as key.delete — and it + // can't tell it apart from forward-delete (\x1b[3~): both arrive as + // key.delete with empty input. So always delete backward, which is what + // pressing ⌫ should do. (Forward-delete is unrecoverable here and rare in + // a chat prompt.) This is THE fix for "backspace does nothing". + const next = deletePromptBackward(prompt, promptCursorRef.current); handlePromptChange(next.value, next.cursor); return; } if (textInputActive && !key.ctrl && input) { - const suffix = printableInput(input); - if (suffix) { - const next = insertPromptText(prompt, promptCursorRef.current, suffix); + // Segment the chunk so a coalesced "type + backspace" burst (which Ink + // hands us as one chunk with no backspace flag) applies its deletes + // instead of dropping them. Pure-printable input inserts as before. + const cursor = promptCursorRef.current; + const next = applyCoalescedPromptInput(prompt, cursor, input); + if (next.value !== prompt || next.cursor !== cursor) { handlePromptChange(next.value, next.cursor); } } @@ -9264,7 +10852,7 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } const handlePromptChange = useCallback((value: string, cursor: number = value.length) => { setFormDiscardArmed(false); if (activePaneRef.current === "chat" && value === "?") { - setRightPane({ kind: "help", title: "Help" }); + renderHelpPane("", 0, helpRecentsRef.current); focusDetails(); setPromptValue(""); return; @@ -9333,14 +10921,17 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } } if (delta > 0 && isPromptCursorOnLastVisualRow(prompt, width, current)) { setAttachmentFocusIndex(null); - setInlineRowFocus({ cell: providerLockedRef.current ? "model" : "provider" }); + // Down past the last prompt row opens the full model picker pane. The + // footer inline model row mirrors the same `modelState`, so both surfaces + // stay in sync live; surface follows whether we're drafting a new chat. + openModelPicker({ surface: activeSessionIdRef.current ? "chat" : "new-chat" }); return; } const next = movePromptCursorVertical(prompt, width, current, delta); promptCursorRef.current = next; setPromptCursor(next); setAttachmentFocusIndex(null); - }, [attachedImageChips.length, prompt, promptPaneWidth]); + }, [attachedImageChips.length, openModelPicker, prompt, promptPaneWidth]); const rightPaneVisible = rightPaneWidth > 0; const laneName = activeLane?.name ?? "main"; @@ -9353,6 +10944,7 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } const detailsFooterSelected = footerControl === "details"; const agentsFooterSelected = footerControl === "agents"; const rightPaneShowsAgents = rightPaneVisible && rightPane.kind === "chat-info"; + const showCommandPalette = commandPaletteOpen; const showMentionPalette = activeMentionRange != null && mentionSuggestions.length > 0; const showSlashPalette = prompt.startsWith("/") && slashRows.length > 0; const paletteBottomRows = 5 @@ -9360,11 +10952,30 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } + modelStatusOverlayRows + (attachedImageChips.length ? 1 : 0) + (error ? 1 : 0); - const paletteOverlayRows = showMentionPalette ? MENTION_PALETTE_ROWS : SLASH_PALETTE_ROWS; + // Slash palette grows with available terminal height (clamped) so it's bigger + // on large screens. Reserve exactly what it will render so it lines up. + const slashPaletteHeightBudget = Math.max(8, Math.min(17, rows - paletteBottomRows - 4)); + const paletteOverlayRows = showCommandPalette + ? COMMAND_PALETTE_ROWS + : showMentionPalette + ? MENTION_PALETTE_ROWS + : slashPaletteReservedRows(slashPaletteHeightBudget); const paletteOverlayTop = Math.max(1, rows - paletteBottomRows - paletteOverlayRows); const drawerPaneWidth = resolveDrawerPaneWidth(columns, drawerOpen); const paletteOverlayLeft = drawerPaneWidth; const paletteOverlayWidth = Math.max(MIN_CENTER_PANE_WIDTH, centerWidth); + const commandPaletteVisibleRows = Math.max(1, COMMAND_PALETTE_ROWS - 3); + const commandPaletteWindowStart = (() => { + const total = commandPaletteItems.length; + if (total <= commandPaletteVisibleRows) return 0; + const safeIndex = Math.max(0, Math.min(commandPaletteIndex, total - 1)); + const half = Math.floor(commandPaletteVisibleRows / 2); + let start = Math.max(0, safeIndex - half); + if (start + commandPaletteVisibleRows > total) { + start = Math.max(0, total - commandPaletteVisibleRows); + } + return start; + })(); // Drawer selected-chat index: in add-mode the cursor tracks the candidate // chat to add; otherwise we only highlight when the drawer is on the chats // section, leaving lane rows un-marked. @@ -9450,14 +11061,18 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } }); footerX += width; } - if (modelState.codexFastMode) { + if (footerFastSupported) { footerX += 2; - addFooterInlineTarget("footer:inline:fast", footerX, "fast".length, () => { - void runKeybindingAction("chat:fastMode"); + const width = footerCellWidth("fast", "fast"); + addFooterInlineTarget("footer:inline:fast", footerX, width, () => { + selectFooterControl(null); + setPaneFocus("chat"); + setInlineRowFocus({ cell: "fast" }); + applyModelState((prev) => ({ ...prev, codexFastMode: !prev.codexFastMode })); }); - footerX += "fast".length; + footerX += width; } - if (modelState.reasoningEffort) { + if (footerReasoningSupported && modelState.reasoningEffort) { footerX += 2; const width = footerCellWidth(modelState.reasoningEffort, "reasoning"); addFooterInlineTarget("footer:inline:reasoning", footerX, width, () => { @@ -9521,6 +11136,7 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } const cell = inlineRowFocus.cell; if (cell === "provider") cycleProvider(1); else if (cell === "model") cycleModel(1); + else if (cell === "fast") applyModelState((prev) => ({ ...prev, codexFastMode: !prev.codexFastMode })); else if (cell === "reasoning") cycleReasoning(1); else if (cell === "permission") cyclePermission(1); else if (cell === "subagents") openSubagentsPane(); @@ -9537,8 +11153,8 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } } rightFooterItems.push({ id: "footer:split", - label: multiView ? "^g add chat" : "^g split", - onClick: () => startAddMode(), + label: gridViewActive ? "^g add chat" : multiView ? "^g grid" : "^g split", + onClick: () => toggleGridView(), }); if (multiView) { rightFooterItems.push( @@ -9718,7 +11334,24 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } } } - if (showMentionPalette) { + if (showCommandPalette) { + commandPaletteItems + .slice(commandPaletteWindowStart, commandPaletteWindowStart + commandPaletteVisibleRows) + .forEach((item, visibleIndex) => { + const index = commandPaletteWindowStart + visibleIndex; + addTarget({ + id: `command-palette:${index}`, + rect: { x: paletteOverlayLeft + 1, y: paletteOverlayTop + visibleIndex + 1, w: paletteOverlayWidth, h: 1 }, + onClick: () => { + setCommandPaletteIndex(index); + void runCommandPaletteItem(item) + .catch((err) => addNotice(err instanceof Error ? err.message : String(err), "error")); + }, + onHover: (hovered) => { if (hovered) setCommandPaletteIndex(index); }, + zIndex: 22, + }); + }); + } else if (showMentionPalette) { mentionSuggestions.forEach((suggestion, index) => { addTarget({ id: `mention:${index}`, @@ -9766,9 +11399,10 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } }); } else if (pendingApproval?.mode === "question") { const question = pendingApproval.request?.questions[0] ?? null; + const options = question?.options?.length ? question.options : pendingApproval.request?.options ?? []; const centerStart = drawerPaneWidth + 1; const optionStartY = Math.max(1, 4 + goalBannerRows + addModeRows + chatRowBudget - 2); - question?.options?.slice(0, 6).forEach((option, index) => { + options.slice(0, 6).forEach((option, index) => { addTarget({ id: `approval:question-option:${option.value}:${index}`, rect: { x: centerStart + 1, y: optionStartY + index, w: Math.max(12, centerWidth - 2), h: 1 }, @@ -9792,29 +11426,46 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } favorites: modelPickerFavorites, recents: modelPickerRecents, activeModelId: modelState.modelId, + activeReasoningEffort: modelState.reasoningEffort, + aiStatus, + showAll: picker.showAll, + settingsRows: picker.settingsRows ?? [], + footerFocus: picker.footerFocus ?? null, + laneLabel: picker.laneLabel ?? null, query: picker.query, selection: picker.selection, providerTabKey: picker.providerTabKey ?? null, focusedIndex: picker.focusedIndex, searchMode: picker.searchMode, }); - const visibleCapacity = 12; - const half = Math.floor(visibleCapacity / 2); - let windowStart = Math.max(0, layout.focusedIndex - half); - if (windowStart + visibleCapacity > layout.entries.length) { - windowStart = Math.max(0, layout.entries.length - visibleCapacity); - } - const windowEnd = Math.min(layout.entries.length, windowStart + visibleCapacity); + // Single geometry source: derive every clickable rect from the SAME + // constants + windowing the render uses (modelPickerGeometry), so a + // click always lands on the row the user sees — even when scrolled. + const geometry = modelPickerGeometry({ + paneLeft: rightStartColumn, + paneTop: rightBodyTop, + paneWidth: rightPaneWidth, + state: layout, + rows, + }); addTarget({ id: "right:model-picker:search", - rect: { x: rightStartColumn, y: rightBodyTop + 1, w: rightPaneWidth, h: 1 }, + rect: geometry.search, onClick: () => setRightPane({ ...picker, searchMode: true, query: picker.query, focusedIndex: 0 }), zIndex: 4, }); - layout.railEntries.forEach((entry, index) => { + addTarget({ + id: "right:model-picker:show-all", + rect: geometry.showAll, + onClick: () => setRightPane({ ...picker, showAll: !picker.showAll, focusedIndex: 0 }), + zIndex: 3, + }); + geometry.rail.forEach(({ id, rect }, index) => { + const entry = layout.railEntries[index]; + if (!entry) return; addTarget({ - id: `right:model-picker:rail:${index}`, - rect: { x: rightStartColumn, y: rightBodyTop + 6 + index, w: Math.max(8, Math.floor(rightPaneWidth / 4)), h: 1 }, + id, + rect, onClick: () => { const nextSelection = railEntrySelection(entry); if (nextSelection.kind === "provider") { @@ -9837,36 +11488,54 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } zIndex: 4, }); }); - layout.providerTabs.forEach((tab, index) => { + geometry.favorites.forEach(({ modelId, rect }) => { addTarget({ - id: `right:model-picker:provider-tab:${tab.key}`, - rect: { x: rightStartColumn + Math.floor(rightPaneWidth / 4) + 1 + index * 13, y: rightBodyTop + 7, w: 13, h: 1 }, - onClick: () => setRightPane({ ...picker, providerTabKey: tab.key, focusedIndex: 0 }), - zIndex: 4, + id: `right:model-picker:favorite:${modelId}`, + rect, + onClick: () => toggleModelPickerFavoriteId(modelId), + zIndex: 6, }); }); - let modelEntryY = rightBodyTop + 8; - layout.entries.slice(windowStart, windowEnd).forEach((entry, sliceIndex) => { - const index = windowStart + sliceIndex; - const rowHeight = entry.subProvider || !entry.isAvailable ? 2 : 1; - const y = modelEntryY; + geometry.entries.forEach(({ id, index, modelId, rect }) => { + const entry = layout.entries[index]; addTarget({ - id: `right:model-picker:favorite:${entry.modelId}`, - rect: { x: rightStartColumn + Math.floor(rightPaneWidth / 4) + 2, y, w: 3, h: 1 }, - onClick: () => toggleModelPickerFavoriteId(entry.modelId), - zIndex: 6, + id, + rect, + onClick: () => { + setRightPane({ ...picker, focusedIndex: index }); + if (entry?.isAvailable) commitModelPickerSelection(modelId); + }, + zIndex: 5, }); + }); + geometry.settings.forEach(({ id, rect }, index) => { + const row = layout.settingsRows + .filter((r) => r.kind !== "provider" && r.kind !== "model" && r.kind !== "apply")[index]; + if (!row) return; addTarget({ - id: `right:model-picker:entry:${entry.modelId}`, - rect: { x: rightStartColumn + Math.floor(rightPaneWidth / 4) + 1, y, w: Math.max(10, rightPaneWidth - Math.floor(rightPaneWidth / 4) - 2), h: rowHeight }, + id, + rect, onClick: () => { - setRightPane({ ...picker, focusedIndex: index }); - if (entry.isAvailable) commitModelPickerSelection(entry.modelId); + setRightPane({ ...picker, footerFocus: row.kind }); + handleSetupRow(row, 1); }, zIndex: 5, }); - modelEntryY += rowHeight; }); + if (geometry.apply) { + const applyRow = layout.settingsRows.find((r) => r.kind === "apply"); + if (applyRow) { + addTarget({ + id: "right:model-picker:setting:apply", + rect: geometry.apply, + onClick: () => { + setRightPane({ ...picker, footerFocus: "apply" }); + handleSetupRow(applyRow, 1); + }, + zIndex: 6, + }); + } + } } else if (rightPane.kind === "chat-info") { const subagentContent = subagentPaneContentFromRightPane(rightPane); const subagentPaneTop = 4 + goalBannerRows + addModeRows; @@ -9929,20 +11598,39 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } zIndex: 3, }); } - } else if (rightPane.kind === "new-chat-setup" || rightPane.kind === "model-setup") { - const firstRow = rightPane.kind === "new-chat-setup" ? rightBodyTop + 5 : rightBodyTop + 4; - rightPane.rows.forEach((row, index) => { - const y = firstRow + index + (index === rightSelectionIndex ? 1 : 0); - addTarget({ - id: `right:setup:${row.kind}:${index}`, - rect: { x: rightStartColumn, y, w: rightPaneWidth, h: index === rightSelectionIndex && row.detail ? 2 : 1 }, - onClick: () => { - setRightSelectionIndex(index); - if (row.kind === "model" && !row.disabled) openModelPicker({ surface: modelPickerSurfaceForSetupPane(rightPane.kind) }); - else if (row.kind === "apply") handleSetupRow(row, 1); - }, - zIndex: 3, - }); + } else if (rightPane.kind === "form" && rightPane.command === "feedback") { + // Feedback pane hit-rects (left-click only): the Type row cycles + // bug/idea/praise; the send row submits. Rows mirror FeedbackFormPane + // (Type, blank, Body label + body lines, context block, footer w/ [send]). + const fb = rightPane.feedback ?? {}; + const bodyLines = Math.max(1, fb.body && fb.body.length ? fb.body.split("\n").length : 1); + const contextRows = fb.showContext === false ? 1 : 4; + addTarget({ + id: "right:feedback:type", + rect: { x: rightStartColumn, y: rightBodyTop, w: rightPaneWidth, h: 1 }, + onClick: () => { + const meta = rightPane.feedback ?? {}; + const nextType = cycleFeedbackType(feedbackStateFromMeta(meta).type, 1); + setRightPane({ ...rightPane, feedback: { ...meta, type: nextType } }); + setFormDiscardArmed(false); + focusDetails(); + }, + zIndex: 3, + }); + const sendY = rightBodyTop + 2 + bodyLines + 1 + contextRows + 1; + addTarget({ + id: "right:feedback:send", + rect: { x: rightStartColumn, y: sendY, w: rightPaneWidth, h: 1 }, + onClick: () => { + const state = feedbackStateFromMeta(rightPane.feedback ?? {}); + if (!feedbackFormCanSubmit(state)) { + addNotice("Add some feedback before sending.", "error"); + return; + } + void submitRightForm(rightPane, formValues) + .catch((err) => addNotice(err instanceof Error ? err.message : String(err), "error")); + }, + zIndex: 3, }); } else if (rightPane.kind === "form") { rightPane.fields.forEach((field, index) => { @@ -9975,10 +11663,16 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } }); }); } else if (rightPane.kind === "list" && rightPane.action) { - rightPane.rows.forEach((_, index) => { + // Clamp to match RightPane's list rendering so click targets align with + // the visible rows even after a stale offset. + const listStart = Math.max(0, Math.min(rightPaneScrollOffsetRows, Math.max(0, rightPane.rows.length - DETAILS_BODY_MAX_LINES))); + rightPane.rows + .slice(listStart, listStart + DETAILS_BODY_MAX_LINES) + .forEach((_, visibleIndex) => { + const index = listStart + visibleIndex; addTarget({ id: `right:list:${index}`, - rect: { x: rightStartColumn, y: rightBodyTop + 2 + index, w: rightPaneWidth, h: 1 }, + rect: { x: rightStartColumn, y: rightBodyTop + 2 + visibleIndex, w: rightPaneWidth, h: 1 }, onClick: () => { setRightSelectionIndex(index); const selectedId = rightPane.action?.ids[index] ?? null; @@ -9986,13 +11680,7 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } if (rightPane.action.kind === "switch-lane") { const lane = lanes.find((entry) => entry.id === selectedId); if (!lane) return; - selectActiveLaneId(lane.id); - setDrawerLaneId(lane.id); - setSelectedDrawerLaneId(lane.id); - const session = newestSession(displaySessions.filter((entry) => entry.laneId === lane.id)); - selectActiveSessionId(session?.sessionId ?? null); - setSelectedDrawerChatId(session?.sessionId ?? null); - addNotice(`Switched to lane ${lane.name}.`, "success"); + activateLaneWithLastChat(lane, { notify: true }); return; } const session = displaySessions.find((entry) => entry.sessionId === selectedId); @@ -10022,11 +11710,15 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } addNotice, addTileToGrid, answerPendingInput, + activateLaneWithLastChat, applyDrawerChatSelection, centerWidth, chatRowBudget, columns, commitModelPickerSelection, + commandPaletteItems, + commandPaletteVisibleRows, + commandPaletteWindowStart, cycleModel, cyclePermission, cycleProvider, @@ -10068,11 +11760,13 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } promptRows.length, removeMultiViewTile, rightPane, + rightPaneScrollOffsetRows, rightPaneVisible, rightPaneWidth, resolvePendingApproval, rows, runKeybindingAction, + runCommandPaletteItem, runRightCommand, selectActiveLaneId, selectActiveSessionId, @@ -10084,6 +11778,7 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } setFormDiscardArmed, setPaneFocus, showMentionPalette, + showCommandPalette, showSlashPalette, slashRows, startAddMode, @@ -10097,6 +11792,40 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } toggleSubagentsPane, ]); + // Chat link click-targets, isolated so they re-register as the transcript + // scrolls/streams (keyed on the visible rows) without rebuilding the entire + // app hit-target set on every coalesced flush. + useEffect(() => { + const registry = hitTestRegistryRef.current; + for (const id of chatLinkTargetIdsRef.current) registry.unregister(id); + const ids: string[] = []; + const chatTopRow = 3 + goalBannerRows + addModeRows; + const chatStartColumn = drawerPaneWidth + 3; + visibleChatSelectionRows.forEach((row, index) => { + if (row.sourceRow == null) return; + const match = firstUrlInText(row.text); + if (!match) return; + const y = chatTopRow + index; + if (y < chatTopRow || y > chatTopRow + chatRowBudget) return; + const id = `chat:link:${index}:${match.url}`; + ids.push(id); + registry.register({ + id, + rect: { x: chatStartColumn + match.index, y, w: Math.max(1, match.width), h: 1 }, + onClick: () => { + if (!openExternalUrl(match.url, addNotice)) { + addNotice(`Couldn't open ${match.url}.`, "error"); + } + }, + zIndex: 6, + }); + }); + chatLinkTargetIdsRef.current = ids; + return () => { + for (const id of ids) registry.unregister(id); + }; + }, [addModeRows, addNotice, chatRowBudget, drawerPaneWidth, goalBannerRows, visibleChatSelectionRows]); + if (error && !connection) { return ( @@ -10151,14 +11880,15 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } loading={mode === "connecting" || lanes.length === 0} prByLaneId={prByLaneId} diffByLaneId={diffByLaneId} - unavailableLaneIds={unavailableLaneIds} - width={drawerPaneWidth} - /> + unavailableLaneIds={unavailableLaneIds} + width={drawerPaneWidth} + scrollOffsetRows={drawerScrollOffsetRows} + /> ) : null} {pendingApproval?.highStakes ? ( - ) : multiView ? ( + ) : (gridViewActive && multiView) ? ( { + terminalViewportMetricsRef.current = metrics; + }} /> ) : ( <> ) : null} - {showMentionPalette ? ( + {showCommandPalette ? ( + + + + ) : null} + {!showCommandPalette && showMentionPalette ? ( ) : null} - {!showMentionPalette && showSlashPalette ? ( + {!showCommandPalette && !showMentionPalette && showSlashPalette ? ( ) : null} @@ -10323,11 +12073,13 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } modelDisplay={modelState.displayName} reasoningEffort={modelState.reasoningEffort} permissionLabel={permissionSummary(modelState)} + permissionDetail={modelState.provider === "codex" ? codexApprovalSandboxLabel(modelState) : null} contextPercent={contextPercent} tokenSummary={tokenSummary} approvalActive={pendingApproval?.mode === "approval" && !pendingApproval.highStakes} liveAgentCount={liveAgentCount} fastMode={modelState.codexFastMode} + fastSupported={footerFastSupported} inlineRowFocused={inlineRowFocused} inlineRowCell={inlineRowFocus.cell} providerLocked={providerLocked} diff --git a/apps/ade-cli/src/tuiClient/commands.ts b/apps/ade-cli/src/tuiClient/commands.ts index ef6b45856..49d3b48d9 100644 --- a/apps/ade-cli/src/tuiClient/commands.ts +++ b/apps/ade-cli/src/tuiClient/commands.ts @@ -2,79 +2,116 @@ import type { AgentChatProvider, AgentChatSlashCommand } from "../../../desktop/ export type CommandPlacement = "inline" | "right" | "overlay" | "chat"; +// Category buckets for the /help command reference (grouped, searchable). +// Order here is the order categories appear in the help pane. +export type CommandCategory = + | "Lanes" + | "Chats" + | "Steer" + | "PRs" + | "Linear" + | "Model" + | "Nav" + | "System"; + +export const COMMAND_CATEGORY_ORDER: CommandCategory[] = [ + "Lanes", + "Chats", + "Steer", + "PRs", + "Linear", + "Model", + "Nav", + "System", +]; + export type BuiltinCommand = { name: string; description: string; placement: CommandPlacement; argumentHint?: string; providers?: AgentChatProvider[]; + category?: CommandCategory; }; export const BUILTIN_COMMANDS: BuiltinCommand[] = [ - { name: "/commit", description: "Commit lane changes", placement: "inline", argumentHint: "[message]" }, - { name: "/push", description: "Push the active lane branch", placement: "inline" }, - { name: "/pull", description: "Pull the active lane branch (default ff-only)", placement: "inline", argumentHint: "[--ff-only|--rebase|--merge]" }, - { name: "/undo", description: "Undo the last recorded HEAD change on the active lane branch", placement: "inline" }, - { name: "/redo", description: "Redo the most recently undone HEAD change on the active lane", 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: "/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" }, - { name: "/steer cancel", description: "Remove the latest staged steer message", placement: "inline" }, - { name: "/steer edit", description: "Edit the latest staged steer message", placement: "inline", argumentHint: "" }, - { name: "/steer send", description: "Send the latest staged steer into a Claude turn", placement: "inline", providers: ["claude"] }, - { name: "/steer interrupt", description: "Interrupt Claude and run the latest staged steer", placement: "inline", providers: ["claude"] }, - { name: "/steer", description: "Show staged steer messages", placement: "right" }, - { name: "/new lane", description: "Create a new lane", placement: "right" }, - { name: "/new chat", description: "Create a new chat", placement: "right", argumentHint: "[title]" }, - { name: "/rename", description: "Rename the active chat", placement: "right", argumentHint: "[title]" }, - { name: "/tag", description: "Tag the active Claude chat", placement: "right", argumentHint: "", providers: ["claude"] }, - { name: "/output-style", description: "List or select the active Claude output style", placement: "right", argumentHint: "[style]", providers: ["claude"] }, - { name: "/plugin", description: "List, reload, or manage Claude plugins", placement: "right", argumentHint: "[reload|native args]", providers: ["claude"] }, - { 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: "/info", description: "Open active chat info, plan, goal, and agents", placement: "right" }, - { name: "/skills", description: "List agent skills from project, user, and ADE bundled roots", placement: "right" }, - { 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]", 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]" }, - { name: "/lane delete", description: "Delete the active lane after confirmation", placement: "right" }, - { name: "/pr", description: "Show pull request state", placement: "right" }, - { name: "/pr open", description: "Create or open a PR for the active lane", placement: "right" }, - { name: "/pr review", description: "Show PR reviews", placement: "right" }, - { name: "/pr comments", description: "Show actionable PR comments", placement: "right" }, - { name: "/pr checks", description: "Show PR checks", placement: "right" }, - { name: "/linear", description: "Run Linear workflow, route, sync, or ingress commands", placement: "right", argumentHint: "" }, - { name: "/linear list", description: "List Linear work", placement: "right" }, - { name: "/linear workflows", description: "List Linear workflow runs", placement: "right" }, - { name: "/linear run", description: "Inspect or resolve a Linear run", placement: "right", argumentHint: "" }, - { name: "/linear route", description: "Route a Linear issue", placement: "right", argumentHint: "" }, - { name: "/linear sync", description: "Operate Linear sync", placement: "right", argumentHint: "" }, - { name: "/linear ingress", description: "Inspect Linear ingress", placement: "right", argumentHint: "" }, - { name: "/linear pull", description: "Pull a Linear ticket into chat context", placement: "right", argumentHint: "" }, - { name: "/linear comment", description: "Comment on a Linear ticket", placement: "right", argumentHint: " " }, - { name: "/linear comments", description: "Show comments on a Linear ticket", placement: "right", argumentHint: "" }, - { name: "/linear status", description: "Show Linear sync status", placement: "right" }, - { name: "/linear assign", description: "Assign a Linear ticket", placement: "right", argumentHint: " " }, - { name: "/feedback", description: "Submit ADE feedback to GitHub issues", placement: "right" }, - { name: "/chats", description: "List chats in the active lane", placement: "right" }, - { name: "/switch", description: "Switch lane or chat", placement: "right", argumentHint: "[lane|chat]" }, - { name: "/help", description: "Show keymap and command help", placement: "right" }, - { name: "/keybindings", description: "Show Claude-compatible keybinding config diagnostics", placement: "right", argumentHint: "[open]" }, - { name: "/statusline", description: "Show Claude-compatible status line config", placement: "right" }, - { name: "/doctor", description: "Show ADE Code and Claude-compat diagnostics", placement: "right" }, - { name: "/model", description: "Open the model, reasoning, and permission picker", placement: "right" }, - { name: "/effort", description: "Open the reasoning-effort picker", placement: "right" }, - { name: "/system", description: "Show system and runtime details", placement: "right" }, - { name: "/ade", description: "Run an ADE action or force a TUI command", placement: "right", argumentHint: " [json]" }, + { name: "/commit", description: "Commit lane changes", placement: "inline", argumentHint: "[message]", category: "Lanes" }, + { name: "/push", description: "Push the active lane branch", placement: "inline", category: "Lanes" }, + { name: "/pull", description: "Pull the active lane branch (default ff-only)", placement: "inline", argumentHint: "[--ff-only|--rebase|--merge]", category: "Lanes" }, + { name: "/undo", description: "Undo the last recorded HEAD change on the active lane branch", placement: "inline", category: "Lanes" }, + { name: "/redo", description: "Redo the most recently undone HEAD change on the active lane", placement: "inline", category: "Lanes" }, + { name: "/stage all", description: "Stage all changes in the active lane", placement: "inline", category: "Lanes" }, + { name: "/clear", description: "Clear the local terminal transcript view", placement: "inline", category: "Chats" }, + { name: "/login", description: "Sign in to the active CLI-backed provider from this terminal", placement: "inline", category: "Nav" }, + { name: "/open", description: "Open this ADE context in desktop", placement: "inline", category: "Nav" }, + { name: "/quit", description: "Exit ade code", placement: "inline", category: "System" }, + { name: "/steer cancel", description: "Remove the latest staged steer message", placement: "inline", category: "Steer" }, + { name: "/steer edit", description: "Edit the latest staged steer message", placement: "inline", argumentHint: "", category: "Steer" }, + { name: "/steer send", description: "Send the latest staged steer into a Claude turn", placement: "inline", providers: ["claude"], category: "Steer" }, + { name: "/steer interrupt", description: "Interrupt Claude and run the latest staged steer", placement: "inline", providers: ["claude"], category: "Steer" }, + { name: "/steer", description: "Show staged steer messages", placement: "right", category: "Steer" }, + { name: "/new lane", description: "Create a new lane", placement: "right", category: "Lanes" }, + { name: "/new chat", description: "Create a new chat", placement: "right", argumentHint: "[title]", category: "Chats" }, + { name: "/rename", description: "Rename the active chat", placement: "right", argumentHint: "[title]", category: "Chats" }, + { name: "/chat rename", description: "Rename the active chat", placement: "right", argumentHint: "[title]", category: "Chats" }, + { name: "/chat archive", description: "Archive the active chat", placement: "right", category: "Chats" }, + { name: "/chat unarchive", description: "Unarchive a chat by id or title", placement: "right", argumentHint: "", category: "Chats" }, + { name: "/chat archived", description: "List archived chats", placement: "right", argumentHint: "[filter]", category: "Chats" }, + { name: "/chat delete", description: "Delete the active chat after confirmation", placement: "right", category: "Chats" }, + { name: "/tag", description: "Tag the active Claude chat", placement: "right", argumentHint: "", providers: ["claude"], category: "Model" }, + { name: "/output-style", description: "List or select the active Claude output style", placement: "right", argumentHint: "[style]", providers: ["claude"], category: "Model" }, + { name: "/plugin", description: "List, reload, or manage Claude plugins", placement: "right", argumentHint: "[reload|native args]", providers: ["claude"], category: "Model" }, + { name: "/status", description: "Show project, lane, and runtime state", placement: "right", category: "Nav" }, + { name: "/context", description: "Show chat context usage", placement: "right", category: "Nav" }, + { name: "/agents", description: "List Claude agents from user and project config", placement: "right", providers: ["claude"], category: "Nav" }, + { name: "/info", description: "Open active chat info, plan, goal, and agents", placement: "right", category: "Nav" }, + { name: "/skills", description: "List agent skills from project, user, and ADE bundled roots", placement: "right", category: "Nav" }, + { name: "/compact", description: "Compact the active chat context", placement: "chat", argumentHint: "[instructions]", providers: ["claude", "codex"], category: "Model" }, + { name: "/init", description: "Generate AGENTS.md and Claude pointer files", placement: "right", providers: ["claude"], category: "Nav" }, + { name: "/usage", description: "Show Claude usage through the active SDK session", placement: "chat", providers: ["claude"], category: "Model" }, + { name: "/insights", description: "Generate Claude session insights through the active SDK session", placement: "chat", providers: ["claude"], category: "Model" }, + { name: "/fast", description: "Toggle Claude fast mode through the active SDK session", placement: "chat", argumentHint: "[on|off]", providers: ["claude"], category: "Model" }, + { name: "/goal", description: "Set, clear, or inspect the active chat goal", placement: "chat", argumentHint: "[|clear|status active|paused|complete]", providers: ["claude", "codex"], category: "Model" }, + { name: "/diff", description: "Show active lane diff", placement: "right", category: "Lanes" }, + { name: "/log", description: "Show recent commits", placement: "right", category: "Lanes" }, + { name: "/reparent", description: "Move the active lane under another lane", placement: "right", argumentHint: " [stack-base-ref]", category: "Lanes" }, + { name: "/lane rename", description: "Rename the active lane", placement: "right", argumentHint: "[name]", category: "Lanes" }, + { name: "/lane archive", description: "Archive the active lane", placement: "right", category: "Lanes" }, + { name: "/lane unarchive", description: "Unarchive a lane by id or name", placement: "right", argumentHint: "", category: "Lanes" }, + { name: "/lane archived", description: "List archived lanes", placement: "right", category: "Lanes" }, + { name: "/lane delete", description: "Delete the active lane after confirmation", placement: "right", category: "Lanes" }, + { name: "/pr", description: "Show pull request state", placement: "right", category: "PRs" }, + { name: "/pr open", description: "Create or open a PR for the active lane", placement: "right", category: "PRs" }, + { name: "/pr review", description: "Show PR reviews", placement: "right", category: "PRs" }, + { name: "/pr comments", description: "Show actionable PR comments", placement: "right", category: "PRs" }, + { name: "/pr comment", description: "Comment on the active PR", placement: "right", argumentHint: "", category: "PRs" }, + { name: "/pr approve", description: "Approve the active PR", placement: "right", argumentHint: "[note]", category: "PRs" }, + { name: "/pr request-changes", description: "Request changes on the active PR", placement: "right", argumentHint: "", category: "PRs" }, + { name: "/pr land", description: "Merge the active PR (needs confirm)", placement: "right", argumentHint: "[confirm] [merge|squash|rebase]", category: "PRs" }, + { name: "/pr checks", description: "Show PR checks", placement: "right", category: "PRs" }, + { name: "/linear", description: "Run Linear workflow, route, sync, or ingress commands", placement: "right", argumentHint: "", category: "Linear" }, + { name: "/linear list", description: "List Linear work", placement: "right", category: "Linear" }, + { name: "/linear workflows", description: "List Linear workflow runs", placement: "right", category: "Linear" }, + { name: "/linear run", description: "Inspect or resolve a Linear run", placement: "right", argumentHint: "", category: "Linear" }, + { name: "/linear route", description: "Route a Linear issue", placement: "right", argumentHint: "", category: "Linear" }, + { name: "/linear sync", description: "Operate Linear sync", placement: "right", argumentHint: "", category: "Linear" }, + { name: "/linear ingress", description: "Inspect Linear ingress", placement: "right", argumentHint: "", category: "Linear" }, + { name: "/linear pull", description: "Pull a Linear ticket into chat context", placement: "right", argumentHint: "", category: "Linear" }, + { name: "/linear comment", description: "Comment on a Linear ticket", placement: "right", argumentHint: " ", category: "Linear" }, + { name: "/linear comments", description: "Show comments on a Linear ticket", placement: "right", argumentHint: "", category: "Linear" }, + { name: "/linear status", description: "Show Linear sync status", placement: "right", category: "Linear" }, + { name: "/linear assign", description: "Assign a Linear ticket", placement: "right", argumentHint: " ", category: "Linear" }, + { name: "/feedback", description: "Submit ADE feedback to GitHub issues", placement: "right", category: "Nav" }, + { name: "/chats", description: "List chats in the active lane", placement: "right", argumentHint: "[filter]", category: "Chats" }, + { name: "/switch", description: "Switch lane or chat", placement: "right", argumentHint: "[lane|chat]", category: "Chats" }, + { name: "/help", description: "Show keymap and command help", placement: "right", category: "System" }, + { name: "/keybindings", description: "Show Claude-compatible keybinding config diagnostics", placement: "right", argumentHint: "[open]", category: "System" }, + { name: "/statusline", description: "Show Claude-compatible status line config", placement: "right", category: "System" }, + { name: "/doctor", description: "Show ADE Code and Claude-compat diagnostics", placement: "right", category: "System" }, + { name: "/model", description: "Open the model, reasoning, and permission picker", placement: "right", category: "Model" }, + { name: "/effort", description: "Open the reasoning-effort picker", placement: "right", category: "Model" }, + { name: "/system", description: "Show system and runtime details", placement: "right", category: "System" }, + { name: "/ade", description: "Run an ADE action or force a TUI command", placement: "right", argumentHint: " [json]", category: "System" }, ]; // ADE-owned local controls must dispatch in the TUI even if the active runtime diff --git a/apps/ade-cli/src/tuiClient/components/AdeWordmark.tsx b/apps/ade-cli/src/tuiClient/components/AdeWordmark.tsx index 6221912c5..a3c46901e 100644 --- a/apps/ade-cli/src/tuiClient/components/AdeWordmark.tsx +++ b/apps/ade-cli/src/tuiClient/components/AdeWordmark.tsx @@ -3,56 +3,150 @@ import { Box, Text } from "ink"; import { theme } from "../theme"; /** - * Detailed ADE wordmark in the ANSI-Shadow figlet style: 12 rows tall × 36 - * cells wide. The last row is the shadow and tints to a deeper violet. + * ADE wordmark — solid, logo-style letterforms with a layered 3D drop shadow. + * + * The face is uniform bright brand violet (like the white letters on the real + * app icon), and a three-step diagonal shadow falls down-right in progressively + * deeper violets, giving real extruded depth. The shadow only lands on the + * outer bottom-right *rim* of the silhouette (computed by flooding the exterior + * from the border, then skipping any cell with a face directly below or right) + * so letter counters and notches stay open and crisp instead of filling in. + * + * The whole thing is composited once at module load into a flat list of colored + * runs per row — rendering is a trivial pure map, zero runtime state, zero idle + * re-renders. */ -const FACE_ROWS = [ - " █████╗ ██████╗ ███████╗", - " ██╔══██╗ ██╔══██╗ ██╔════╝", - " ██║ ██║ ██║ ██║ ██║ ", - " ██║ ██║ ██║ ██║ ██║ ", - " ███████║ ██║ ██║ █████╗ ", - " ███████║ ██║ ██║ █████╗ ", - " ██╔══██║ ██║ ██║ ██║ ", - " ██╔══██║ ██║ ██║ ██║ ", - " ██║ ██║ ██║ ██║ ██║ ", - " ██║ ██║ ██║ ██║ ██║ ", - " ██║ ██║ ██████╔╝ ███████╗", - " ╚═╝ ╚═╝ ╚═════╝ ╚══════╝", + +// Solid 6×N glyph sprites (`#` = filled, `.` = empty). Bold, blocky forms that +// echo the app icon: peaked A with a counter, rounded D, chunky E. +const GLYPH_A = [ + "..####..", + ".##..##.", + "##....##", + "########", + "##....##", + "##....##", +]; +const GLYPH_D = [ + "######..", + "##...##.", + "##....##", + "##....##", + "##...##.", + "######..", ]; +const GLYPH_E = [ + "#######", + "##.....", + "#####..", + "#####..", + "##.....", + "#######", +]; + +// face / shadow-layer-1 / 2 / 3 +type Cell = "F" | "1" | "2" | "3" | " "; +type Run = { text: string; color: string | null }; + +const SHADOW_COLORS: Record<"1" | "2" | "3", string> = { + "1": theme.color.violetDeep, + "2": theme.color.violetDeeper, + "3": theme.color.violetDeepest, +}; + +/** Join the three glyphs side by side with `gap` blank columns between them. */ +function faceRows(gap: number): string[] { + const sep = " ".repeat(gap); + return GLYPH_A.map((_, r) => `${GLYPH_A[r]}${sep}${GLYPH_D[r]}${sep}${GLYPH_E[r]}`); +} /** - * Compact fallback used when the hero card is too narrow for the big version. - * 6 rows tall — same height as the previous wordmark — with shadow corners. + * Stamp the solid face plus a `depth`-step layered diagonal drop shadow onto a + * canvas. The shadow only lands on exterior rim cells (no face directly below + * or to the right), so it hugs the outer bottom-right edge and never fills the + * open counters/notches. Nearest face diagonal sets the shadow layer (1 = + * closest/brightest deep violet … depth = farthest/darkest). Face drawn last. */ -const COMPACT_ROWS = [ - " █████╗ ██████╗ ███████╗", - "██╔══██╗ ██╔══██╗ ██╔════╝", - "███████║ ██║ ██║ █████╗ ", - "██╔══██║ ██║ ██║ ██╔══╝ ", - "██║ ██║ ██████╔╝ ███████╗", - "╚═╝ ╚═╝ ╚═════╝ ╚══════╝", -]; +function buildCanvas(face: string[], depth: number): Cell[][] { + const h = face.length; + const w = face[0]!.length; + const H = h + depth; + const W = w + depth; + const isFace = (r: number, c: number) => r >= 0 && c >= 0 && r < h && c < w && face[r]![c] === "#"; + const canvas: Cell[][] = Array.from({ length: H }, () => Array.from({ length: W }, () => " " as Cell)); + + // Flood the exterior from the border so enclosed counters are excluded. + const outside: boolean[][] = Array.from({ length: H }, () => Array(W).fill(false)); + const stack: Array<[number, number]> = []; + for (let r = 0; r < H; r++) stack.push([r, 0], [r, W - 1]); + for (let c = 0; c < W; c++) stack.push([0, c], [H - 1, c]); + while (stack.length) { + const [r, c] = stack.pop()!; + if (r < 0 || c < 0 || r >= H || c >= W || outside[r]![c] || isFace(r, c)) continue; + outside[r]![c] = true; + stack.push([r + 1, c], [r - 1, c], [r, c + 1], [r, c - 1]); + } + + for (let r = 0; r < H; r++) { + for (let c = 0; c < W; c++) { + if (!outside[r]![c]) continue; + // Rim only: skip cells that sit directly above or left of the face, so the + // shadow doesn't bleed into interior notches. + if (isFace(r + 1, c) || isFace(r, c + 1)) continue; + for (let k = 1; k <= depth; k++) { + if (isFace(r - k, c - k)) { canvas[r]![c] = String(k) as Cell; break; } + } + } + } + for (let r = 0; r < h; r++) { + for (let c = 0; c < w; c++) { + if (face[r]![c] === "#") canvas[r]![c] = "F"; + } + } + return canvas; +} + +/** Collapse a canvas into per-row runs of same-colored cells. */ +function canvasToRuns(canvas: Cell[][]): Run[][] { + return canvas.map((row) => { + const runs: Run[] = []; + let text = ""; + let color: string | null = null; + const flush = () => { if (text) runs.push({ text, color }); text = ""; }; + for (const cell of row) { + const ch = cell === " " ? " " : "█"; + const cellColor = cell === "F" ? theme.color.violet : cell === " " ? null : SHADOW_COLORS[cell]; + if (cellColor !== color) { flush(); color = cellColor; } + text += ch; + } + flush(); + return runs; + }); +} + +// Full: wide letter gaps + 3-layer shadow. Compact: tighter, 2-layer shadow. +const FULL_RUNS = canvasToRuns(buildCanvas(faceRows(2), 3)); +const COMPACT_RUNS = canvasToRuns(buildCanvas(faceRows(1), 2)); -export const ADE_WORDMARK_FULL_WIDTH = 36; -export const ADE_WORDMARK_COMPACT_WIDTH = 26; +// 8+gap+8+gap+7 + depth shadow cols. Full: 27+3 = 30. Compact: 25+2 = 27. +export const ADE_WORDMARK_FULL_WIDTH = 30; +export const ADE_WORDMARK_COMPACT_WIDTH = 27; export function AdeWordmark({ compact = false }: { compact?: boolean } = {}) { - const rows = compact ? COMPACT_ROWS : FACE_ROWS; + const rows = compact ? COMPACT_RUNS : FULL_RUNS; return ( - {rows.map((row, index) => { - const isShadow = index === rows.length - 1; - return ( - - {row} - - ); - })} + {rows.map((rowRuns, index) => ( + + {rowRuns.map((run, runIndex) => + run.color === null ? ( + {run.text} + ) : ( + {run.text} + ), + )} + + ))} ); } diff --git a/apps/ade-cli/src/tuiClient/components/ApprovalPrompt.tsx b/apps/ade-cli/src/tuiClient/components/ApprovalPrompt.tsx index 054c8f24b..38db29b8d 100644 --- a/apps/ade-cli/src/tuiClient/components/ApprovalPrompt.tsx +++ b/apps/ade-cli/src/tuiClient/components/ApprovalPrompt.tsx @@ -1,24 +1,38 @@ import React from "react"; -import { Box, Text } from "ink"; +import { Box, Text, useStdout } from "ink"; import type { PendingApproval } from "../types"; import { theme } from "../theme"; +import { useHoveredHitId } from "../hitTestRegistry"; +import { SectionHeader, Pill, StatusDot, KeyHints, Rule } from "./designKit"; -function ApproveChip({ - k, - label, - color, - highlighted, -}: { - k: string; - label: string; - color: string; - highlighted?: boolean; -}) { - return ( - - [{k}] {label} - - ); +// Default inner widths. The parent never passes a width, so the card sizes +// itself: a fixed, centered column when modal (high-stakes), and a comfortable +// readable width inline. Both stay well within a narrow terminal. +const MODAL_WIDTH = 60; +const INLINE_WIDTH = 64; + +function truncateEnd(value: string, max: number): string { + if (max <= 1) return value.length ? "…" : ""; + if (value.length <= max) return value; + return `${value.slice(0, Math.max(0, max - 1))}…`; +} + +/** + * An access-key prefix shown immediately before an action's pill, e.g. the `a` + * in `a [ approve ]`. Accentuated when the action is highlighted. + */ +function Hotkey({ value, highlighted }: { value: string; highlighted?: boolean }) { + return {`${value} `}; +} + +/** + * A neutral/destructive bracketed chip — the deny affordance. The kit `Pill` is + * violet-only (primary), so deny gets its own red/neutral chip per the color + * rules (the primary accept action uses the kit `Pill`). + */ +function DenyChip({ label, highlighted }: { label: string; highlighted?: boolean }) { + const color = highlighted ? theme.color.error : theme.color.t4; + return {`[ ${label} ]`}; } export function ApprovalPrompt({ @@ -28,18 +42,80 @@ export function ApprovalPrompt({ approval: PendingApproval | null; modal?: boolean; }) { + const hoveredId = useHoveredHitId(); + const { stdout } = useStdout(); if (!approval) return null; + const question = approval.request?.questions[0] ?? null; + const options = question?.options?.length ? question.options : approval.request?.options ?? []; const highStakes = approval.highStakes; - const borderColor = highStakes ? theme.color.error : theme.color.attention; - const headerColor = highStakes ? theme.color.error : theme.color.attention; + const isQuestion = approval.mode === "question"; + const kind = approval.request?.kind; + // Accent + glyph for the titled header. Green is never used here — amber for a + // routine permission ask, red for a high-stakes/destructive one, violet for + // selection/input requests (informational, not a warning). let title: string; - if (approval.mode === "question") title = "INPUT REQUESTED"; - else if (highStakes) title = "HIGH-STAKES APPROVAL REQUIRED"; - else title = "APPROVAL REQUIRED"; + let glyph: string; + let accent: string; + if (kind === "model_selection") { + title = "MODEL SELECTION"; + glyph = "◆"; + accent = theme.color.violet; + } else if (kind === "plan_approval") { + title = "PLAN APPROVAL"; + glyph = "◇"; + accent = theme.color.violet; + } else if (isQuestion) { + title = "INPUT REQUESTED"; + glyph = "?"; + accent = theme.color.violet; + } else if (highStakes) { + title = "HIGH-STAKES PERMISSION"; + glyph = "▲"; + accent = theme.color.error; + } else { + title = "PERMISSION"; + glyph = "▲"; + accent = theme.color.attention; + } + + const borderColor = highStakes ? theme.color.error : theme.color.borderActive; + // Clamp the fixed card width to the terminal so it never overflows a narrow + // window. Border (2) + paddingX (2) = 4 cols of chrome around innerWidth. + const fixedWidth = modal ? MODAL_WIDTH : INLINE_WIDTH; + const terminalCols = stdout?.columns ?? 0; + const innerWidth = + terminalCols > 0 ? Math.max(20, Math.min(fixedWidth, terminalCols - 4)) : fixedWidth; + // The content region sits inside a single border (2 cols) + paddingX of 1 + // (2 cols), so usable text columns are innerWidth - 4. Rules/truncation use + // this so nothing wraps past the card edge. + const textWidth = Math.max(8, innerWidth - 4); + + // The request body: a primary line (the command / question being asked) and an + // optional secondary detail, rendered dim beneath it. + const primary = + question?.question ?? approval.request?.title ?? approval.description ?? ""; + const secondaryRaw = + question?.question && approval.request?.description && approval.request.description !== primary + ? approval.request.description + : !question?.question && approval.request?.title && approval.description !== primary + ? approval.description + : null; + const secondary = secondaryRaw && secondaryRaw !== primary ? secondaryRaw : null; + + const showChips = !isQuestion && !highStakes; + + // Footer hints, keyed to the real bindings in app input handling. + const hints: Array<[string, string]> = isQuestion + ? [["1-6", "select"], ["type", "answer"], ["deny", "decline"]] + : highStakes + ? [["approve", "allow"], ["deny", "decline"], ["enter", "confirm"]] + : [["a", "approve"], ["d", "deny"]]; - const showChips = approval.mode !== "question" && !highStakes; + const accentingAccept = + hoveredId === "approval:accept" || + !(typeof hoveredId === "string" && hoveredId.startsWith("approval:")); const card = ( - - ⚠ {title} - - {question?.question ?? approval.description} - {question?.options?.length ? ( - - {question.options.slice(0, 6).map((option, index) => ( - - {index + 1}. {option.label}{option.description ? ` - ${option.description}` : ""} - - ))} + + + {primary ? ( + + + {truncateEnd(primary, textWidth)} + + + ) : null} + {secondary ? ( + + {truncateEnd(secondary, textWidth)} + + ) : null} + + {options.length ? ( + + {options.slice(0, 6).map((option, index) => { + const optionId = `approval:question-option:${option.value}:${index}`; + const active = hoveredId === optionId; + const detail = option.description ? ` — ${option.description}` : ""; + const line = `${option.label}${detail}`; + return ( + + + {`${index + 1} `} + + + {truncateEnd(line, Math.max(8, textWidth - 2))} + + + ); + })} ) : null} {showChips ? ( - - - + + + + + + + + ) : null} - - - {approval.mode === "question" - ? "Type an answer, option number/value, deny, or cancel." - : highStakes - ? 'Type "approve" or "deny", then press enter.' - : "Press a to approve, d to deny."} - - + {highStakes ? ( + + + destructive — explicit confirmation required + + ) : null} + + {modal ? ( + + + + ) : null} + ); + if (!modal) return card; return ( diff --git a/apps/ade-cli/src/tuiClient/components/ChatView.tsx b/apps/ade-cli/src/tuiClient/components/ChatView.tsx index 854a7179c..20320383d 100644 --- a/apps/ade-cli/src/tuiClient/components/ChatView.tsx +++ b/apps/ade-cli/src/tuiClient/components/ChatView.tsx @@ -21,7 +21,7 @@ import { type WorkToolStatus, } from "../aggregate"; import { theme } from "../theme"; -import { useBrailleSpin, useDotPulse, useSpinFrame } from "../spinTick"; +import { useBrailleSpin, useDotPulse, useShimmerTick, useSpinFrame } from "../spinTick"; import { ADE_WORDMARK_COMPACT_WIDTH, ADE_WORDMARK_FULL_WIDTH, @@ -38,6 +38,10 @@ const HERO_WORDMARK_FULL_MIN_USABLE = ADE_WORDMARK_FULL_WIDTH + 2; const HERO_WORDMARK_COMPACT_MIN_USABLE = ADE_WORDMARK_COMPACT_WIDTH + 2; const DEFAULT_VIEW_WIDTH = 88; const BLANK_ROW_TEXT = " "; +// Placeholder frame for historical (non-live) blocks. Non-live blocks never +// read the animation frame, so any fixed value keeps their rows identical while +// letting us memoize them independently of the spinner tick. +const STATIC_SPIN_FRAME = "◐"; type RenderedChatRow = { id: string; @@ -159,6 +163,7 @@ function wrapInlineRuns(runs: InlineRun[], width: number, firstPrefix: string, r if (run.italic) style.italic = true; if (run.code) style.code = true; if (run.link) style.link = true; + if (run.href) style.href = run.href; for (const part of parts) { if (!part) continue; const isSpace = /^\s+$/.test(part); @@ -898,13 +903,29 @@ function planRows(block: Extract, spinFrame: return out; } -function activeTurnRows(dots: string, showWorkingIndicator = true): RenderedChatRow[] { +const WORKING_LABEL = "✦ model working"; + +// A bright cell sweeps left→right across the label, then a gap before repeating — +// a terminal "shimmer" like Claude Code's working indicator. shimmerPos < 0 (the +// default, used by the non-animated selection/plain-text paths) renders it flat. +function workingShimmerRuns(shimmerPos: number): InlineRun[] { + const chars = [...WORKING_LABEL]; + return chars.map((ch, index) => { + const dist = shimmerPos < 0 ? 99 : shimmerPos - index; + if (dist === 0) return { text: ch, color: theme.color.t1, bold: true }; + if (dist === 1) return { text: ch, color: theme.color.fg, bold: true }; + if (dist === 2 || dist === -1) return { text: ch, color: theme.color.violet }; + return { text: ch, color: theme.color.t3 }; + }); +} + +function activeTurnRows(dots: string, showWorkingIndicator = true, shimmerPos = -1): RenderedChatRow[] { if (!showWorkingIndicator) return []; return [{ id: "model-working", tone: "work", - text: `✦ model working${dots}`, - color: theme.color.violet, + runs: [...workingShimmerRuns(shimmerPos), { text: dots, color: theme.color.violet }], + text: `${WORKING_LABEL}${dots}`, bold: true, rail: null, }]; @@ -1027,6 +1048,13 @@ function shouldInsertSpacer(prev: AggregatedBlock["kind"], next: AggregatedBlock return true; } +// A block is "live" (animating) when its group is still in progress. Only live +// blocks read the spinner frame; everything before the first live block renders +// identically regardless of the tick, so it can be memoized independently. +function isLiveBlock(block: AggregatedBlock): boolean { + return "live" in block && (block as { live?: boolean }).live === true; +} + function maxScrollOffsetForRows(rowCount: number, maxRows?: number): number { if (!maxRows || maxRows <= 0 || rowCount <= maxRows) return 0; return Math.max(0, rowCount - Math.max(1, maxRows - 1)); @@ -1045,7 +1073,12 @@ function sliceRows( scrollOffsetRows = 0, unseenMessageCount = 0, ): RenderedChatRow[] { - const indexedRows = rows.map((row, index) => ({ ...row, sourceRowIndex: index })); + // Preserve object identity when sourceRowIndex is already correct (pre-indexed + // historical rows), so React.memo(ChatRow) can skip re-rendering unchanged rows + // on every spinner tick. Rows without a matching index are cloned as before. + const indexedRows = rows.map((row, index) => ( + row.sourceRowIndex === index ? row : { ...row, sourceRowIndex: index } + )); if (!maxRows || maxRows <= 0) return indexedRows; const viewportRows = Math.max(1, maxRows); if (indexedRows.length <= viewportRows) { @@ -1099,7 +1132,7 @@ function railColorForTone(_tone: RenderedChatRow["tone"]): string | null { return null; } -function InlineSpans({ runs }: { runs: InlineRun[] }) { +const InlineSpans = React.memo(function InlineSpans({ runs }: { runs: InlineRun[] }) { return ( <> {runs.map((run, index) => { @@ -1113,9 +1146,12 @@ function InlineSpans({ runs }: { runs: InlineRun[] }) { ); } if (run.link) { + const content = run.href + ? `\u001B]8;;${run.href}\u0007${run.text}\u001B]8;;\u0007` + : run.text; return ( - {run.text} + {content} ); } @@ -1127,7 +1163,7 @@ function InlineSpans({ runs }: { runs: InlineRun[] }) { })} ); -} +}); function normalizeSelection(selection: ChatTextSelection): ChatTextSelection { if ( @@ -1165,7 +1201,7 @@ function splitTextByColumns(value: string, start: number, end: number): [string, ]; } -function ChatRow({ +const ChatRow = React.memo(function ChatRow({ row, selection, }: { @@ -1204,7 +1240,7 @@ function ChatRow({ {row.runs ? : plainText} ); -} +}); function renderedRowText(row: RenderedChatRow): string { if (!row.runs && row.text === BLANK_ROW_TEXT) return ""; @@ -1328,6 +1364,7 @@ export function renderChatVisibleRowTexts({ export function renderChatVisibleSelectionRows({ events, + blocks: providedBlocks, notices, activeSession, expandedLineIds, @@ -1340,6 +1377,7 @@ export function renderChatVisibleSelectionRows({ showWorkingIndicator = true, }: { events: AgentChatEventEnvelope[]; + blocks?: AggregatedBlock[]; notices: LocalNotice[]; activeSession: AgentChatSessionSummary | null; expandedLineIds?: Set; @@ -1351,7 +1389,7 @@ export function renderChatVisibleSelectionRows({ interrupted?: boolean; showWorkingIndicator?: boolean; }): ChatVisibleSelectionRow[] { - const blocks = aggregateChatBlocks({ + const blocks = providedBlocks ?? aggregateChatBlocks({ events, notices, activeSession, @@ -1374,6 +1412,7 @@ export function renderChatVisibleSelectionRows({ export function renderChatSelectableRowTexts({ events, + blocks: providedBlocks, notices, activeSession, expandedLineIds, @@ -1383,6 +1422,7 @@ export function renderChatSelectableRowTexts({ showWorkingIndicator = true, }: { events: AgentChatEventEnvelope[]; + blocks?: AggregatedBlock[]; notices: LocalNotice[]; activeSession: AgentChatSessionSummary | null; expandedLineIds?: Set; @@ -1391,7 +1431,7 @@ export function renderChatSelectableRowTexts({ interrupted?: boolean; showWorkingIndicator?: boolean; }): string[] { - const blocks = aggregateChatBlocks({ + const blocks = providedBlocks ?? aggregateChatBlocks({ events, notices, activeSession, @@ -1453,8 +1493,18 @@ export function renderChatTranscriptPlainText({ .trimEnd(); } +// The splash (BootHero) stays on screen until the chat has real conversation +// content. Informational notices ("Model set to …", "No prompt history", nav +// hints) aggregate into `notice` blocks but must NOT dismiss the splash — only +// a sent prompt or a streaming response (any non-notice block) makes it "a +// chat." Keeps the new-chat surface clean: nothing but the splash until send. +export function hasConversationContent(blocks: ReadonlyArray<{ kind: string }>): boolean { + return blocks.some((block) => block.kind !== "notice"); +} + export function computeChatScrollMaxOffset({ events, + blocks: providedBlocks, notices, activeSession, expandedLineIds, @@ -1465,6 +1515,7 @@ export function computeChatScrollMaxOffset({ width = DEFAULT_VIEW_WIDTH, }: { events: AgentChatEventEnvelope[]; + blocks?: AggregatedBlock[]; notices: LocalNotice[]; activeSession: AgentChatSessionSummary | null; expandedLineIds?: Set; @@ -1474,13 +1525,13 @@ export function computeChatScrollMaxOffset({ showWorkingIndicator?: boolean; width?: number; }): number { - const blocks = aggregateChatBlocks({ + const blocks = providedBlocks ?? aggregateChatBlocks({ events, notices, activeSession, expandedLineIds, }); - if (!blocks.length && !streaming && !interrupted) return 0; + if (!hasConversationContent(blocks) && !streaming && !interrupted) return 0; const innerWidth = Math.max(24, width - 4); let statusRows = 0; if (streaming) statusRows = activeTurnRows("", showWorkingIndicator).length; @@ -1491,6 +1542,7 @@ export function computeChatScrollMaxOffset({ export function ChatView({ events, + blocks: providedBlocks, notices, activeSession, projectName, @@ -1513,6 +1565,8 @@ export function ChatView({ onRemove, }: { events: AgentChatEventEnvelope[]; + /** Pre-aggregated blocks. When provided, skips the internal aggregation pass. */ + blocks?: AggregatedBlock[]; notices: LocalNotice[]; activeSession: AgentChatSessionSummary | null; projectName: string; @@ -1538,36 +1592,68 @@ export function ChatView({ // shouldn't re-run on every spinner tick. Events identity changes only when // the underlying chat history advances, which is the right invalidation key. const blocks = useMemo( - () => aggregateChatBlocks({ events, notices, activeSession, expandedLineIds }), - [events, notices, activeSession, expandedLineIds], + () => providedBlocks ?? aggregateChatBlocks({ events, notices, activeSession, expandedLineIds }), + [providedBlocks, events, notices, activeSession, expandedLineIds], ); const tileMode = focused || Boolean(onRemove); const bodyRows = tileMode ? Math.max(1, (maxRows ?? 4) - 4) : maxRows; const brailleFrame = useBrailleSpin(); const spinFrame = useSpinFrame(); const dotPulse = useDotPulse(); + const shimmerTick = useShimmerTick(); const showWorkingIndicator = provider !== "claude" && activeSession?.provider !== "claude"; - // Memoize the rendered row list. Spinner ticks change brailleFrame/spinFrame - // every animation frame; without memoization we'd rebuild every row tree per - // tick. Most non-live rows don't depend on the frames, so this is a big win - // for large transcripts. - const rows = useMemo( - () => visibleRowsForBlocks({ - blocks, - maxRows: bodyRows, - scrollOffsetRows, - unseenMessageCount, - width, - streaming, - interrupted, - brailleFrame, - spinFrame, - dotPulse, - showWorkingIndicator, - }), - [blocks, bodyRows, brailleFrame, dotPulse, interrupted, scrollOffsetRows, showWorkingIndicator, spinFrame, streaming, unseenMessageCount, width], + const rowInnerWidth = Math.max(24, width - 4); + // Split the transcript at the first live (animating) block. Everything before + // it is frame-independent, so we memoize those rows on [blocks, width] and the + // 100ms spinner tick no longer rebuilds the whole transcript — only the few + // trailing live blocks (the current turn's running tool/plan/compaction groups) + // are rebuilt per tick. + const { historicalBlocks, tailBlocks } = useMemo(() => { + const idx = blocks.findIndex(isLiveBlock); + return idx < 0 + ? { historicalBlocks: blocks, tailBlocks: [] as AggregatedBlock[] } + : { historicalBlocks: blocks.slice(0, idx), tailBlocks: blocks.slice(idx) }; + }, [blocks]); + const historicalRows = useMemo( + // Pre-index historical rows by their final position (they always occupy + // slots 0..H-1, before the seam spacer + live tail). sliceRows then reuses + // these stable objects instead of cloning them every tick. + () => rowsForBlocks(historicalBlocks, rowInnerWidth, STATIC_SPIN_FRAME, STATIC_SPIN_FRAME) + .map((row, index) => ({ ...row, sourceRowIndex: index })), + [historicalBlocks, rowInnerWidth], ); - const isEmpty = !blocks.length && !streaming && !interrupted; + const rows = useMemo(() => { + const tailRows = tailBlocks.length + ? rowsForBlocks(tailBlocks, rowInnerWidth, brailleFrame, spinFrame) + : []; + let baseRows: RenderedChatRow[]; + if (historicalBlocks.length && tailBlocks.length) { + // Match single-pass rowsForBlocks: the seam spacer is keyed off the + // adjacent block KINDS (its prevKind), not whether the historical blocks + // happened to produce any rows — a zero-row historical block (e.g. an + // empty group) still seeds prevKind there. + const lastHistKind = historicalBlocks[historicalBlocks.length - 1]?.kind; + const firstTailKind = tailBlocks[0]?.kind; + const needSpacer = lastHistKind != null && firstTailKind != null + && shouldInsertSpacer(lastHistKind, firstTailKind); + baseRows = needSpacer + ? [...historicalRows, spacerRow(`${tailBlocks[0]!.id}:spacer`), ...tailRows] + : [...historicalRows, ...tailRows]; + } else { + baseRows = tailBlocks.length ? [...historicalRows, ...tailRows] : historicalRows; + } + let withSuffix = baseRows; + if (streaming) { + // Sweep a bright cell across the label, with a short gap before repeating. + const sweepLength = WORKING_LABEL.length + 6; + const shimmerPos = shimmerTick % sweepLength; + withSuffix = [...baseRows, ...activeTurnRows(dotPulse, showWorkingIndicator, shimmerPos)]; + } else if (interrupted) { + withSuffix = [...baseRows, ...modelInterruptedRows()]; + } + return sliceRows(withSuffix, bodyRows, scrollOffsetRows, unseenMessageCount); + }, [historicalRows, historicalBlocks, tailBlocks, rowInnerWidth, brailleFrame, spinFrame, dotPulse, shimmerTick, streaming, interrupted, showWorkingIndicator, bodyRows, scrollOffsetRows, unseenMessageCount]); + const isEmpty = !hasConversationContent(blocks) && !streaming && !interrupted; let content: React.ReactNode; if (isEmpty && tileMode) { content = ( @@ -1601,18 +1687,40 @@ export function ChatView({ const innerWidth = Math.max(8, width - 4); const title = activeSession?.title ?? activeSession?.goal ?? activeSession?.summary ?? activeSession?.sessionId ?? "chat"; - const streamingDot = streaming ? " ●" : ""; const removeSlot = onRemove ? " ×" : ""; - const available = Math.max(4, innerWidth - streamingDot.length - removeSlot.length - 3); + // Lane identity: an explicit lane color tints the rail + border so tiles for + // the same lane read at a glance (the focused tile still wins with cyan). + const laneAccent = lane?.color ?? null; + const railSlot = laneAccent ? 2 : 0; + // Multi-state status glyph (mirrors the desktop board's per-card state dot). + let statusGlyph: string; + let statusColor: string; + if (streaming) { + statusGlyph = brailleFrame; + statusColor = theme.color.running; + } else if (interrupted) { + statusGlyph = "◌"; + statusColor = theme.color.attention; + } else if (activeSession?.status === "ended") { + statusGlyph = "○"; + statusColor = theme.color.t5; + } else { + statusGlyph = "·"; + statusColor = theme.color.t4; + } + const available = Math.max(4, innerWidth - railSlot - 2 /* status */ - removeSlot.length - 3); const lanePart = truncateEnd(laneName || "(lane removed)", Math.max(3, Math.floor(available * 0.4))); const titlePart = truncateEnd(title, Math.max(3, available - textWidth(lanePart) - 3)); - const header = truncateEnd(`${lanePart} / ${titlePart}${streamingDot}`, Math.max(4, innerWidth - removeSlot.length)); + const header = truncateEnd(`${lanePart} / ${titlePart}`, Math.max(4, innerWidth - railSlot - removeSlot.length - 2)); + // Borders stay neutral; only the focused tile is accented (violet). Lane + // identity lives INSIDE the tile (the colored rail + title), never the border, + // so the grid doesn't read as a jumble of differently-colored frames. let tileBorderColor: string; - if (focused) tileBorderColor = "cyan"; - else if (hovered) tileBorderColor = theme.color.borderFocused; + if (focused) tileBorderColor = theme.color.violet; + else if (hovered) tileBorderColor = theme.color.borderActive; else tileBorderColor = theme.color.border; let headerColor: string; - if (focused) headerColor = "cyan"; + if (focused) headerColor = theme.color.violet; else if (activeSession?.status === "ended") headerColor = theme.color.t4; else headerColor = theme.color.t2; return ( @@ -1623,15 +1731,21 @@ export function ChatView({ height={maxRows} width={width} > - - - {header} - - {onRemove ? ( - - × + + + {laneAccent ? {"▎ "} : null} + + {header} - ) : null} + + + {statusGlyph} + {onRemove ? ( + + {" ×"} + + ) : null} + {content} diff --git a/apps/ade-cli/src/tuiClient/components/CommandPalette.tsx b/apps/ade-cli/src/tuiClient/components/CommandPalette.tsx new file mode 100644 index 000000000..d214d7200 --- /dev/null +++ b/apps/ade-cli/src/tuiClient/components/CommandPalette.tsx @@ -0,0 +1,271 @@ +import React from "react"; +import { Box, Text } from "ink"; +import { theme } from "../theme"; +import type { AdeCodeProvider } from "../types"; + +export type CommandPaletteItem = { + key: string; + kind: "command" | "lane" | "chat"; + label: string; + detail: string; +}; + +// Header + VISIBLE_ROWS body rows + detail line + footer = the reserved height. +// app.tsx windows the list with `COMMAND_PALETTE_ROWS - 3`, so VISIBLE_ROWS must +// stay in lockstep with that subtraction (header + detail + footer = 3 extras). +const VISIBLE_ROWS = 7; +export const COMMAND_PALETTE_ROWS = VISIBLE_ROWS + 3; +const DEFAULT_PALETTE_WIDTH = 92; +const MAX_PALETTE_WIDTH = 112; + +const KNOWN_PROVIDERS: ReadonlySet = new Set([ + "claude", + "codex", + "cursor", + "droid", + "opencode", + "ollama", + "lmstudio", +]); + +const KIND_TAG: Record = { + command: "cmd", + lane: "lane", + chat: "chat", +}; + +const TAG_WIDTH = 4; +// Every glyph this palette draws (◇ ◉ / ▎ · ↑ ↓ ↵ …) is a single terminal cell +// per `string-width`, so a code-point count is an accurate column measure. +const GLYPH_WIDTH = 1; + +function cellWidth(value: string): number { + return [...value].length; +} + +function endTruncate(value: string, max: number): string { + if (max <= 1) return value.length ? "…" : ""; + if (cellWidth(value) <= max) return value; + return `${[...value].slice(0, Math.max(0, max - 1)).join("")}…`; +} + +function fillEnd(value: string, width: number): string { + const clipped = endTruncate(value, width); + return `${clipped}${" ".repeat(Math.max(0, width - cellWidth(clipped)))}`; +} + +function glyph(kind: CommandPaletteItem["kind"]): string { + if (kind === "lane") return "◇"; + if (kind === "chat") return "◉"; + return "/"; +} + +// Per-kind accent for the leading glyph (and the kind tag). Commands are the +// brand/accent action so they wear violet; lanes are neutral chrome (they carry +// no color of their own at this layer); chats borrow their provider brand color +// when the trailing detail segment names a known provider, else a calm blue +// "thread" tone. Never green here — that stays reserved for running/success. +function kindColor(item: CommandPaletteItem): string { + if (item.kind === "command") return theme.color.violet; + if (item.kind === "lane") return theme.color.t3; + const provider = item.detail.split("·").pop()?.trim().toLowerCase() ?? ""; + if (KNOWN_PROVIDERS.has(provider)) return theme.provider(provider as AdeCodeProvider).color; + return theme.color.info; +} + +// One full-width body row painted on the palette surface. The row is composed of +// colored segments — a selection rail, a per-kind glyph, a dim kind tag, the +// label, and a dim right-side detail — then padded out to the inner width so the +// right border lands flush. The selected row swaps its leading space for a +// violet rail and brightens + bolds. +function PaletteRow({ + item, + selected, + labelWidth, + detailWidth, + innerWidth, +}: { + item: CommandPaletteItem | null; + selected: boolean; + labelWidth: number; + detailWidth: number; + innerWidth: number; +}) { + if (!item) { + return ( + + {`│${" ".repeat(innerWidth)}│`} + + ); + } + const accent = kindColor(item); + const tag = KIND_TAG[item.kind]; + const label = fillEnd(item.label, labelWidth); + const detail = endTruncate(item.detail, detailWidth); + // Visible columns consumed by the content between the framing pipes: + // "│ " rail " glyph " tag(padded) " " label " " detail + // The leading "│ " (2) belongs to the framing segment; the rest is summed here. + const content = + 1 /*rail*/ + + 1 /*space*/ + + GLYPH_WIDTH + + 1 /*space*/ + + TAG_WIDTH + + 1 /*space*/ + + labelWidth + + 1 /*space*/ + + cellWidth(detail); + // innerWidth includes the lead space after "│"; subtract it (1) plus content. + const pad = " ".repeat(Math.max(0, innerWidth - 1 - content)); + return ( + + + + {selected ? theme.rail : " "} + + {` ${glyph(item.kind)} `} + + {`${fillEnd(tag, TAG_WIDTH)} `} + + {label} + {` ${detail}`} + {`${pad}│`} + + ); +} + +export function CommandPalette({ + query, + items, + selectedIndex, + width, +}: { + query: string; + items: CommandPaletteItem[]; + selectedIndex: number; + width?: number; +}) { + const paletteWidth = Math.min(MAX_PALETTE_WIDTH, Math.max(56, Math.floor(width ?? DEFAULT_PALETTE_WIDTH) - 2)); + const innerWidth = Math.max(1, paletteWidth - 2); + const total = items.length; + const safeIndex = Math.max(0, Math.min(selectedIndex, Math.max(0, total - 1))); + const half = Math.floor(VISIBLE_ROWS / 2); + let start = Math.max(0, safeIndex - half); + const end = Math.min(total, start + VISIBLE_ROWS); + start = Math.max(0, end - VISIBLE_ROWS); + const window = items.slice(start, end); + const selected = items[safeIndex] ?? null; + const aboveCount = start; + const belowCount = total - end; + + const labelWidth = Math.max(18, Math.floor(paletteWidth * 0.38)); + // Reserve the fixed left gutter — lead 1 + rail 1 + sp 1 + glyph + sp 1 + + // tag + sp 1 + label + sp 1 — so the detail column aligns under the header on + // every row. Mirrors PaletteRow's content sum exactly. + const gutter = 1 + 1 + 1 + GLYPH_WIDTH + 1 + TAG_WIDTH + 1 + labelWidth + 1; + const detailWidth = Math.max(10, innerWidth - gutter); + + // Header — a SectionHeader-style hairline: bold violet title, a borderSoft + // rule that fills the gap, and a dim right-aligned result count. + const title = `Command palette · ${query.trim() || "all"}`; + const countHint = `${total} match${total === 1 ? "" : "es"}`; + const headerTitle = endTruncate(title, Math.max(8, innerWidth - cellWidth(countHint) - 4)); + // "┌ " (2) + title + " " (1) + rule + " " (1) + count + " ┐" (2) === paletteWidth + const headerRuleLen = Math.max(1, paletteWidth - 6 - cellWidth(headerTitle) - cellWidth(countHint)); + + // Footer — the key hints sit on the bottom rule, with an above/below scroll + // affordance ahead of them when the list overflows the window. + const moreSummary = [ + aboveCount ? `↑ ${aboveCount} above` : null, + belowCount ? `↓ ${belowCount} below` : null, + ].filter(Boolean).join(" · "); + const morePrefix = moreSummary ? `${moreSummary} · ` : ""; + // Rendered cell-width of the hint cluster "↑↓ move · ↵ run · esc close". + const hintWidth = cellWidth("↑↓ move · ↵ run · esc close"); + // "└ " (2) + morePrefix + hints + " " (1) + rule + "┘" (1) === paletteWidth + const footerRuleLen = Math.max(1, paletteWidth - 4 - cellWidth(morePrefix) - hintWidth); + + return ( + + {/* Header rule */} + + {"┌ "} + {headerTitle} + {` ${"─".repeat(headerRuleLen)} `} + {countHint} + {" ┐"} + + + {/* Rows */} + {Array.from({ length: VISIBLE_ROWS }).map((_, index) => { + const item = window[index] ?? null; + const absoluteIndex = start + index; + return ( + + ); + })} + + {/* Selected-item detail line */} + + + {/* Footer rule + key hints */} + + {"└ "} + {morePrefix ? {morePrefix} : null} + {"↑↓"} + {" move "} + {"· "} + {"↵"} + {" run "} + {"· "} + {"esc"} + {" close"} + {` ${"─".repeat(footerRuleLen)}`} + {"┘"} + + + ); +} + +// The detail line echoes the selected row's glyph, label, and full detail, then +// pads out to the inner width so the right border stays flush. +function DetailLine({ item, innerWidth }: { item: CommandPaletteItem | null; innerWidth: number }) { + if (!item) { + const empty = "No matches"; + const pad = " ".repeat(Math.max(0, innerWidth - 1 - empty.length)); + return ( + + {"│ "} + {empty} + {`${pad}│`} + + ); + } + const labelCap = Math.max(8, Math.floor(innerWidth * 0.4)); + const label = endTruncate(item.label, labelCap); + // Within innerWidth (the columns after "│"): + // lead space (1) + glyph (2) + " " (1) + label + " · " (3) + detail + const sep = " · "; + const fixed = 1 + GLYPH_WIDTH + 1 + cellWidth(label) + cellWidth(sep); + const detailCap = Math.max(4, innerWidth - fixed); + const detail = endTruncate(item.detail, detailCap); + const content = fixed + cellWidth(detail); + const pad = " ".repeat(Math.max(0, innerWidth - content)); + return ( + + {"│ "} + {glyph(item.kind)} + {` ${label}`} + {sep} + {detail} + {`${pad}│`} + + ); +} diff --git a/apps/ade-cli/src/tuiClient/components/Drawer.tsx b/apps/ade-cli/src/tuiClient/components/Drawer.tsx index a4d176bb2..ad95c983c 100644 --- a/apps/ade-cli/src/tuiClient/components/Drawer.tsx +++ b/apps/ade-cli/src/tuiClient/components/Drawer.tsx @@ -8,6 +8,8 @@ import { computeStackRowMeta, sortLanesForStackGraph } from "../laneTree"; import { useSpinFrame } from "../spinTick"; import { theme, type LaneStatusKind } from "../theme"; import type { AdeCodeProvider } from "../types"; +import { useHoveredHitId } from "../hitTestRegistry"; +import { Chip, Rail, statusGlyph, type StatusKind } from "./designKit"; type DrawerDensity = "full" | "mini"; type DrawerMode = "lanes" | "chats"; @@ -61,6 +63,31 @@ function deriveLaneStatus( return "idle"; } +/** Map a lane's wireframe status onto the shared design-kit status glyph set. */ +function laneStatusDot(status: LaneStatusKind): StatusKind { + switch (status) { + case "running": + return "live"; + case "attention": + return "pending"; + case "failed": + return "failed"; + case "primary": + return "info"; + case "idle": + default: + return "idle"; + } +} + +/** Map a chat session onto the shared design-kit status glyph set. */ +function chatStatusDot(session: AgentChatSessionSummary): StatusKind { + if (session.status === "active") return "live"; + if (session.awaitingInput) return "pending"; + if (session.status === "ended" || session.endedAt) return "done"; + return "idle"; +} + function truncate(text: string, max: number): string { if (max <= 1) return text.slice(0, max); if (text.length <= max) return text; @@ -136,6 +163,7 @@ export function Drawer({ loading = false, unavailableLaneIds = new Set(), width: requestedWidth, + scrollOffsetRows = 0, }: { lanes: LaneSummary[]; sessions: AgentChatSessionSummary[]; @@ -154,13 +182,16 @@ export function Drawer({ loading?: boolean; unavailableLaneIds?: ReadonlySet; width?: number; + scrollOffsetRows?: number; }) { const { stdout } = useStdout(); const resolvedPanelHeight = panelHeight ?? stdout?.rows ?? 40; const ordered = React.useMemo(() => sortLanesForStackGraph(lanes), [lanes]); const rowMeta = React.useMemo(() => computeStackRowMeta(ordered), [ordered]); - const laneRows = ordered.slice(0, visibleDrawerLaneCount(resolvedPanelHeight, ordered.length)); - const visibleRowMeta = rowMeta.slice(0, laneRows.length); + const visibleCount = visibleDrawerLaneCount(resolvedPanelHeight, ordered.length); + const laneStart = Math.max(0, Math.min(scrollOffsetRows, Math.max(0, ordered.length - visibleCount))); + const laneRows = ordered.slice(laneStart, laneStart + visibleCount); + const visibleRowMeta = rowMeta.slice(laneStart, laneStart + laneRows.length); const browsing = browsingLaneId ?? activeLaneId; const browsingLane = laneRows.find((l) => l.id === browsing) ?? null; @@ -180,6 +211,7 @@ export function Drawer({ if (addMode) borderColor = emphasisColor; else if (focused) borderColor = theme.color.violet; else borderColor = theme.color.border; + const hoveredId = useHoveredHitId(); if (density === "mini") { return ( @@ -189,6 +221,8 @@ export function Drawer({ addMode={addMode} emphasisColor={emphasisColor} lanes={laneRows} + laneStart={laneStart} + laneTotal={ordered.length} sessions={laneSessions} activeLaneId={activeLaneId} activeSessionId={activeSessionId} @@ -205,12 +239,18 @@ export function Drawer({ ); } + const headerTitle = addMode ? "PICK CHAT" : "LANES"; + const headerCount = addMode ? null : loading && lanes.length === 0 ? "…" : String(lanes.length); return ( - - {addMode ? "PICK CHAT" : `LANES · ${loading && lanes.length === 0 ? "…" : lanes.length}`} - + @@ -220,7 +260,12 @@ export function Drawer({ ) : null} {laneRows.map((lane, index) => { - const isSelected = index === selectedLaneIndex; + // laneRows is sliced by laneStart for scrolling, but selectedLaneIndex + // is an ABSOLUTE index into `ordered`; rebase before comparing so the + // highlight tracks the right lane once the list is scrolled. + const absoluteIndex = laneStart + index; + const isSelected = absoluteIndex === selectedLaneIndex; + const isHovered = hoveredId?.startsWith(`drawer:lane:${lane.id}:`) ?? false; const meta = visibleRowMeta[index] ?? { depth: 0, isLast: false, prefix: "" }; const worktreeAvailable = !unavailableLaneIds.has(lane.id); const status = deriveLaneStatus(lane, sessions, activeLaneId, unavailableLaneIds); @@ -230,14 +275,25 @@ export function Drawer({ const showChatBlock = mode === "chats" ? isBrowsing && browsingLane?.id === lane.id : isSelected; - const cardBorder = cardBorderColor(isSelected); - // width - 2 (outer drawer border) - 2 (lane container paddingX) - 2 (card border) - 2 (card paddingX) - const cardInnerWidth = width - 8; + // Flat rows: no side borders or per-card padding, so content uses the + // full drawer width. Only the outer drawer border (2) + the lane + // container paddingX (2) inset the content now. + const cardInnerWidth = width - 4; + // The top edge is a faint hairline separator for every card except the + // first (whose top would otherwise double up the header rule). It tints + // violet on the selected card to reinforce the selection rail. + // Each lane is a rounded card. The box border IS the single selection + // indicator — violet when selected, neutral otherwise — so there's no + // second inner rail (that was the confusing "two lines"). Same two + // border rows as before, so the mouse hit-test cadence is unchanged. + const cardBorderColor = isSelected ? theme.color.violet : theme.color.border; + const laneContentWidth = Math.max(10, cardInnerWidth - 4); return ( 0 ? 1 : 0} @@ -246,8 +302,9 @@ export function Drawer({ lane={lane} status={status} prefix={meta.prefix} - width={cardInnerWidth} + width={laneContentWidth} selected={isSelected} + hovered={isHovered} active={lane.id === activeLaneId} provider={sessionProviderFor(lane, sessions)} pr={prByLaneId[lane.id] ?? null} @@ -259,9 +316,10 @@ export function Drawer({ sessions={laneChatSessions} activeSessionId={activeSessionId} selectedChatIndex={selectedChatIndex} - width={cardInnerWidth} + width={laneContentWidth} worktreeAvailable={worktreeAvailable} interactive={mode === "chats"} + hoveredId={hoveredId} /> ) : null} @@ -269,60 +327,17 @@ export function Drawer({ })} - - {!focused ? ( - <> - - - - ) : addMode ? ( - <> - - ↑↓ - {" select chat in left pane"} - - - ↵/click - {" add · "} - esc - {" cancel"} - - - ) : mode === "chats" ? ( - <> - - ↑↓ - {" "} - {browsingLane && unavailableLaneIds.has(browsingLane.id) ? "lane unavailable" : "select chat"} - - - - {" open · "} - esc - {" lanes · "} - tab - {" section"} - - - ) : ( - <> - - ↑↓ - {" lanes"} - - - - {" enter chats · "} - tab - {" section"} - - - )} - + = laneRows.length ? theme.color.violet : theme.color.t4} - bold={focused && mode === "lanes" && selectedLaneIndex >= laneRows.length} + color={focused && mode === "lanes" && selectedLaneIndex >= ordered.length ? theme.color.violet : theme.color.t4} + bold={focused && mode === "lanes" && selectedLaneIndex >= ordered.length} > + new lane @@ -331,8 +346,106 @@ export function Drawer({ ); } -function cardBorderColor(selected: boolean): string { - return selected ? theme.color.violet : theme.color.border; +// Lane-card chrome note: the drawer's mouse hit-test (drawerMouseHitForLine in +// app.tsx) reserves exactly two border rows per lane card (top + bottom) plus a +// 1-row margin between cards. The rounded box used per lane keeps exactly those +// two border rows, so the hit-test cadence stays correct. + +/** + * A single-row titled hairline-rule section header (glyph + bold title + count + * chip, then a rule that fills the remaining width). Stays exactly one row so + * the parent's mouse hit-test row math is preserved. Mirrors the SectionHeader + * primitive's look while keeping the literal "TITLE · N" string that callers and + * tests rely on. + */ +function DrawerSectionRule({ + title, + count, + color, + glyph, + width, +}: { + title: string; + count: string | null; + color: string; + glyph?: string; + width: number; +}) { + const inner = Math.max(6, Math.floor(width)); + const countText = count != null ? ` · ${count}` : ""; + const used = (glyph ? glyph.length + 1 : 0) + title.length + countText.length + 1; + const ruleLen = Math.max(0, inner - used); + return ( + + {glyph ? {`${glyph} `} : null} + {title} + {countText ? {countText} : null} + {ruleLen > 0 ? {` ${"─".repeat(ruleLen)}`} : null} + + ); +} + +/** + * Dim key-hint footer for the drawer. Mirrors the KeyHints design-kit look (keys + * in accent, actions dim, `·` separators) but takes a per-mode accent so add + * mode can stay amber. Renders a single truncating row; the surrounding region + * is flexible and not part of the parent's row hit-test. + */ +function DrawerFooter({ + focused, + addMode, + mode, + emphasisColor, + laneUnavailable, +}: { + focused: boolean; + addMode: boolean; + mode: DrawerMode; + emphasisColor: string; + laneUnavailable: boolean; +}) { + // Two dim hint rows (mirrors the KeyHints look: keys in accent, actions dim, + // `·` separators) so the full hint set fits a narrow drawer without + // truncating the escape/confirm keys. The footer region is flexible and is + // not part of the parent's mouse hit-test, so row count here is cosmetic. + let primary: Array<[string, string]> = []; + let secondary: Array<[string, string]> = []; + let keyColor: string = theme.color.accent; + if (!focused) { + // keep two blank rows for stable vertical rhythm + } else if (addMode) { + keyColor = emphasisColor; + primary = [["↑↓", "select chat in left pane"]]; + secondary = [["↵/click", "add"], ["esc", "cancel"]]; + } else if (mode === "chats") { + primary = [["↑↓", laneUnavailable ? "lane unavailable" : "select chat"]]; + secondary = [["↵", "open"], ["esc", "lanes"], ["tab", "section"]]; + } else { + primary = [["↑↓", "lanes"]]; + secondary = [["↵", "enter chats"], ["tab", "section"]]; + } + return ( + + + + + ); +} + +/** One dim key-hint row: `key action · key action · …`, truncating on overflow. */ +function DrawerHintLine({ items, keyColor }: { items: Array<[string, string]>; keyColor: string }) { + if (items.length === 0) return ; + return ( + + {items.map(([key, action], index) => ( + + {index > 0 ? {" · "} : null} + {key} + {` ${action}`} + + ))} + + ); } /** @@ -354,6 +467,7 @@ function LaneCard({ pr, diffStats, worktreeAvailable, + hovered, }: { lane: LaneSummary; status: LaneStatusKind; @@ -365,8 +479,16 @@ function LaneCard({ pr: DrawerPrSummary | null; diffStats: DiffLineStats | null; worktreeAvailable: boolean; + hovered?: boolean; }) { - const nameColor = selected || active || status === "primary" ? theme.color.violet : theme.color.t1; + // Lane identity is now conveyed by tinting the NAME with the user-assigned + // lane color (when set) instead of stacking a second vertical accent bar next + // to the selection rail. Selection/active/hover/primary still win with violet + // so the highlighted lane always reads as violet. + const highlighted = selected || active || hovered || status === "primary"; + const nameColor = highlighted + ? theme.color.violet + : lane.color ?? theme.color.t1; const detail = laneDetailSuffix(lane, diffStats, worktreeAvailable); const exec = theme.provider(provider); const age = formatLaneAge(lane); @@ -382,7 +504,7 @@ function LaneCard({ return null; case "running": return "run"; case "attention": return "wait"; - case "failed": return worktreeAvailable ? "fail" : "miss"; + case "failed": return worktreeAvailable ? "fail" : "no worktree"; default: return null; } })(); @@ -395,6 +517,14 @@ function LaneCard({ } })(); + // Status dot leads line 1 so a running/awaiting/failed lane pops at a glance + // without parsing the chip text. Green is reserved for the live dot, amber for + // awaiting, red for failed; idle stays a dim hollow ○, primary an info dot. + const dot = statusGlyph(laneStatusDot(status)); + // Leading chrome on line 1: the status dot (1 cell) + space. (The selection + // rail is gone — the card's box border now indicates selection.) + const LEAD_WIDTH = 2; + const indicatorWidth = prefix.length; const chipWidth = chipText ? chipText.length : 0; const prPillText = pr?.state === "open" ? formatPrPillText(pr) : null; @@ -412,10 +542,12 @@ function LaneCard({ const canShowVmBadge = isVmLane && contentWidth - indicatorWidth - rightReservationWithoutVm - VM_BADGE_WIDTH - 1 >= 3; const reservedRight = rightReservationWithoutVm + (canShowVmBadge ? VM_BADGE_WIDTH + 1 : 0); - const nameMax = Math.max(3, contentWidth - indicatorWidth - reservedRight); + const nameMax = Math.max(3, contentWidth - indicatorWidth - LEAD_WIDTH - reservedRight); const name = truncate(lane.name, nameMax); - const line2Indent = " ".repeat(Math.min(indicatorWidth, 4)); + // Line 2 indents under the name (past prefix + lead chrome), capped so deep + // stacks don't push the branch ref off a narrow drawer. + const line2Indent = " ".repeat(Math.min(indicatorWidth + LEAD_WIDTH, 6)); const branch = lane.branchRef ?? ""; // Diff is rendered on its own third line under selected cards; never inline // on line 2. Hints (missing worktree, dirty, rebase, checkpoint Xd) still @@ -436,6 +568,7 @@ function LaneCard({ {prefix ? {prefix} : null} + {dot.glyph} {pad(name, nameMax)} @@ -469,7 +602,7 @@ function LaneCard({ ) : ( · )} - {truncBranch} + {truncHint ? ( <> · @@ -524,6 +657,7 @@ function ChatBlock({ width, worktreeAvailable, interactive = true, + hoveredId, }: { sessions: AgentChatSessionSummary[]; activeSessionId: string | null; @@ -531,12 +665,13 @@ function ChatBlock({ width: number; worktreeAvailable: boolean; interactive?: boolean; + hoveredId?: string | null; }) { if (!worktreeAvailable) { return ( - CHATS · unavailable + worktree missing @@ -544,34 +679,53 @@ function ChatBlock({ ); } - if (sessions.length === 0 && selectedChatIndex !== 0) { - return ( - - No chats in lane. - - ); - } + // An empty lane still falls through to the normal render below, which shows + // the "CHATS · 0" header and a working "+ new chat" row — matching the + // hit-test, which always expects a new-chat row at the block start. (Returning + // early here used to drop the button, leaving chatless lanes with no way to + // start a chat.) const max = Math.max(8, width - 4); return ( - CHATS · {sessions.length} + {sessions.map((session, index) => { const running = session.status === "active"; const selected = interactive && index === selectedChatIndex; + const hovered = hoveredId?.startsWith(`drawer:chat:${session.sessionId}:`) ?? false; const provider = (session.provider as AdeCodeProvider) ?? null; const exec = theme.provider(provider); const when = formatSessionAge(session); - const label = truncate(formatSessionLabel(session), max - 6); + // Status dot leads each chat row so live/awaiting/ended state pops at a + // glance: live ● (the running spinner takes over for the active chat), + // awaiting ◔ amber, ended ✓, otherwise an idle ○. The provider brand + // glyph follows to keep the agent identity, then the title. + const dot = statusGlyph(chatStatusDot(session)); + const label = truncate(formatSessionLabel(session), max - 8); // White by default. Violet on selection (the only highlight state in a // TUI). The running spinner + activeSession dot already convey activity // — no need to also recolor the title, and bold is avoided because some // xterm builds render bold characters slightly wider, which makes the // selected row look outdented next to its neighbours. - const titleColor: string = selected ? theme.color.violet : theme.color.t1; + // Awaiting-input chats tint amber (a calm "needs you" signal) when not + // selected; selection/hover still wins with violet. Running activity is + // conveyed by the spinner, not a recolor. + const titleColor: string = selected || hovered + ? theme.color.violet + : session.awaitingInput && !running + ? theme.color.attention + : theme.color.t1; return ( 0 ? 1 : 0}> + {running ? ( + // Running chats carry their live signal via the trailing spinner + // (kept adjacent to the age); lead with a blank so names stay + // aligned with the idle/awaiting/done rows above and below. + {" "} + ) : ( + {dot.glyph} + )} {exec.glyph} {label} @@ -586,7 +740,7 @@ function ChatBlock({ ); })} - + + new chat @@ -606,6 +760,8 @@ function MiniDrawer({ addMode, emphasisColor, lanes, + laneStart, + laneTotal, sessions, activeLaneId, activeSessionId, @@ -624,6 +780,8 @@ function MiniDrawer({ addMode: boolean; emphasisColor: string; lanes: LaneSummary[]; + laneStart: number; + laneTotal: number; sessions: AgentChatSessionSummary[]; activeLaneId: string | null; activeSessionId: string | null; @@ -639,6 +797,7 @@ function MiniDrawer({ }) { void focused; void browsingLaneId; + const hoveredId = useHoveredHitId(); const inner = width - 2; return ( @@ -655,19 +814,24 @@ function MiniDrawer({ {lanes.map((lane, index) => { const status = deriveLaneStatus(lane, rawSessions, activeLaneId, unavailableLaneIds); const meta = rowMeta[index] ?? { depth: 0, prefix: "", isLast: false }; - const selected = index === selectedLaneIndex; + // lanes is sliced by laneStart; selectedLaneIndex is absolute. + const selected = laneStart + index === selectedLaneIndex; + const hovered = hoveredId?.startsWith(`drawer:lane:${lane.id}:`) ?? false; const detail = formatLaneAge(lane); const isVmLane = lane.runtimePlacement === "macos-vm"; const vmSuffixWidth = isVmLane ? 3 : 0; - const nameMax = Math.max(4, inner - 3 - detail.length - meta.prefix.length - vmSuffixWidth); + // Leading chrome: selection rail (1) + status dot (1) + space (1) = 3. + const dot = statusGlyph(laneStatusDot(status)); + const nameMax = Math.max(4, inner - 5 - detail.length - meta.prefix.length - vmSuffixWidth); return ( - - {theme.rail} + + + {dot.glyph}{" "} {meta.prefix ? {meta.prefix} : } {pad(truncate(lane.name, nameMax), nameMax)} @@ -691,17 +855,20 @@ function MiniDrawer({ {sessions.map((session, index) => { const selected = index === selectedChatIndex; const running = session.status === "active"; + const hovered = hoveredId?.startsWith(`drawer:chat:${session.sessionId}:`) ?? false; const provider = (session.provider as AdeCodeProvider) ?? null; const exec = theme.provider(provider); const when = formatSessionAge(session); - const nameMax = Math.max(4, inner - 3 - when.length); + const dot = statusGlyph(chatStatusDot(session)); + const nameMax = Math.max(4, inner - 5 - when.length); return ( + {running ? : {dot.glyph} } {exec.glyph} - {lanes[selectedLaneIndex] && unavailableLaneIds.has(lanes[selectedLaneIndex].id) ? ( + {lanes[selectedLaneIndex - laneStart] && unavailableLaneIds.has(lanes[selectedLaneIndex - laneStart]!.id) ? ( worktree missing ) : ( - + + new chat )} @@ -737,8 +904,8 @@ function MiniDrawer({ = lanes.length ? theme.color.violet : theme.color.t4} - bold={focused && mode === "lanes" && selectedLaneIndex >= lanes.length} + color={focused && mode === "lanes" && selectedLaneIndex >= laneTotal ? theme.color.violet : theme.color.t4} + bold={focused && mode === "lanes" && selectedLaneIndex >= laneTotal} > + lane diff --git a/apps/ade-cli/src/tuiClient/components/FooterControls.tsx b/apps/ade-cli/src/tuiClient/components/FooterControls.tsx index 9a9d4c09e..871b3d521 100644 --- a/apps/ade-cli/src/tuiClient/components/FooterControls.tsx +++ b/apps/ade-cli/src/tuiClient/components/FooterControls.tsx @@ -3,10 +3,12 @@ import { Box, Text } from "ink"; import { theme } from "../theme"; import type { AdeCodeProvider } from "../types"; import { gridMiniMapText } from "./GridMiniMap"; +import { useHoveredHitId } from "../hitTestRegistry"; +import { useShimmerTick } from "../spinTick"; const TOKEN_BAR_CELLS = 10; -type InlineRowCell = 'provider' | 'model' | 'reasoning' | 'permission' | 'subagents' | null; +type InlineRowCell = 'provider' | 'model' | 'fast' | 'reasoning' | 'permission' | 'subagents' | null; function Hint({ keyLabel, action }: { keyLabel: string; action: string }) { return ( @@ -17,21 +19,57 @@ function Hint({ keyLabel, action }: { keyLabel: string; action: string }) { ); } -function tokenBarColor(percent: number): string { +export function tokenBarColor(percent: number): string { + // Context-usage fill: on-brand violet while healthy, escalating to amber then + // red as context runs low. (No green — a green block here read as a glitch.) if (percent >= 95) return theme.color.danger; if (percent >= 80) return theme.color.warning; - if (percent >= 50) return theme.color.accent; - return theme.color.running; + return theme.color.accent; } -function TokenBar({ percent }: { percent: number }) { +export function TokenBar({ percent }: { percent: number }) { const safe = Math.max(0, Math.min(100, percent)); - const filled = Math.max(0, Math.min(TOKEN_BAR_CELLS, Math.round((safe / 100) * TOKEN_BAR_CELLS))); + const target = Math.max(0, Math.min(TOKEN_BAR_CELLS, Math.round((safe / 100) * TOKEN_BAR_CELLS))); + + // The shimmer tick only advances during real activity (streaming/connecting). + // We lean on it for *both* the growth easing and the danger pulse so the bar + // costs nothing when idle: with the tick frozen, this component never re-renders + // and the math below collapses to a static render at the true value. + const tick = useShimmerTick(); + const animRef = React.useRef({ filled: target, tick }); + + let filled: number; + if (tick === animRef.current.tick) { + // Idle (or first render): the tick is not advancing, so we can't animate. + // Snap to the true value and keep it there — no re-render loop, no drift. + filled = target; + animRef.current.filled = target; + } else { + // Active: ease one cell per tick toward the target. The bar converges in a + // few frames and then holds steady (current === target → no further motion). + const current = animRef.current.filled; + if (current < target) filled = current + 1; + else if (current > target) filled = current - 1; + else filled = target; + animRef.current.filled = filled; + animRef.current.tick = tick; + } + const empty = TOKEN_BAR_CELLS - filled; const color = tokenBarColor(safe); + + // Context exhaustion: pulse the last filled cell bright/dim so it's *felt*. + // Gated on the same activity tick — dim phase on alternating frames. + const pulseDanger = safe >= 95 && filled > 0; + const pulseDim = pulseDanger && tick % 2 === 1; + const leading = pulseDanger ? filled - 1 : filled; + return ( - {"▓".repeat(filled)} + {"▓".repeat(Math.max(0, leading))} + {pulseDanger ? ( + {"▓"} + ) : null} {"░".repeat(empty)} ); @@ -50,6 +88,7 @@ function Cell({ baseColor, accentColor, locked, + hovered, }: { value: string; focused: boolean; @@ -57,6 +96,7 @@ function Cell({ baseColor: string; accentColor: string; locked?: boolean; + hovered?: boolean; }) { if (locked) { return ( @@ -72,7 +112,7 @@ function Cell({ ); } - if (rowFocused) { + if (rowFocused || hovered) { return {value}; } return {value}; @@ -84,12 +124,14 @@ export function FooterControls({ modelDisplay, reasoningEffort, permissionLabel, + permissionDetail, contextPercent, tokenSummary, approvalActive, liveAgentCount, subagentsButtonVisible, fastMode, + fastSupported, inlineRowFocused, inlineRowCell, planMode, @@ -103,12 +145,14 @@ export function FooterControls({ modelDisplay?: string | null; reasoningEffort?: string | null; permissionLabel?: string | null; + permissionDetail?: string | null; contextPercent?: number | null; tokenSummary?: string | null; approvalActive?: boolean; liveAgentCount?: number; subagentsButtonVisible?: boolean; fastMode?: boolean; + fastSupported?: boolean; inlineRowFocused?: boolean; inlineRowCell?: InlineRowCell; planMode?: boolean; @@ -123,6 +167,9 @@ export function FooterControls({ const agents = liveAgentCount ?? 0; const showSubagents = subagentsButtonVisible === true; const providerIsLocked = providerLocked === true; + const hoveredId = useHoveredHitId(); + const inlineHovered = (name: string) => hoveredId === `footer:inline:${name}`; + const footerHovered = (id: string) => hoveredId === id; return ( @@ -138,6 +185,7 @@ export function FooterControls({ baseColor={planMode ? theme.color.planMode : brand.color} accentColor={accentColor} locked={providerIsLocked} + hovered={inlineHovered("provider")} /> ) : null} {modelDisplay ? ( @@ -149,10 +197,23 @@ export function FooterControls({ rowFocused={rowFocused} baseColor={theme.color.t2} accentColor={accentColor} + hovered={inlineHovered("model")} /> ) : null} - {fastMode ? ( + {fastSupported ? ( + <> + {" "} + + + ) : fastMode ? ( <> {" "} fast @@ -167,6 +228,7 @@ export function FooterControls({ rowFocused={rowFocused} baseColor={theme.color.t3} accentColor={accentColor} + hovered={inlineHovered("reasoning")} /> ) : null} @@ -179,7 +241,14 @@ export function FooterControls({ rowFocused={rowFocused} baseColor={theme.color.t3} accentColor={accentColor} + hovered={inlineHovered("permission")} /> + {permissionDetail ? ( + <> + {" · "} + {permissionDetail} + + ) : null} ) : null} {showSubagents ? ( @@ -196,7 +265,7 @@ export function FooterControls({ ); } return ( - + {subagentValue} ); @@ -223,7 +292,19 @@ export function FooterControls({ {multiViewMap ? ( <> {" "} - {gridMiniMapText(multiViewMap.count, multiViewMap.focusedIndex)} + {(() => { + // Accent the focused tile glyph (▣) violet to match the grid's + // selected-pane color; the rest of the minimap stays dim. + const map = gridMiniMapText(multiViewMap.count, multiViewMap.focusedIndex); + const [before, after = ""] = map.split("▣"); + return ( + + {before} + + {after} + + ); + })()} {multiViewMap.notice ? ( {` ${multiViewMap.notice}`} ) : null} @@ -241,9 +322,9 @@ export function FooterControls({ ) : approvalActive ? ( <> - a + a {" approve "} - d + d {" deny · "} ← → {" choose"} diff --git a/apps/ade-cli/src/tuiClient/components/ModelPicker/ModelPickerPane.tsx b/apps/ade-cli/src/tuiClient/components/ModelPicker/ModelPickerPane.tsx index 8b05c792e..2895198ec 100644 --- a/apps/ade-cli/src/tuiClient/components/ModelPicker/ModelPickerPane.tsx +++ b/apps/ade-cli/src/tuiClient/components/ModelPicker/ModelPickerPane.tsx @@ -1,24 +1,16 @@ import React from "react"; import { Box, Text } from "ink"; import { theme } from "../../theme"; -import type { AdeCodeProvider } from "../../types"; -import type { ModelPickerEntry, ModelPickerRailEntry, ModelPickerState } from "./types"; - -const PROVIDER_GLYPHS: Record = { - codex: "◇", - claude: "✦", - opencode: "○", - cursor: "◈", - droid: "▲", - ollama: "●", - lmstudio: "■", -}; - -function railGlyph(entry: ModelPickerRailEntry): string { - if (entry.kind === "favorites") return "★"; - if (entry.kind === "recents") return "◷"; - return PROVIDER_GLYPHS[entry.provider] ?? "·"; -} +import type { SetupPaneRow, SetupPaneRowKind } from "../../types"; +import { useHoveredHitId } from "../../hitTestRegistry"; +import { KeyHints } from "../designKit"; +import type { ModelPickerAuthStatus, ModelPickerEntry, ModelPickerRailEntry, ModelPickerState } from "./types"; +// The model list is a FIXED-height window so a long catalog (e.g. OpenCode's +// dozens of providers) scrolls inside its own region instead of shoving the +// settings footer around. Settings stay stickied below. Geometry constants + +// the windowing function live in modelPickerGeometry so the click hit-test in +// app.tsx computes identical rects from a SINGLE source. +import { MODEL_LIST_ROWS, RAIL_WIDTH, rowWindow, modelPickerGeometry } from "./modelPickerGeometry"; function endTruncate(value: string, max: number): string { if (max <= 1) return value.length ? "…" : ""; @@ -26,55 +18,58 @@ function endTruncate(value: string, max: number): string { return `${value.slice(0, Math.max(0, max - 1))}…`; } -function ModelPickerSearchBar({ - query, - searchMode, - width, -}: { - query: string; - searchMode: boolean; - width: number; -}) { - const displayed = endTruncate(query || "search models…", Math.max(8, width - 8)); - return ( - - [ - {searchMode ? "▸" : "/"} - - - {displayed} - - ] - - ); +// Brand glyph + color for a category/provider rail entry, reusing the canonical +// provider identity so the picker matches the rest of the TUI. +function railIcon(entry: ModelPickerRailEntry): { glyph: string; color: string } { + if (entry.kind === "favorites") return { glyph: "★", color: theme.color.warning }; + if (entry.kind === "recents") return { glyph: "◷", color: theme.color.t3 }; + const brand = theme.provider(entry.provider); + return { glyph: brand.glyph, color: brand.color }; +} + +// Amber pip for a provider that needs sign-in; nothing for a ready provider +// (green would read as idle chrome). +function authPip(status: ModelPickerAuthStatus): { glyph: string; color: string } | null { + if (status === "unavailable") return { glyph: "○", color: theme.color.attention }; + return null; +} + +function settingIcon(kind: SetupPaneRowKind): string { + switch (kind) { + case "reasoning": return "✦"; + case "permission": return "◆"; + case "codex-fast": return "↯"; + case "output-style": return "✎"; + case "refresh-status": return "↻"; + case "open-settings": return "↗"; + default: return "·"; + } } -function ModelPickerRail({ +// ── Vertical icon rail (categories / provider families) ────────────────────── + +function VerticalRail({ entries, selectedIndex, - width, + hoveredId, }: { entries: ModelPickerRailEntry[]; selectedIndex: number; - width: number; + hoveredId: string | null; }) { - const labelWidth = Math.max(4, Math.min(9, width - 4)); return ( - + {entries.map((entry, index) => { const selected = index === selectedIndex; + const hovered = hoveredId === `right:model-picker:rail:${index}`; + const accent = selected || hovered; + const icon = railIcon(entry); + const pip = entry.kind === "provider" ? authPip(entry.authStatus) : null; return ( - - {selected ? theme.rail : " "} - - - {" "} - {railGlyph(entry)}{" "} - - - {endTruncate(entry.label, labelWidth)} - + {selected ? "▶" : " "} + {icon.glyph} + {pip ? {pip.glyph} : {" "}} ); })} @@ -82,86 +77,192 @@ function ModelPickerRail({ ); } +// ── Compact sub-provider selector ──────────────────────────────────────────── +// Replaces the old wrap-everything chip block: shows the ACTIVE group only, +// with a count and the [ ] switch hint. Bounded to a single row. +function SubProviderSelector({ + tabs, + selectedIndex, + width, +}: { + tabs: { key: string; label: string }[]; + selectedIndex: number; + width: number; +}) { + if (tabs.length <= 1) return null; + const safe = Math.max(0, Math.min(selectedIndex, tabs.length - 1)); + const active = tabs[safe]; + if (!active) return null; + return ( + + {"‹ "} + {endTruncate(active.label, Math.max(8, width - 18))} + {` ${safe + 1}/${tabs.length}`} + {" › "} + {"[ ]"} + + ); +} + +// ── Model list row (single line — no overlapping second line) ──────────────── + +// Fixed prefix drawn before every name: selection rail (1) + favorite star (1) +// + " glyph " (space + brand glyph + space = 3) = 5 cols. +const ROW_PREFIX_WIDTH = 5; +// Per-row suffixes, measured to the character so the name reservation is exact +// and a row can never wrap onto a second line (the geometry assumes 1 line each): +// active -> " ● now" (7 cols) +// unavailable-> " · sign in" (11 cols) +const ROW_SUFFIX_ACTIVE = " ● now".length; +const ROW_SUFFIX_UNAVAILABLE = " · sign in".length; +const ROW_NAME_MIN = 6; + function ModelListRow({ entry, selected, active, - width, + hovered, + contentWidth, }: { entry: ModelPickerEntry; selected: boolean; active: boolean; - width: number; + hovered: boolean; + contentWidth: number; }) { - const labelMax = Math.max(8, width - 4); - const starColor = entry.isFavorite ? theme.color.warning : theme.color.t5; + const accent = selected || hovered; const brand = theme.provider(entry.family); - const activeChip = active ? " now" : ""; - const localChip = entry.family === "ollama" || entry.family === "lmstudio" ? " local" : ""; - const chipWidth = activeChip.length + localChip.length; + const nameColor = !entry.isAvailable + ? theme.color.t5 + : accent + ? theme.color.violet + : active + ? theme.color.t1 + : theme.color.t2; + // Reserve the EXACT suffix this row draws (active and unavailable are mutually + // exclusive — an active model is by definition available). The name column is + // whatever is left after the fixed prefix and this row's suffix. + const suffixWidth = active + ? ROW_SUFFIX_ACTIVE + : !entry.isAvailable + ? ROW_SUFFIX_UNAVAILABLE + : 0; + const nameWidth = Math.max(ROW_NAME_MIN, contentWidth - ROW_PREFIX_WIDTH - suffixWidth); return ( - - - - {selected ? theme.rail : " "} - - - {entry.isFavorite ? " ★" : " ☆"} - - {brand.glyph} - - {" "} - {endTruncate(entry.displayName, Math.max(6, labelMax - chipWidth - 4))} - - {active ? ( - now - ) : null} - {localChip ? ( - local - ) : null} - - {entry.subProvider ? ( - - - {endTruncate(entry.subProvider, labelMax - 2)} - + + {/* Selection rail (violet) — selection is shown by COLOR, not indentation. */} + {selected ? theme.rail : " "} + {entry.isFavorite ? "★" : "☆"} + {` ${brand.glyph} `} + + {endTruncate(entry.displayName, nameWidth)} + + {active ? {" ● now"} : null} + {!entry.isAvailable ? {" · sign in"} : null} + + ); +} + +// ── Sticky settings footer ─────────────────────────────────────────────────── + +function SettingsFooter({ + rows, + footerFocus, + width, + hoveredId, +}: { + rows: SetupPaneRow[]; + footerFocus: SetupPaneRowKind | null; + width: number; + hoveredId: string | null; +}) { + if (!rows.length) return null; + const visibleRows = rows.filter((row) => row.kind !== "provider" && row.kind !== "model"); + if (!visibleRows.length) return null; + const settingRows = visibleRows.filter((row) => row.kind !== "apply"); + const applyRow = visibleRows.find((row) => row.kind === "apply") ?? null; + const divider = "─".repeat(Math.max(4, width)); + + return ( + + {divider} + {settingRows.length ? ( + + {settingRows.map((row) => { + const focused = footerFocus === row.kind; + const hovered = hoveredId === `right:model-picker:setting:${row.kind}`; + const accent = focused || hovered; + const labelColor = row.disabled ? theme.color.t5 : theme.color.t4; + const valueColor = row.disabled ? theme.color.t5 : accent ? theme.color.violet : theme.color.t2; + return ( + + {focused ? theme.rail : " "} + {`${settingIcon(row.kind)} `} + {endTruncate(row.label.toLowerCase(), 12)}{" "} + {endTruncate(row.value, 14)} + + ); + })} ) : null} - {!entry.isAvailable ? ( - - - Configure provider in ADE/OpenCode - + {applyRow ? ( + + {(() => { + const focused = footerFocus === "apply"; + const hovered = hoveredId === "right:model-picker:setting:apply"; + const accent = focused || hovered; + const color = applyRow.disabled ? theme.color.t5 : accent ? theme.color.violet : theme.color.violetDeep; + return {`[ ${endTruncate(applyRow.label, 20)} ]`}; + })()} ) : null} ); } -const VISIBLE_ROW_BUDGET = 12; +// ── Windowing ──────────────────────────────────────────────────────────────── +// rowWindow + RAIL_WIDTH + MODEL_LIST_ROWS are imported from modelPickerGeometry +// (the single geometry source shared with the app.tsx click hit-test). -function rowWindow(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 }; +// ── Dev-only hit-test verifier ─────────────────────────────────────────────── +// Gated on ADE_DEBUG_HITTEST. Lists the relative (paneTop/paneLeft = 0) rects +// the SHARED geometry helper produces for the current state, so a developer can +// cross-check that the painted rows line up with the clickable rects. Static +// (no timers, idle = zero extra re-renders); neutral chrome colors only. +function DebugHitOverlay({ state }: { state: ModelPickerState }) { + if (!process.env.ADE_DEBUG_HITTEST) return null; + const geometry = modelPickerGeometry({ + paneLeft: 0, + paneTop: 0, + paneWidth: 40, + state, + rows: 100, + }); + const fmt = (id: string, r: { x: number; y: number; w: number; h: number }) => + `${id} y${r.y} x${r.x} ${r.w}×${r.h}`; + const lines: string[] = [ + fmt("search", geometry.search), + ...geometry.rail.map((entry) => fmt(`rail[${entry.id.split(":").pop()}]`, entry.rect)), + ...geometry.entries.map((entry) => fmt(`entry#${entry.index}`, entry.rect)), + ...geometry.settings.map((entry) => fmt(`set:${entry.id.split(":").pop()}`, entry.rect)), + ...(geometry.apply ? [fmt("apply", geometry.apply)] : []), + ]; + return ( + + hit-test geometry (ADE_DEBUG_HITTEST) + {lines.map((line, index) => ( + {line} + ))} + + ); +} + +function emptyStateLabel(state: ModelPickerState, railEntry: ModelPickerRailEntry | undefined): string { + if (state.query.trim()) return "No models match your search."; + if (railEntry?.kind === "favorites") return "Press f on a model to pin it here."; + if (railEntry?.kind === "recents") return "Models you switch to appear here."; + if (railEntry?.kind === "provider" && railEntry.authStatus === "unavailable") return "Sign in to use this provider."; + return "No models available."; } export function ModelPickerPane({ @@ -171,117 +272,125 @@ export function ModelPickerPane({ state: ModelPickerState; width: number; }) { + const hoveredId = useHoveredHitId(); const innerWidth = Math.max(20, width - 4); + const searching = state.query.trim().length > 0; const railEntry = state.railEntries[state.railIndex] ?? state.railEntries[0]; const activeEntry = state.activeModelId ? state.entries.find((entry) => entry.modelId === state.activeModelId) ?? null : null; - const headingLabel = state.query.trim() - ? "Search results" - : railEntry?.kind === "favorites" - ? "Favorites" - : railEntry?.kind === "recents" - ? "Recents" - : (railEntry?.label ?? "Models"); - const window = rowWindow(state.entries.length, state.focusedIndex, VISIBLE_ROW_BUDGET); + const window = rowWindow(state.entries.length, state.focusedIndex, MODEL_LIST_ROWS); const visibleEntries = state.entries.slice(window.start, window.end); - const hiddenBefore = window.start; const hiddenAfter = state.entries.length - window.end; + const hiddenBefore = window.start; + // Content sits right of the icon rail (full width while searching). Each row + // reserves its OWN prefix + (active/unavailable) suffix from contentWidth, so + // the precise name width is computed per row inside ModelListRow — guaranteeing + // every row stays exactly one line at any terminal width. + const contentWidth = searching ? innerWidth : Math.max(14, innerWidth - RAIL_WIDTH - 2); + + // The list always occupies exactly MODEL_LIST_ROWS rows (padded with blanks) + // so the settings footer never shifts as the catalog length changes. + const listRows: React.ReactNode[] = []; + if (state.entries.length === 0) { + listRows.push( + + {endTruncate(emptyStateLabel(state, railEntry), contentWidth)} + , + ); + } else { + visibleEntries.forEach((entry, sliceIndex) => { + const flatIndex = window.start + sliceIndex; + listRows.push( + , + ); + }); + } + while (listRows.length < MODEL_LIST_ROWS) { + listRows.push( ); + } return ( - - - - - {state.entries.length} model{state.entries.length === 1 ? "" : "s"} · {headingLabel} + {/* Header (fixed). */} + + + {state.entries.length} model{state.entries.length === 1 ? "" : "s"} + {state.laneLabel ? ` · ${endTruncate(state.laneLabel, Math.max(6, innerWidth - 18))}` : ""} {activeEntry ? ( - Using {theme.provider(activeEntry.family).glyph} {endTruncate(activeEntry.displayName, Math.max(8, innerWidth - 10))} + {"● now "} + {theme.provider(activeEntry.family).glyph} + {` ${endTruncate(activeEntry.displayName, Math.max(8, innerWidth - 12))}`} ) : null} + {state.activeProviderAuthStatus === "unavailable" && state.activeProviderSignInHint ? ( + {`Sign in: ${state.activeProviderSignInHint}`} + ) : null} - - - - - - {headingLabel} - - {state.providerTabs.length > 1 ? ( - - {state.providerTabs.map((tab, index) => { - const selected = index === state.providerTabIndex; - return ( - - {selected ? "[" : " "} - {endTruncate(tab.label, 12)} - {selected ? "]" : " "} - {" "} - - ); - })} - - ) : null} - {state.entries.length === 0 ? ( - - {state.query.trim() - ? "No models match your search." - : railEntry?.kind === "favorites" - ? "Press f on a model to pin it here." - : railEntry?.kind === "recents" - ? "Models you switch to will appear here." - : railEntry?.kind === "provider" && railEntry.provider === "opencode" - ? "Install OpenCode to use these models." - : railEntry?.kind === "provider" && railEntry.provider === "ollama" - ? "Install OpenCode to use Ollama models." - : railEntry?.kind === "provider" && railEntry.provider === "lmstudio" - ? "Install OpenCode to use LM Studio models." - : "No models available."} - - ) : ( - <> - {hiddenBefore > 0 ? ( - {` ↑ ${hiddenBefore} earlier`} - ) : null} - {visibleEntries.map((entry, sliceIndex) => { - const flatIndex = window.start + sliceIndex; - const selected = flatIndex === state.focusedIndex; - const active = state.activeModelId != null && entry.modelId === state.activeModelId; - return ( - - ); - })} - {hiddenAfter > 0 ? ( - {` ↓ ${hiddenAfter} more`} - ) : null} - - )} - + {/* Search (fixed). */} + + {"⌕ "} + + {endTruncate(state.query || "search models…", Math.max(8, innerWidth - 2))} + + {state.searchMode ? : null} - + {/* Bounded model region: icon rail + fixed-height windowed list. */} + {searching ? ( + {listRows} + ) : ( + + + + + {listRows} + + + )} + {(hiddenBefore > 0 || hiddenAfter > 0) ? ( - ↑↓ pick · ↵ select · tab rail · [ ] providers · f fav · / search · esc close - - + {hiddenBefore > 0 ? `↑ ${hiddenBefore} ` : ""}{hiddenAfter > 0 ? `↓ ${hiddenAfter} more` : ""} + + ) : null} + + {/* Sticky settings footer. */} + + + {/* Key hints. */} + + + ); } diff --git a/apps/ade-cli/src/tuiClient/components/ModelPicker/modelPickerGeometry.test.ts b/apps/ade-cli/src/tuiClient/components/ModelPicker/modelPickerGeometry.test.ts new file mode 100644 index 000000000..4016153fd --- /dev/null +++ b/apps/ade-cli/src/tuiClient/components/ModelPicker/modelPickerGeometry.test.ts @@ -0,0 +1,298 @@ +import { describe, expect, it } from "vitest"; +import { + MODEL_LIST_ROWS, + RAIL_WIDTH, + RAIL_TO_LIST_GAP, + headerLineCount, + hasSubProviderSelector, + isSearching, + modelPickerGeometry, + rowWindow, + settingsChipWidth, +} from "./modelPickerGeometry"; +import type { ModelPickerEntry, ModelPickerState, ModelPickerProviderTab } from "./types"; + +// Derive the settings-row shape from the picker state itself so the test does +// not need a second specifier for ../../types (importing the same physical file +// via two paths trips a Vite resolution quirk). +type SetupPaneRow = ModelPickerState["settingsRows"][number]; + +function entry(overrides: Partial & { modelId: string }): ModelPickerEntry { + return { + runtimeModelId: overrides.modelId, + displayName: overrides.modelId, + family: "claude", + isFavorite: false, + isAvailable: true, + authStatus: "ready", + ...overrides, + }; +} + +function settingRow(kind: SetupPaneRow["kind"], label: string = kind): SetupPaneRow { + return { kind, label, value: "" }; +} + +function makeState(overrides: Partial): ModelPickerState { + return { + query: "", + searchMode: false, + showAll: false, + railEntries: [ + { kind: "favorites", label: "Favorites" }, + { kind: "recents", label: "Recents" }, + { kind: "provider", provider: "claude", label: "Anthropic", authStatus: "ready", signInHint: null }, + ], + railIndex: 2, + entries: [], + providerTabs: [], + providerTabIndex: 0, + focusedIndex: 0, + activeModelId: null, + activeProviderAuthStatus: "ready", + activeProviderSignInHint: null, + settingsRows: [], + footerFocus: null, + laneLabel: null, + ...overrides, + }; +} + +const PANE_LEFT = 100; +const PANE_TOP = 5; +const PANE_WIDTH = 38; +const ROWS = 60; + +function geo(state: ModelPickerState) { + return modelPickerGeometry({ paneLeft: PANE_LEFT, paneTop: PANE_TOP, paneWidth: PANE_WIDTH, state, rows: ROWS }); +} + +// Indexed access under noUncheckedIndexedAccess yields T | undefined; this +// asserts presence so the assertions below read cleanly. +function at(arr: T[], index: number): T { + const value = arr[index]; + if (value === undefined) throw new Error(`expected element at index ${index}`); + return value; +} + +describe("rowWindow", () => { + it("returns the whole list when it fits the capacity", () => { + expect(rowWindow(5, 0, MODEL_LIST_ROWS)).toEqual({ start: 0, end: 5 }); + }); + + it("centers the focused row once the list overflows", () => { + // 20 entries, capacity 9, focus 10 -> half=4 -> start=6, end=15. + expect(rowWindow(20, 10, MODEL_LIST_ROWS)).toEqual({ start: 6, end: 15 }); + }); + + it("clamps the window to the end of the list", () => { + // focus at the last index pins the window to the tail. + expect(rowWindow(20, 19, MODEL_LIST_ROWS)).toEqual({ start: 11, end: 20 }); + }); +}); + +describe("headerLineCount", () => { + it("counts only the models line by default", () => { + expect(headerLineCount(makeState({}))).toBe(1); + }); + + it("adds a line for the active model when it is in the list", () => { + const state = makeState({ + entries: [entry({ modelId: "anthropic/claude-opus-4-8" })], + activeModelId: "anthropic/claude-opus-4-8", + }); + expect(headerLineCount(state)).toBe(2); + }); + + it("adds a sign-in line when the provider is unavailable", () => { + const state = makeState({ + activeProviderAuthStatus: "unavailable", + activeProviderSignInHint: "/login claude", + }); + expect(headerLineCount(state)).toBe(2); + }); +}); + +describe("modelPickerGeometry — list rows", () => { + it("places each entry on its own single line below the header + search", () => { + const state = makeState({ + entries: [ + entry({ modelId: "a" }), + entry({ modelId: "b" }), + entry({ modelId: "c" }), + ], + focusedIndex: 0, + }); + const g = geo(state); + // header(1) -> +1 marginBottom -> search row -> +1 marginBottom -> region. + // no selector (single/zero provider tabs), so listTop == modelRegionTop. + const headerLines = 1; + const searchY = PANE_TOP + headerLines + 1; + const listTop = searchY + 2; + expect(g.search).toEqual({ x: PANE_LEFT, y: searchY, w: PANE_WIDTH, h: 1 }); + g.entries.forEach((e, index) => { + expect(e.rect.h).toBe(1); + expect(e.rect.y).toBe(listTop + index); + // not searching -> list sits to the right of the rail. + expect(e.rect.x).toBe(PANE_LEFT + RAIL_WIDTH + RAIL_TO_LIST_GAP); + }); + // y increments by exactly 1 per row (no phantom 2-line drift). + expect(at(g.entries, 1).rect.y - at(g.entries, 0).rect.y).toBe(1); + expect(at(g.entries, 2).rect.y - at(g.entries, 1).rect.y).toBe(1); + }); + + it("keeps single-line rows even for sub-provider / unavailable entries", () => { + const state = makeState({ + entries: [ + entry({ modelId: "x", subProvider: "anthropic via OpenCode", isAvailable: true }), + entry({ modelId: "y", isAvailable: false }), + ], + focusedIndex: 0, + }); + const g = geo(state); + expect(g.entries.every((e) => e.rect.h === 1)).toBe(true); + expect(at(g.entries, 1).rect.y - at(g.entries, 0).rect.y).toBe(1); + }); + + it("windows a long scrolled list using MODEL_LIST_ROWS, mapping screen rows to true indices", () => { + const entries: ModelPickerEntry[] = Array.from({ length: 25 }, (_v, i) => + entry({ modelId: `m${i}` }), + ); + const focusedIndex = 18; + const state = makeState({ entries, focusedIndex }); + const g = geo(state); + const window = rowWindow(entries.length, focusedIndex, MODEL_LIST_ROWS); + expect(g.window).toEqual(window); + // Exactly MODEL_LIST_ROWS visible rects. + expect(g.entries.length).toBe(MODEL_LIST_ROWS); + // First visible rect carries the windowed flat index, NOT 0. + expect(at(g.entries, 0).index).toBe(window.start); + expect(at(g.entries, 0).modelId).toBe(`m${window.start}`); + // The focused row's screen y equals listTop + (focusedIndex - window.start). + const headerLines = 1; + const listTop = PANE_TOP + headerLines + 1 + 2; + const focusRect = g.entries.find((e) => e.index === focusedIndex); + expect(focusRect).toBeDefined(); + expect(focusRect!.rect.y).toBe(listTop + (focusedIndex - window.start)); + }); +}); + +describe("modelPickerGeometry — rail vs search mode", () => { + it("registers a rail rect per entry in the leftmost RAIL_WIDTH cols when not searching", () => { + const state = makeState({ entries: [entry({ modelId: "a" })] }); + const g = geo(state); + expect(g.rail.length).toBe(state.railEntries.length); + g.rail.forEach((r, index) => { + expect(r.rect.x).toBe(PANE_LEFT); + expect(r.rect.w).toBe(RAIL_WIDTH); + expect(r.rect.h).toBe(1); + // rail rows start at the model region top: + // header(1) + marginBottom(1) + search(1) + marginBottom(1) = +4. + expect(r.rect.y).toBe(PANE_TOP + 1 + 3 + index); + expect(r.id).toBe(`right:model-picker:rail:${index}`); + }); + }); + + it("hides the rail and uses the full body width for the list while searching", () => { + const state = makeState({ + query: "opus", + searchMode: true, + entries: [entry({ modelId: "a" }), entry({ modelId: "b" })], + }); + expect(isSearching(state)).toBe(true); + const g = geo(state); + expect(g.rail).toEqual([]); + g.entries.forEach((e) => { + expect(e.rect.x).toBe(PANE_LEFT); + expect(e.rect.w).toBe(PANE_WIDTH); + }); + }); +}); + +describe("modelPickerGeometry — sub-provider selector", () => { + it("does not reserve a selector row when there is at most one provider tab", () => { + const oneTab: ModelPickerProviderTab[] = [{ key: "k", label: "L" }]; + const state = makeState({ providerTabs: oneTab, entries: [entry({ modelId: "a" })] }); + expect(hasSubProviderSelector(state)).toBe(false); + const g = geo(state); + // header(1)+mb(1)+search(1)+mb(1) = +4 region top, no selector. + const listTop = PANE_TOP + 1 + 3; + expect(at(g.entries, 0).rect.y).toBe(listTop); + }); + + it("reserves the selector row (2 lines) when there is more than one provider tab", () => { + const tabs: ModelPickerProviderTab[] = [ + { key: "a", label: "A" }, + { key: "b", label: "B" }, + ]; + const state = makeState({ providerTabs: tabs, entries: [entry({ modelId: "a" })] }); + expect(hasSubProviderSelector(state)).toBe(true); + const g = geo(state); + const regionTop = PANE_TOP + 1 + 1 + 1 + 1; // header+mb+search+mb + const listTopWithSelector = regionTop + 2; + expect(at(g.entries, 0).rect.y).toBe(listTopWithSelector); + }); +}); + +describe("modelPickerGeometry — settings footer + apply", () => { + it("places the footer below the fixed list block and registers one rect per setting chip", () => { + const state = makeState({ + entries: [entry({ modelId: "a" })], + settingsRows: [settingRow("reasoning"), settingRow("permission")], + }); + const g = geo(state); + const listTop = PANE_TOP + 1 + 3; // header+mb+search+mb = +4, no selector. + // footer divider: listTop + MODEL_LIST_ROWS + (no more-line) + marginTop(1). + expect(g.footerTop).toBe(listTop + MODEL_LIST_ROWS + 1); + expect(g.settings.length).toBe(2); + g.settings.forEach((s) => { + // divider at footerTop, blank (chips Box marginTop) at footerTop+1, + // chips painted at footerTop+2. + expect(s.rect.y).toBe(g.footerTop + 2); + expect(s.rect.h).toBe(1); + }); + // provider/model rows are excluded from the footer chips. + }); + + it("excludes provider/model/apply rows from the chip list and emits a dedicated apply rect", () => { + const state = makeState({ + entries: [entry({ modelId: "a" })], + settingsRows: [ + settingRow("provider"), + settingRow("model"), + settingRow("reasoning"), + settingRow("apply", "Apply"), + ], + }); + const g = geo(state); + // Only the reasoning chip is a footer setting. + expect(g.settings.map((s) => s.id)).toEqual(["right:model-picker:setting:reasoning"]); + expect(g.apply).not.toBeNull(); + // chips at footerTop+2, blank (apply Box marginTop) at footerTop+3, + // [ Apply ] painted at footerTop+4 (chipsY + 2 when chips exist). + expect(g.apply!.y).toBe(g.footerTop + 2 + 2); + expect(g.apply!.h).toBe(1); + }); + + it("places apply directly below the divider when there are no setting chips", () => { + const state = makeState({ + entries: [entry({ modelId: "a" })], + settingsRows: [settingRow("apply", "Apply")], + }); + const g = geo(state); + expect(g.settings).toEqual([]); + expect(g.apply!.y).toBe(g.footerTop + 2); + }); + + it("shifts the footer down by one when a scroll indicator row is shown", () => { + const entries: ModelPickerEntry[] = Array.from({ length: 25 }, (_v, i) => + entry({ modelId: `m${i}` }), + ); + const withMore = makeState({ entries, focusedIndex: 18, settingsRows: [settingRow("reasoning")] }); + const noScroll = makeState({ entries: [entry({ modelId: "a" })], settingsRows: [settingRow("reasoning")] }); + const gMore = geo(withMore); + const gNone = geo(noScroll); + // The scrolled case adds the "↑ n / ↓ n more" line, pushing the footer +1. + expect(gMore.footerTop).toBe(gNone.footerTop + 1); + }); +}); diff --git a/apps/ade-cli/src/tuiClient/components/ModelPicker/modelPickerGeometry.ts b/apps/ade-cli/src/tuiClient/components/ModelPicker/modelPickerGeometry.ts new file mode 100644 index 000000000..87b6f5f3d --- /dev/null +++ b/apps/ade-cli/src/tuiClient/components/ModelPicker/modelPickerGeometry.ts @@ -0,0 +1,255 @@ +import type { ModelPickerState } from "./types"; + +/** + * Screen rectangle in 1-based terminal cells. Structurally identical to + * hitTestRegistry's HitRect; declared locally so this pure geometry module + * stays free of any React/ink import (keeps it trivially unit-testable). + */ +export type HitRect = { x: number; y: number; w: number; h: number }; + +// ── Single source of truth for model-picker layout geometry ───────────────── +// +// Both the RENDER (ModelPickerPane.tsx) and the CLICK hit-test (app.tsx) must +// agree on exactly where each row lands on screen. Previously the hit-test +// hand-rolled its own offsets that drifted from the render, so clicks selected +// the wrong row (and worse once the list scrolled). This module is the ONE +// place those numbers live: ModelPickerPane imports the constants + rowWindow, +// and the app.tsx hit-test useEffect calls `modelPickerGeometry()` to derive +// rects from the same math. + +/** Width (cols) of the vertical icon rail on the left of the model region. */ +export const RAIL_WIDTH = 4; + +/** Fixed number of visible model rows; the list windows/scrolls inside this. */ +export const MODEL_LIST_ROWS = 9; + +/** + * Columns between the rail and the model name, inside the bordered list box: + * left border (1) + paddingLeft (1). The name column therefore starts at + * RAIL_WIDTH + RAIL_TO_LIST_GAP into the pane body. + */ +export const RAIL_TO_LIST_GAP = 2; + +/** + * Window the fixed-height model list around the focused row. Mirrors the + * desktop picker's centering behaviour: the focused row stays roughly centered + * until the list start/end is reached. + * + * Shared verbatim with the render so the visible slice and the clickable rects + * are computed identically. + */ +export function rowWindow( + 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); + const end = Math.min(rowCount, start + capacity); + start = Math.max(0, end - capacity); + return { start, end }; +} + +/** Number of fixed header lines above the search row (variable by state). */ +export function headerLineCount(state: ModelPickerState): number { + let lines = 1; // "N models …" is always present. + if (state.activeModelId && state.entries.some((e) => e.modelId === state.activeModelId)) { + lines += 1; // "● now …" line. + } + if (state.activeProviderAuthStatus === "unavailable" && state.activeProviderSignInHint) { + lines += 1; // "Sign in: …" line. + } + return lines; +} + +/** True when the picker is in cross-provider search mode (query non-empty). */ +export function isSearching(state: ModelPickerState): boolean { + return state.query.trim().length > 0; +} + +/** Whether the sub-provider selector row is rendered (only with >1 tab). */ +export function hasSubProviderSelector(state: ModelPickerState): boolean { + return !isSearching(state) && state.providerTabs.length > 1; +} + +// Settings chip cell width — mirrors SettingsFooter's render EXACTLY so the +// hit-rects and the painted chips share one source of truth: +// focus/rail indicator (1) + "icon " (icon 1 + space 1 = 2) +// + lowercased label (≤12) + trailing space (1) + value (≤14) + marginRight (2) +const SETTING_LABEL_MAX = 12; +const SETTING_VALUE_MAX = 14; +export function settingsChipWidth(label: string, value: string): number { + const labelLen = Math.min(label.toLowerCase().length, SETTING_LABEL_MAX); + const valueLen = Math.min(value.length, SETTING_VALUE_MAX); + return 1 + 2 + labelLen + 1 + valueLen + 2; +} + +export type GeometryRect = { id: string; rect: HitRect }; + +export type ModelPickerGeometry = { + /** Window applied to state.entries (start inclusive, end exclusive). */ + window: { start: number; end: number }; + /** Search input row. */ + search: HitRect; + /** Show-all toggle row (kept for parity with existing target). */ + showAll: HitRect; + /** One rect per rail entry (empty while searching — rail is hidden). */ + rail: GeometryRect[]; + /** One rect per visible (windowed) model entry. */ + entries: Array<{ id: string; index: number; modelId: string; rect: HitRect }>; + /** Star toggle hotspot per visible model entry (left edge of the row). */ + favorites: Array<{ modelId: string; rect: HitRect }>; + /** One rect per visible setting chip (kind keyed). */ + settings: GeometryRect[]; + /** Apply button rect, when an apply row is present. */ + apply: HitRect | null; + /** Top screen row of the settings footer (divider sits here). */ + footerTop: number; +}; + +export type GeometryInput = { + /** 1-based screen column of the pane body left edge (rightStartColumn). */ + paneLeft: number; + /** 1-based screen row of the pane body first line (rightBodyTop). */ + paneTop: number; + /** Pane body width in columns (rightPaneWidth). */ + paneWidth: number; + /** Resolved picker state (from buildModelPickerLayout). */ + state: ModelPickerState; + /** Terminal row count, to clamp the footer into view. */ + rows: number; +}; + +/** + * Compute exact screen rects for every clickable region of the model picker, + * derived from the SAME constants + windowing the render uses. All rects are + * 1-based terminal cells, matching hitTestRegistry's contains() convention + * ([x, x+w) × [y, y+h)). + */ +export function modelPickerGeometry(input: GeometryInput): ModelPickerGeometry { + const { paneLeft, paneTop, paneWidth, state, rows } = input; + const searching = isSearching(state); + + // Vertical layout (each value is a screen row offset from paneTop): + // header (headerLines) marginBottom 1 + // search (1) marginBottom 1 + // [model region] + const headerLines = headerLineCount(state); + const searchY = paneTop + headerLines + 1; // +1 for header marginBottom. + const modelRegionTop = searchY + 1 + 1; // search row (1) + its marginBottom (1). + + // Selector occupies 1 row + marginBottom (1) when present, before the list. + const selectorLines = hasSubProviderSelector(state) ? 2 : 0; + const listTop = modelRegionTop + selectorLines; + + // List x-origin: full body while searching (no rail); else rail + gap. + const listLeft = searching ? paneLeft : paneLeft + RAIL_WIDTH + RAIL_TO_LIST_GAP; + const listWidth = searching + ? paneWidth + : Math.max(8, paneWidth - RAIL_WIDTH - RAIL_TO_LIST_GAP); + + const window = rowWindow(state.entries.length, state.focusedIndex, MODEL_LIST_ROWS); + + // Search row spans the full body width. + const search: HitRect = { x: paneLeft, y: searchY, w: paneWidth, h: 1 }; + + // Show-all toggle. Render does not draw a dedicated row for this anymore, but + // the keyboard/legacy target is harmless; pin it to the search row so it can + // never overlap a model row (zIndex keeps search on top where they coincide). + const showAll: HitRect = { x: paneLeft, y: searchY, w: paneWidth, h: 1 }; + + // Rail: leftmost RAIL_WIDTH cols, one 1-line row per rail entry, starting at + // the model region top. Hidden entirely while searching. + const rail: GeometryRect[] = []; + if (!searching) { + state.railEntries.forEach((_, index) => { + rail.push({ + id: `right:model-picker:rail:${index}`, + rect: { x: paneLeft, y: modelRegionTop + index, w: RAIL_WIDTH, h: 1 }, + }); + }); + } + + // Model entries: each is EXACTLY 1 line (matches ModelListRow), windowed. + const entries: ModelPickerGeometry["entries"] = []; + const favorites: ModelPickerGeometry["favorites"] = []; + state.entries.slice(window.start, window.end).forEach((entry, sliceIndex) => { + const index = window.start + sliceIndex; + const y = listTop + sliceIndex; + entries.push({ + id: `right:model-picker:entry:${entry.modelId}`, + index, + modelId: entry.modelId, + rect: { x: listLeft, y, w: listWidth, h: 1 }, + }); + // Star hotspot is the first glyph cell(s) of the row. + favorites.push({ + modelId: entry.modelId, + rect: { x: listLeft, y, w: 2, h: 1 }, + }); + }); + + // Footer: after the fixed list block (always MODEL_LIST_ROWS tall) plus the + // optional "↑ n / ↓ n more" line, a marginTop (1) precedes the divider. + const hiddenBefore = window.start; + const hiddenAfter = state.entries.length - window.end; + const moreLine = hiddenBefore > 0 || hiddenAfter > 0 ? 1 : 0; + // footerTop is the divider row; the marginTop pushes it down by 1. + let footerTop = listTop + MODEL_LIST_ROWS + moreLine + 1; + // Keep the footer on-screen if the pane is short. + footerTop = Math.min(Math.max(footerTop, listTop + 1), Math.max(1, rows - 1)); + + const visibleRows = state.settingsRows.filter( + (row) => row.kind !== "provider" && row.kind !== "model", + ); + const settingRows = visibleRows.filter((row) => row.kind !== "apply"); + const applyRow = visibleRows.find((row) => row.kind === "apply") ?? null; + + // The chips Box has its OWN marginTop (1) below the divider (the divider sits + // at footerTop), so chips paint at footerTop+2. (footerTop already accounts + // for the footer Box's outer marginTop.) + const chipsY = footerTop + 2; + // SIMULATE SettingsFooter's `flexWrap="wrap"` row: lay each natural-width chip + // left-to-right from paneLeft, wrapping to the next row when the next chip + // would overrun the pane. One rect per chip, keyed by kind, with the rendered + // cell width — so the painted chips and the click rects share one source. + const settings: GeometryRect[] = []; + const paneRight = paneLeft + paneWidth; + let chipX = paneLeft; + let chipRowY = chipsY; + for (const row of settingRows) { + const w = settingsChipWidth(row.label, row.value); + // Wrap before placing (unless this chip is the first on its row). + if (chipX > paneLeft && chipX + w > paneRight) { + chipRowY += 1; + chipX = paneLeft; + } + settings.push({ + id: `right:model-picker:setting:${row.kind}`, + rect: { x: chipX, y: chipRowY, w, h: 1 }, + }); + chipX += w; + } + + // Apply button: its own marginTop (1) below the LAST (possibly wrapped) chip + // row — i.e. one blank row below it — or below the divider when there are no + // chips. Rendered as "[ Apply ]". + let apply: HitRect | null = null; + if (applyRow) { + const applyY = settingRows.length ? chipRowY + 2 : footerTop + 2; + apply = { x: paneLeft, y: applyY, w: Math.max(8, Math.min(paneWidth, 24)), h: 1 }; + } + + return { + window, + search, + showAll, + rail, + entries, + favorites, + settings, + apply, + footerTop, + }; +} diff --git a/apps/ade-cli/src/tuiClient/components/ModelPicker/modelPickerLayout.ts b/apps/ade-cli/src/tuiClient/components/ModelPicker/modelPickerLayout.ts index 6e2c84fc3..2bf5b62e6 100644 --- a/apps/ade-cli/src/tuiClient/components/ModelPicker/modelPickerLayout.ts +++ b/apps/ade-cli/src/tuiClient/components/ModelPicker/modelPickerLayout.ts @@ -1,6 +1,7 @@ import { scoreModelPickerSearch } from "../../../../../desktop/src/renderer/components/shared/ModelPicker/modelPickerSearch"; import { sortModelItems } from "../../../../../desktop/src/renderer/components/shared/ModelPicker/modelOrdering"; import type { AgentChatModelCatalog, AgentChatModelInfo } from "../../../../../desktop/src/shared/types/chat"; +import type { AiSettingsStatus, AiRuntimeConnectionStatus } from "../../../../../desktop/src/shared/types/config"; import { getModelById, resolveProviderGroupForModel, @@ -12,7 +13,9 @@ import type { ModelPickerEntry, ModelPickerRailEntry, ModelPickerState, + ModelPickerAuthStatus, } from "./types"; +import type { SetupPaneRow, SetupPaneRowKind } from "../../types"; const PROVIDER_LABELS: Record = { codex: "OpenAI", @@ -28,6 +31,71 @@ function providerLabel(provider: AdeCodeProvider): string { return PROVIDER_LABELS[provider] ?? provider; } +function providerSignInHint(provider: AdeCodeProvider): string { + return `/login ${provider}`; +} + +function providerModelsCount(status: AiSettingsStatus | null | undefined, provider: AdeCodeProvider): number { + if (!status) return 0; + if (provider === "claude" || provider === "codex" || provider === "cursor" || provider === "droid") { + return status.models?.[provider]?.length ?? 0; + } + const matchingRuntime = Object.values(status.runtimeConnections ?? {}).filter((connection) => { + const key = String(connection.provider ?? "").toLowerCase(); + return key === provider || key.includes(provider); + }); + const runtimeLoaded = matchingRuntime.reduce((total, connection) => total + (connection.loadedModelIds?.length ?? 0), 0); + const openCodeLoaded = (status.opencodeProviders ?? []) + .filter((entry) => entry.id.toLowerCase().includes(provider) || entry.name.toLowerCase().includes(provider)) + .reduce((total, entry) => total + entry.modelCount, 0); + return runtimeLoaded + openCodeLoaded; +} + +function runtimeReady(connection: AiRuntimeConnectionStatus | null | undefined): boolean { + return Boolean( + connection?.authAvailable + || connection?.runtimeAvailable + || (connection?.loadedModelIds?.length ?? 0) > 0, + ); +} + +export function modelPickerProviderAuthStatus( + status: AiSettingsStatus | null | undefined, + provider: AdeCodeProvider, +): ModelPickerAuthStatus { + if (!status) return "unknown"; + if (provider === "claude") { + const connection = status.providerConnections?.claude; + if (connection?.authAvailable || connection?.runtimeAvailable || status.availableProviders?.claude?.auth?.ready || providerModelsCount(status, provider) > 0) { + return "ready"; + } + return "unavailable"; + } + if (provider === "codex" || provider === "cursor" || provider === "droid") { + const connection = status.providerConnections?.[provider]; + if (connection?.authAvailable || connection?.runtimeAvailable || status.availableProviders?.[provider] === true || providerModelsCount(status, provider) > 0) { + return "ready"; + } + return "unavailable"; + } + if (provider === "opencode") { + if ((status.opencodeProviders ?? []).some((entry) => entry.connected) || status.opencodeBinaryInstalled === true) return "ready"; + if (status.opencodeBinaryInstalled === false || status.opencodeInventoryError) return "unavailable"; + return "unknown"; + } + const matchingRuntime = Object.values(status.runtimeConnections ?? {}).filter((connection) => { + const key = String(connection.provider ?? "").toLowerCase(); + return key === provider || key.includes(provider); + }); + if (matchingRuntime.some(runtimeReady)) return "ready"; + const matchingOpenCodeProvider = (status.opencodeProviders ?? []).find((entry) => ( + entry.id.toLowerCase().includes(provider) || entry.name.toLowerCase().includes(provider) + )); + if (matchingOpenCodeProvider?.connected) return "ready"; + if (matchingOpenCodeProvider || matchingRuntime.length) return "unavailable"; + return "unknown"; +} + function normalizeProvider(value: ProviderFamily | string | undefined): AdeCodeProvider { // resolveProviderGroupForModel already returns ModelProviderGroup values // (claude/codex/opencode/cursor/droid). Map ProviderFamily aliases as well so @@ -58,6 +126,8 @@ function descriptorFor(modelInfo: AgentChatModelInfo): ModelDescriptor | undefin function entriesFromCatalog( catalog: AgentChatModelCatalog, favoritesSet: Set, + aiStatus?: AiSettingsStatus | null, + activeReasoningEffort?: string | null, ): ModelPickerEntry[] { const entries: ModelPickerEntry[] = []; const seen = new Set(); @@ -67,15 +137,18 @@ function entriesFromCatalog( for (const model of subsection.models ?? []) { if (seen.has(model.id)) continue; seen.add(model.id); + const family = providerFromCatalogGroup(String(model.groupKey || group.key), model.family); entries.push({ modelId: model.id, runtimeModelId: model.runtimeModelId || model.id, displayName: model.displayName, - family: providerFromCatalogGroup(String(model.groupKey || group.key), model.family), + family, subProvider: model.providerName || provider.displayName || subsection.label || undefined, subProviderKey: model.providerId || provider.key || subsection.key || undefined, isFavorite: favoritesSet.has(model.id), isAvailable: model.isAvailable, + authStatus: modelPickerProviderAuthStatus(aiStatus, family), + reasoningLabel: activeReasoningEffort ? `think ${activeReasoningEffort}` : null, ...(model.serviceTiers?.length ? { serviceTiers: [...model.serviceTiers] } : {}), ...(model.cursorAvailability ? { cursorAvailability: { ...model.cursorAvailability } } : {}), }); @@ -89,6 +162,8 @@ function entriesFromCatalog( function entryFromModelInfo( modelInfo: AgentChatModelInfo, favoritesSet: Set, + aiStatus?: AiSettingsStatus | null, + activeReasoningEffort?: string | null, ): ModelPickerEntry { const modelId = modelInfo.modelId ?? modelInfo.id; const descriptor = descriptorFor(modelInfo); @@ -107,6 +182,8 @@ function entryFromModelInfo( : {}), isFavorite: favoritesSet.has(modelId), isAvailable: true, + authStatus: modelPickerProviderAuthStatus(aiStatus, provider), + reasoningLabel: activeReasoningEffort ? `think ${activeReasoningEffort}` : null, ...(modelInfo.serviceTiers?.length ? { serviceTiers: [...modelInfo.serviceTiers] } : {}), ...(cursorAvailability ? { cursorAvailability: { ...cursorAvailability } } : {}), }; @@ -118,6 +195,12 @@ export type BuildLayoutInput = { favorites: string[]; recents: string[]; activeModelId: string | null; + activeReasoningEffort?: string | null; + aiStatus?: AiSettingsStatus | null; + showAll?: boolean; + settingsRows?: SetupPaneRow[]; + footerFocus?: SetupPaneRowKind | null; + laneLabel?: string | null; query: string; selection: { kind: "favorites" } | { kind: "recents" } | { kind: "provider"; provider: AdeCodeProvider }; providerTabKey?: string | null; @@ -128,8 +211,9 @@ export type BuildLayoutInput = { export function buildModelPickerLayout(input: BuildLayoutInput): ModelPickerState { const favoritesSet = new Set(input.favorites); const allEntries = input.catalog - ? entriesFromCatalog(input.catalog, favoritesSet) - : input.models.map((m) => entryFromModelInfo(m, favoritesSet)); + ? entriesFromCatalog(input.catalog, favoritesSet, input.aiStatus, input.activeReasoningEffort) + : input.models.map((m) => entryFromModelInfo(m, favoritesSet, input.aiStatus, input.activeReasoningEffort)); + const visibleEntries = input.showAll ? allEntries : allEntries.filter((entry) => entry.isAvailable); // Providers actually present in the registry-filtered model list. const providersPresent = Array.from( @@ -142,6 +226,8 @@ export function buildModelPickerLayout(input: BuildLayoutInput): ModelPickerStat kind: "provider" as const, provider, label: providerLabel(provider), + authStatus: modelPickerProviderAuthStatus(input.aiStatus, provider), + signInHint: providerSignInHint(provider), })), ]; @@ -165,18 +251,18 @@ export function buildModelPickerLayout(input: BuildLayoutInput): ModelPickerStat let pool: ModelPickerEntry[]; if (searchActive) { - pool = allEntries; + pool = visibleEntries; } else if (normalizedSelection.kind === "favorites") { - pool = allEntries.filter((entry) => favoritesSet.has(entry.modelId)); + pool = visibleEntries.filter((entry) => favoritesSet.has(entry.modelId)); } else if (normalizedSelection.kind === "recents") { const recentSet = new Set(input.recents); const order = new Map(input.recents.map((id, i) => [id, i] as const)); - pool = allEntries + pool = visibleEntries .filter((entry) => recentSet.has(entry.modelId)) .sort((a, b) => (order.get(a.modelId) ?? 0) - (order.get(b.modelId) ?? 0)); } else { const target = normalizedSelection.provider; - pool = allEntries.filter((entry) => entry.family === target); + pool = visibleEntries.filter((entry) => entry.family === target); } const providerTabs = (() => { @@ -214,6 +300,9 @@ export function buildModelPickerLayout(input: BuildLayoutInput): ModelPickerStat if (searchActive) { const scored: Array<{ entry: ModelPickerEntry; score: number }> = []; for (const candidate of pool) { + // Include the model's short id + aliases so users can type "opus"/"sonnet" + // (matching the desktop picker), not just the full display name. + const descriptor = getModelById(candidate.modelId); const score = scoreModelPickerSearch( { name: candidate.displayName, @@ -224,6 +313,8 @@ export function buildModelPickerLayout(input: BuildLayoutInput): ModelPickerStat : candidate.family) as ProviderFamily, providerDisplayName: providerLabel(candidate.family), isFavorite: candidate.isFavorite, + ...(descriptor?.shortId ? { shortName: descriptor.shortId } : {}), + ...(descriptor?.aliases?.length ? { aliases: descriptor.aliases } : {}), ...(candidate.subProvider ? { subProvider: candidate.subProvider } : {}), }, trimmedQuery, @@ -258,17 +349,29 @@ export function buildModelPickerLayout(input: BuildLayoutInput): ModelPickerStat const focusedIndex = entries.length === 0 ? 0 : Math.max(0, Math.min(input.focusedIndex, entries.length - 1)); + const activeRailEntry = railEntries[railIndex] ?? null; + const activeProviderRailEntry = activeRailEntry?.kind === "provider" ? activeRailEntry : null; return { query: input.query, searchMode: input.searchMode, + showAll: input.showAll === true, railEntries, railIndex, - entries, - providerTabs: providerTabs.map((tab) => ({ key: tab.key, label: tab.label })), - providerTabIndex: Math.max(0, providerTabs.findIndex((tab) => tab.key === activeProviderTabKey)), - focusedIndex, + entries, + providerTabs: providerTabs.map((tab) => ({ key: tab.key, label: tab.label })), + providerTabIndex: Math.max(0, providerTabs.findIndex((tab) => tab.key === activeProviderTabKey)), + focusedIndex, activeModelId: input.activeModelId, + activeProviderAuthStatus: activeProviderRailEntry + ? activeProviderRailEntry.authStatus + : "unknown", + activeProviderSignInHint: activeProviderRailEntry + ? activeProviderRailEntry.signInHint + : null, + settingsRows: input.settingsRows ?? [], + footerFocus: input.footerFocus ?? null, + laneLabel: input.laneLabel ?? null, }; } diff --git a/apps/ade-cli/src/tuiClient/components/ModelPicker/types.ts b/apps/ade-cli/src/tuiClient/components/ModelPicker/types.ts index e780e7616..e489f6ff9 100644 --- a/apps/ade-cli/src/tuiClient/components/ModelPicker/types.ts +++ b/apps/ade-cli/src/tuiClient/components/ModelPicker/types.ts @@ -1,12 +1,14 @@ import type { AdeCodeProvider } from "../../types"; +import type { SetupPaneRow, SetupPaneRowKind } from "../../types"; import type { CursorModelAvailability } from "../../../../../desktop/src/shared/modelRegistry"; export type ModelPickerRailKind = "favorites" | "recents" | "provider"; +export type ModelPickerAuthStatus = "ready" | "unavailable" | "unknown"; export type ModelPickerRailEntry = | { kind: "favorites"; label: string } | { kind: "recents"; label: string } - | { kind: "provider"; provider: AdeCodeProvider; label: string }; + | { kind: "provider"; provider: AdeCodeProvider; label: string; authStatus: ModelPickerAuthStatus; signInHint: string | null }; export type ModelPickerEntry = { /** Canonical ADE model id (matches modelRegistry.id). Empty string for placeholder. */ @@ -20,6 +22,8 @@ export type ModelPickerEntry = { subProviderKey?: string; isFavorite: boolean; isAvailable: boolean; + authStatus: ModelPickerAuthStatus; + reasoningLabel?: string | null; serviceTiers?: string[]; cursorAvailability?: CursorModelAvailability; }; @@ -32,6 +36,7 @@ export type ModelPickerProviderTab = { export type ModelPickerState = { query: string; searchMode: boolean; + showAll: boolean; railEntries: ModelPickerRailEntry[]; railIndex: number; entries: ModelPickerEntry[]; @@ -39,4 +44,9 @@ export type ModelPickerState = { providerTabIndex: number; focusedIndex: number; activeModelId: string | null; + activeProviderAuthStatus: ModelPickerAuthStatus; + activeProviderSignInHint: string | null; + settingsRows: SetupPaneRow[]; + footerFocus: SetupPaneRowKind | null; + laneLabel?: string | null; }; diff --git a/apps/ade-cli/src/tuiClient/components/RightPane.tsx b/apps/ade-cli/src/tuiClient/components/RightPane.tsx index 3ed4d1e2c..219964db7 100644 --- a/apps/ade-cli/src/tuiClient/components/RightPane.tsx +++ b/apps/ade-cli/src/tuiClient/components/RightPane.tsx @@ -12,7 +12,45 @@ import { theme } from "../theme"; import { buildSubagentPaneRows, type SubagentPaneRow } from "../subagentPane"; import { ModelPickerPane } from "./ModelPicker/ModelPickerPane"; import { buildModelPickerLayout } from "./ModelPicker/modelPickerLayout"; +import { TokenBar } from "./FooterControls"; +import { UsagePane } from "./UsagePane"; import type { AgentChatModelCatalog, AgentChatModelInfo } from "../../../../desktop/src/shared/types/chat"; +import type { AiSettingsStatus } from "../../../../desktop/src/shared/types/config"; +import { useHoveredHitId } from "../hitTestRegistry"; +import { diffLineKind, type DiffLineKind } from "../format"; +import type { HelpRow } from "../helpIndex"; +import { useShimmerTick } from "../spinTick"; +import { + FEEDBACK_TYPES, + feedbackFormCanSubmit, + serializeContextFooter, + type FeedbackFormState, + type FeedbackType, +} from "../feedbackForm"; + +// Cap per-file diff body so a pathological 50k-line file can't make the right +// pane build a giant row array on every scroll. The window only shows +// DETAILS_BODY_MAX_LINES at a time, but the flattened array is built in full — +// this keeps that bounded while still covering any realistic review diff. +const DIFF_FILE_BODY_MAX = 600; + +// Map a diff line kind to a theme token. Green/red here is diff *content* +// semantics (the universal add/remove convention Claude Code uses), not idle +// chrome — so it's exempt from the "no green chrome" rule. +function diffLineTone(kind: DiffLineKind): { color: string; dim: boolean; bold: boolean } { + switch (kind) { + case "add": + return { color: theme.color.done, dim: false, bold: false }; + case "del": + return { color: theme.color.error, dim: false, bold: false }; + case "hunk": + return { color: theme.color.violet, dim: false, bold: true }; + case "meta": + return { color: theme.color.t4, dim: true, bold: false }; + default: + return { color: theme.color.t3, dim: true, bold: false }; + } +} // --------------------------------------------------------------------------- // Right-pane width / focus chrome @@ -20,7 +58,7 @@ import type { AgentChatModelCatalog, AgentChatModelInfo } from "../../../../desk const DEFAULT_PANE_WIDTH = 38; const LANE_FILE_PREVIEW_ROWS = 5; -const DETAILS_BODY_MAX_LINES = 26; +export const DETAILS_BODY_MAX_LINES = 26; // --------------------------------------------------------------------------- // Actions for the lane-details pane (5 rows · wireframe) @@ -287,6 +325,13 @@ function endTruncate(value: string, max: number): string { return `${value.slice(0, Math.max(0, max - 1))}…`; } +function compactNumber(value: number): string { + if (!Number.isFinite(value)) return "0"; + if (value >= 1_000_000) return `${(value / 1_000_000).toFixed(1)}M`; + if (value >= 1_000) return `${(value / 1_000).toFixed(1)}k`; + return String(Math.round(value)); +} + function compactPath(value: string, max: number): string { if (value.length <= max) return value; const parts = value.split("/").filter(Boolean); @@ -356,6 +401,7 @@ function LaneDetailsPane({ content: Extract; width: number; }) { + const hoveredId = useHoveredHitId(); const lane = content.lane; const worktreeMissing = content.worktreeAvailable === false; const git = content.git; @@ -438,7 +484,7 @@ function LaneDetailsPane({ detail={action.detail} glyph={action.glyph} glyphColorKind={action.glyphColorKind} - selected={idx === content.selectedActionIndex} + selected={idx === content.selectedActionIndex || hoveredId === `right:lane-action:${idx}`} width={contentWidth} /> ))} @@ -539,10 +585,16 @@ function rosterRowDetail(snapshot: SubagentSnapshot): string | null { return unique.length ? unique.join(" · ") : null; } -function ChatInfoSectionHead({ title, hint, color }: { title: string; hint?: string; color: string }) { +function ChatInfoSectionHead({ title, hint, color, width }: { title: string; hint?: string; color: string; width?: number }) { + // Section header with a hairline rule that fills the gap to the hint, so each + // block reads as a titled card divider rather than a bare label. + const inner = Math.max(12, (width ?? 40) - 4); + const used = title.length + 2 + (hint ? hint.length + 1 : 0); + const ruleLen = Math.max(1, inner - used); return ( - + {title} + {` ${"─".repeat(ruleLen)}${hint ? " " : ""}`} {hint ? {hint} : null} ); @@ -564,13 +616,21 @@ function ChatInfoHeader({ info, width }: { info: ChatInfoSnapshot; width: number {endTruncate(info.laneLabel, Math.max(6, inner - 7))} ) : null} - + - {info.streaming ? "●" : "○"} {info.streaming ? "active" : "idle"} + {info.streaming ? "● live" : "○ idle"} - {info.contextPercent != null ? {` · ${info.contextPercent}% ctx`} : null} - {info.tokenSummary ? {` · ${info.tokenSummary}`} : null} + {info.contextPercent != null ? ( + <> + {" "} + + {` ${info.contextPercent}%`} + + ) : null} + {info.tokenSummary ? ( + {endTruncate(info.tokenSummary, inner)} + ) : null} ); } @@ -581,14 +641,14 @@ function ChatInfoPlanBlock({ info, brandColor, width }: { info: ChatInfoSnapshot if (!plan || !plan.steps.length) { return ( - + No plan yet. ); } return ( - + {plan.steps.slice(0, 6).map((step, index) => ( {planStepGlyph(step.status)} {endTruncate(step.text, inner - 2)} @@ -604,7 +664,7 @@ function ChatInfoGoalBlock({ info, brandColor, width }: { info: ChatInfoSnapshot const inner = Math.max(10, width - 4); return ( - + {goal.objective ? ( {endTruncate(goal.objective, inner)} ) : null} @@ -665,7 +725,7 @@ function ChatInfoRoster({ return ( - + {/* Main row — always present, tagged with the current middle-pane state */} @@ -766,35 +826,102 @@ function ChatInfoPane({ } // --------------------------------------------------------------------------- -// Other content modes (status, list, details, diff, form, new-chat-setup, +// Other content modes (status, list, details, diff, form, // help, empty) — kept compact, refreshed to use theme tokens. // --------------------------------------------------------------------------- -function HelpPane() { +type HelpPaneContent = Extract; + +function HelpPane({ content, width }: { content: HelpPaneContent; width: number }) { + const groups = content.groupedRows ?? []; + const query = content.filterQuery ?? ""; + const selectedIndex = content.selectedIndex ?? 0; + const inner = Math.max(12, width - 4); + + // Flatten to navigation order so the single selected index maps to one row, + // interleaving heading markers so the renderer can print each category title. + type FlatItem = + | { kind: "heading"; category: string } + | { kind: "row"; row: HelpRow; flatIndex: number }; + const flat: FlatItem[] = []; + let flatIndex = 0; + for (const group of groups) { + flat.push({ kind: "heading", category: group.category }); + for (const row of group.rows) { + flat.push({ kind: "row", row, flatIndex }); + flatIndex += 1; + } + } + const totalRows = flatIndex; + + // Scroll the flat list to keep the selection visible. Reserve a few lines for + // the filter line, spacer, footer hint, and overflow markers. + const bodyMax = Math.max(4, Math.min(DETAILS_BODY_MAX_LINES, width > 0 ? 24 : 4)); + const selectedFlatPos = flat.findIndex((item) => item.kind === "row" && item.flatIndex === selectedIndex); + let windowStart = 0; + if (flat.length > bodyMax) { + windowStart = selectedFlatPos < 0 ? 0 : Math.max(0, Math.min(selectedFlatPos - 2, flat.length - bodyMax)); + } + const windowEnd = Math.min(flat.length, windowStart + bodyMax); + const visible = flat.slice(windowStart, windowEnd); + const hasAbove = windowStart > 0; + const hasBelow = windowEnd < flat.length; + return ( - ↓ from prompt enters the model row; ↑ returns - in the row: ← → moves between cells, ↓ cycles values - /model opens the model picker · /info opens chat info - ctrl-o opens or focuses lanes and chats - ctrl-g starts split chat add-mode; enter adds, esc cancels - in split chat: tab focuses tiles, ctrl-w closes the focused tile - 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 + + {"› "} + {query ? ( + {query} + ) : ( + Filter commands… + )} + + + {hasAbove ? ↑ more : null} + {totalRows === 0 ? ( + {query ? `No commands match “${query}”.` : "No commands."} + ) : ( + visible.map((item, idx) => { + if (item.kind === "heading") { + return ( + + {item.category.toUpperCase()} + + ); + } + const selected = item.flatIndex === selectedIndex; + const nameWidth = item.row.name.length; + const descRoom = Math.max(4, inner - nameWidth - 2 - (item.row.keybind ? item.row.keybind.length + 2 : 0)); + return ( + + + {selected ? theme.rail : " "} + {` ${item.row.name}`} + {` ${endTruncate(item.row.description, descRoom)}`} + + {item.row.keybind ? {item.row.keybind} : null} + + ); + }) + )} + {hasBelow ? ↓ more : null} + + + ↑↓ move · ↵ run · esc close + ); } -function detailsBodyLines(body: string): string[] { +function detailsBodyLines(body: string, scrollOffsetRows = 0): string[] { const lines = body.split(/\r?\n/); - if (lines.length <= DETAILS_BODY_MAX_LINES) return lines; - const remaining = lines.length - DETAILS_BODY_MAX_LINES; - return [ - ...lines.slice(0, DETAILS_BODY_MAX_LINES), - `… ${remaining} more line${remaining === 1 ? "" : "s"}`, - ]; + const start = Math.max(0, Math.min(Math.floor(scrollOffsetRows), Math.max(0, lines.length - DETAILS_BODY_MAX_LINES))); + const window = lines.slice(start, start + DETAILS_BODY_MAX_LINES); + if (start > 0) window.unshift(`↑ ${start} earlier`); + const remaining = Math.max(0, lines.length - (start + DETAILS_BODY_MAX_LINES)); + if (remaining > 0) window.push(`↓ ${remaining} more line${remaining === 1 ? "" : "s"}`); + return window; } function isDetailsSectionLine(line: string): boolean { @@ -814,9 +941,9 @@ function detailsKeyValue(line: string): { key: string; value: string } | null { return { key, value }; } -function DetailsPane({ title, body, width }: { title: string; body: string; width: number }) { +function DetailsPane({ title, body, width, scrollOffsetRows = 0 }: { title: string; body: string; width: number; scrollOffsetRows?: number }) { const bodyWidth = Math.max(12, width - 4); - const lines = detailsBodyLines(body); + const lines = detailsBodyLines(body, scrollOffsetRows); return ( {lines.map((line, index) => { @@ -875,8 +1002,232 @@ function DetailsPane({ title, body, width }: { title: string; body: string; widt ); } +type DiffRenderLine = { + key: string; + text: string; + color: string; + dim: boolean; + bold: boolean; + // Set on per-file header rows so the renderer can colorize the +/− counts + // (green adds / red dels) to match the hunk body and ChatView's file rows. + header?: { path: string; additions: number; deletions: number }; +}; + +// Flatten a diff into colorized, fully-scrollable lines: a bold per-file header +// (path + add/del counts) followed by each hunk line tinted by kind. Shared by +// the renderer and the scroll-row counter so they never drift. +function buildDiffRenderLines( + files: Array<{ path: string; additions?: number; deletions?: number; body?: string }>, +): DiffRenderLine[] { + const out: DiffRenderLine[] = []; + for (const file of files) { + const additions = file.additions ?? 0; + const deletions = file.deletions ?? 0; + out.push({ + key: `${file.path}:head`, + text: `▸ ${file.path} +${additions} −${deletions}`, + color: theme.color.t1, + dim: false, + bold: true, + header: { path: file.path, additions, deletions }, + }); + if (!file.body) continue; + const lines = file.body.split(/\r?\n/); + const shown = lines.slice(0, DIFF_FILE_BODY_MAX); + shown.forEach((line, index) => { + const tone = diffLineTone(diffLineKind(line)); + out.push({ key: `${file.path}:${index}`, text: line, color: tone.color, dim: tone.dim, bold: tone.bold }); + }); + const hidden = lines.length - shown.length; + if (hidden > 0) { + out.push({ + key: `${file.path}:truncated`, + text: ` … ${hidden} more line${hidden === 1 ? "" : "s"} in this file`, + color: theme.color.t4, + dim: true, + bold: false, + }); + } + } + return out; +} + +export function rightPaneScrollableRowCount(content: RightPaneContent): number { + switch (content.kind) { + case "details": + return content.body.split(/\r?\n/).length; + case "context-usage": + return (content.usage?.categories.length ?? 0) + 4; + case "list": + return content.rows.length; + case "diff": + return buildDiffRenderLines(content.files).length; + case "usage": + // Mirror UsagePane's rendered rows so the bottom stays reachable when a + // provider exposes multiple quota windows: each QuotaWindowRow is label(1) + // + bar(1) + marginBottom(1) = 3 rows, plus up to ~4 rows for the session + // block (marginTop + "Session" + body) and any loading/error line. + return (content.quotaWindows?.length ?? 0) * 3 + 4; + case "status": + // Flat key/value list — scrolls by row count. + return content.rows.length; + case "empty": + case "form": + case "chat-info": + case "model-picker": + case "help": + case "lane-details": + // These panes have their own internal navigation (help uses selectedIndex, + // lane-details uses selectedActionIndex) or no scrollable body. + return 0; + default: { + const _exhaustive: never = content; + void _exhaustive; + return 0; + } + } +} + type FormPaneContent = Extract; type LaneDeleteFormContent = FormPaneContent & { command: "lane-delete" }; +type FeedbackFormContent = FormPaneContent & { command: "feedback" }; + +// Rebuild the framework-free FeedbackFormState (from feedbackForm.ts) out of the +// FeedbackContextMeta carried on the form content. app.tsx seeds + edits that +// meta; this keeps the render in lock-step with the reducer/serializer that +// validation + submission go through. +export function feedbackStateFromContent(content: FeedbackFormContent): FeedbackFormState { + const meta = content.feedback ?? {}; + const rawType = (meta.type ?? "bug") as FeedbackType; + const type = FEEDBACK_TYPES.includes(rawType) ? rawType : "bug"; + return { + type, + text: meta.body ?? "", + showContext: meta.showContext !== false, + context: { + provider: meta.provider ?? null, + model: meta.model ?? null, + lane: meta.lane ?? null, + lastError: meta.lastError ?? null, + }, + }; +} + +function FeedbackTypeSelector({ type }: { type: FeedbackType }) { + // ‹ bug · idea · praise › — violet is the only selection accent; the rest is + // neutral idle chrome. + return ( + + + {FEEDBACK_TYPES.map((option, index) => ( + + {index > 0 ? · : null} + + {option === type ? `[${option}]` : option} + + + ))} + + + ); +} + +function FeedbackFormPane({ + content, + focused, + width, +}: { + content: FeedbackFormContent; + focused: boolean; + width: number; +}) { + const hoveredId = useHoveredHitId(); + const tick = useShimmerTick(); + const inner = Math.max(12, width - 4); + const state = feedbackStateFromContent(content); + const submitted = content.feedback?.feedback === "submitted"; + const canSubmit = feedbackFormCanSubmit(state); + + if (submitted) { + // The single sanctioned green (#22C55E === theme.color.done) for a success + // ✓, faded in via the shared spin tick (no bare setInterval). Dim for the + // first ~200ms so it reads as a gentle confirm rather than a flash. + const settled = (tick ?? 0) % 1000 >= 2; + return ( + + + ✓ Feedback sent + + Closing… + + ); + } + + const bodyLines = state.text.length ? state.text.split("\n") : []; + const bodyHover = hoveredId === "right:feedback:body"; + const footer = state.showContext ? serializeContextFooter(state.context) : ""; + + return ( + + + {focused ? theme.rail : " "} + Type + + + + + {bodyHover ? theme.rail : " "} Body + + {bodyLines.length ? ( + bodyLines.map((line, index) => ( + + {endTruncate(line.length ? line : " ", inner)} + {index === bodyLines.length - 1 ? : null} + + )) + ) : ( + + Describe it… + + )} + + + + {state.showContext && footer ? ( + + {footer.split("\n").map((line, index) => ( + + {endTruncate(line, inner)} + + ))} + + ) : ( + + + --- Context --- (hidden · Ctrl+T) + + + )} + + + + ⏎ newline · Ctrl+S send · esc cancel + + + {canSubmit ? "[send]" : ""} + + + + ); +} function LaneDeleteFormPane({ content, @@ -889,6 +1240,7 @@ function LaneDeleteFormPane({ activeFormField: number; width: number; }) { + const hoveredId = useHoveredHitId(); const inner = Math.max(12, width - 4); const meta = content.laneDelete; const scope = formValues.scope === "local_branch" || formValues.scope === "remote_branch" @@ -903,7 +1255,7 @@ function LaneDeleteFormPane({ if (scope === "local_branch") scopeHint = "also delete the local branch"; else if (scope === "remote_branch") scopeHint = `also delete ${remoteName}/${meta?.branchRef ?? "branch"}`; const activeName = fields[activeFormField]?.name ?? fields[0]?.name ?? "scope"; - const active = (name: string) => activeName === name; + const active = (name: string) => activeName === name || hoveredId === `right:form:${name}`; const scopeOption = (value: string, label: string) => ( {scope === value ? `[${label}]` : ` ${label} `} @@ -982,6 +1334,49 @@ function LaneDeleteFormPane({ ); } +function ContextUsagePane({ + content, + width, +}: { + content: Extract; + width: number; +}) { + const inner = Math.max(12, width - 4); + if (content.error) { + return ( + + Context unavailable + {endTruncate(content.error, inner * 3)} + + ); + } + if (!content.usage) { + return ( + + Context usage is not available yet. + + ); + } + const usage = content.usage; + const percent = Math.max(0, Math.min(100, usage.percentage)); + return ( + + {usage.model ? endTruncate(usage.model, inner) : "Model context"} + + + {` ${compactNumber(usage.totalTokens)} / ${compactNumber(usage.maxTokens)} (${percent.toFixed(0)}%)`} + + + {usage.categories.map((category, index) => ( + + {endTruncate(category.name.padEnd(18), 18)} {compactNumber(category.tokens).padStart(7)} {category.percentage.toFixed(category.percentage > 0 && category.percentage < 10 ? 1 : 0).padStart(5)}% + + ))} + + + ); +} + // --------------------------------------------------------------------------- // Pane title resolution // --------------------------------------------------------------------------- @@ -993,10 +1388,6 @@ function paneTitle(content: RightPaneContent): { title: string; hint?: string; b title: content.lane.name, branch: content.lane.branchRef, }; - case "new-chat-setup": - return { title: "NEW CHAT" }; - case "model-setup": - return { title: "MODEL" }; case "chat-info": return { title: `CHAT INFO · ${theme.provider(content.info.provider).label.toUpperCase()}` }; case "model-picker": @@ -1011,6 +1402,10 @@ function paneTitle(content: RightPaneContent): { title: string; hint?: string; b return { title: content.title.toUpperCase() }; case "details": return { title: content.title.toUpperCase() }; + case "context-usage": + return { title: content.title.toUpperCase() }; + case "usage": + return { title: (content.title ?? "USAGE").toUpperCase() }; case "form": return { title: content.title.toUpperCase() }; default: @@ -1030,6 +1425,7 @@ export function RightPane({ focused = false, width = DEFAULT_PANE_WIDTH, modelPickerInputs, + scrollOffsetRows = 0, }: { content: RightPaneContent; formValues?: Record; @@ -1038,17 +1434,21 @@ export function RightPane({ focused?: boolean; activeProvider?: AdeCodeProvider | null; width?: number; + scrollOffsetRows?: number; /** Data passed in by app.tsx for the model-picker content kind. */ - modelPickerInputs?: { - models: AgentChatModelInfo[]; - catalog?: AgentChatModelCatalog | null; - favorites: string[]; - recents: string[]; - activeModelId: string | null; - }; + modelPickerInputs?: { + models: AgentChatModelInfo[]; + catalog?: AgentChatModelCatalog | null; + favorites: string[]; + recents: string[]; + activeModelId: string | null; + activeReasoningEffort?: string | null; + aiStatus?: AiSettingsStatus | null; + }; }) { const { title, hint, branch } = paneTitle(content); const paneWidth = Math.max(30, width); + const hoveredId = useHoveredHitId(); return ( Run /status, /diff, /model, or /help. ) : null} - {content.kind === "help" ? : null} + {content.kind === "help" ? : null} {content.kind === "status" ? ( @@ -1094,14 +1494,29 @@ export function RightPane({ {content.kind === "list" ? ( - {content.rows.length ? content.rows.map((row, index) => ( - - {content.action ? `${index === selectedIndex ? theme.rail : " "} ${row}` : row} + {(() => { + // Clamp so a stale offset (after switching to a shorter same-kind + // list) can't scroll past the content into a blank pane. + const listStart = Math.max(0, Math.min(scrollOffsetRows, Math.max(0, content.rows.length - DETAILS_BODY_MAX_LINES))); + const visibleRows = content.rows.slice(listStart, listStart + DETAILS_BODY_MAX_LINES); + return content.rows.length ? visibleRows.map((row, visibleIndex) => { + const index = listStart + visibleIndex; + return ( + + {content.action ? `${index === selectedIndex ? theme.rail : " "} ${row}` : row} + + ); + }) : {content.emptyText ?? "No data."}; + })()} + {content.rows.length > DETAILS_BODY_MAX_LINES ? ( + + {scrollOffsetRows > 0 ? `↑ ${scrollOffsetRows} earlier · ` : ""} + {Math.max(0, content.rows.length - scrollOffsetRows - DETAILS_BODY_MAX_LINES)} more - )) : {content.emptyText ?? "No data."}} + ) : null} {content.action && content.rows.length ? ( arrows move · enter opens ) : null} @@ -1109,26 +1524,54 @@ export function RightPane({ ) : null} {content.kind === "details" ? ( - + + ) : null} + + {content.kind === "context-usage" ? ( + + ) : null} + + {content.kind === "usage" ? ( + ) : null} {content.kind === "diff" ? ( - {content.files.length ? content.files.map((file) => ( - - - {file.path}{" "} - - +{file.additions ?? 0} -{file.deletions ?? 0} - - - {file.body ? ( - - {file.body.split(/\r?\n/).slice(0, 8).join("\n")} - - ) : null} - - )) : No changes.} + {content.files.length ? (() => { + const diffLines = buildDiffRenderLines(content.files); + const window = diffLines.slice(scrollOffsetRows, scrollOffsetRows + DETAILS_BODY_MAX_LINES); + const maxLineWidth = Math.max(10, paneWidth - 4); + return ( + <> + {window.map((line) => { + if (line.header) { + // Counts stay neutral (t4) to match ChatView's file rows and + // keep green confined to actual diff-body add lines — a green + // count on the header row would read as idle chrome. + const counts = ` +${line.header.additions} −${line.header.deletions}`; + const pathRoom = Math.max(6, maxLineWidth - counts.length - 2); + return ( + + {`▸ ${endTruncate(line.header.path, pathRoom)}`} + {counts} + + ); + } + return ( + + {endTruncate(line.text, maxLineWidth)} + + ); + })} + {diffLines.length > DETAILS_BODY_MAX_LINES ? ( + + {scrollOffsetRows > 0 ? `↑ ${scrollOffsetRows} earlier · ` : ""} + {Math.max(0, diffLines.length - scrollOffsetRows - DETAILS_BODY_MAX_LINES)} more · ↑↓ scroll + + ) : null} + + ); + })() : No changes.} ) : null} @@ -1147,8 +1590,14 @@ export function RightPane({ catalog: modelPickerInputs.catalog, favorites: modelPickerInputs.favorites, recents: modelPickerInputs.recents, - activeModelId: modelPickerInputs.activeModelId, - query: content.query, + activeModelId: modelPickerInputs.activeModelId, + activeReasoningEffort: modelPickerInputs.activeReasoningEffort, + aiStatus: modelPickerInputs.aiStatus, + showAll: content.showAll, + settingsRows: content.settingsRows, + footerFocus: content.footerFocus ?? null, + laneLabel: content.laneLabel ?? null, + query: content.query, selection: content.selection, providerTabKey: content.providerTabKey ?? null, focusedIndex: content.focusedIndex, @@ -1158,37 +1607,6 @@ export function RightPane({ /> ) : null} - {content.kind === "new-chat-setup" || content.kind === "model-setup" ? ( - - {content.kind === "new-chat-setup" ? ( - Lane: {content.laneLabel} - ) : null} - - {content.rows.map((row, index) => { - const selected = index === selectedIndex; - return ( - - - {selected ? theme.rail : " "} {row.label}: {row.value} - - {selected && row.detail ? ( - {row.detail} - ) : null} - - ); - })} - - - {content.kind === "new-chat-setup" - ? "↑↓ rows · ←→ change · ↵ prompt · cmd+↵ background" - : "↑↓ rows · ←→ change · ↵ apply · esc close"} - - - ) : null} - {content.kind === "form" && content.command === "lane-delete" ? ( ) : null} - {content.kind === "form" && content.command !== "lane-delete" ? ( + {content.kind === "form" && content.command === "feedback" ? ( + + ) : null} + + {content.kind === "form" && content.command !== "lane-delete" && content.command !== "feedback" ? ( {content.description ? ( @@ -1207,17 +1633,17 @@ export function RightPane({ ) : null} - {content.fields.map((field, index) => { + {content.fields.map((field, index) => { const value = formValues[field.name]?.trim(); const displayValue = endTruncate( (value || field.placeholder || "").replace(/\s+/g, " "), Math.max(8, paneWidth - field.label.length - 8), ); - return ( - + return ( + {index === activeFormField ? theme.rail : " "} {field.label} {field.required ? " *" : ""}: {displayValue} diff --git a/apps/ade-cli/src/tuiClient/components/SlashPalette.tsx b/apps/ade-cli/src/tuiClient/components/SlashPalette.tsx index b54cc7137..f204dcd87 100644 --- a/apps/ade-cli/src/tuiClient/components/SlashPalette.tsx +++ b/apps/ade-cli/src/tuiClient/components/SlashPalette.tsx @@ -16,10 +16,27 @@ function placementGlyph(placement?: CommandPlacement): string { return PLACEMENT_GLYPHS[placement] ?? " "; } -const VISIBLE_ROWS = 5; -export const SLASH_PALETTE_ROWS = VISIBLE_ROWS + 3; +const MIN_VISIBLE_ROWS = 6; +const MAX_VISIBLE_ROWS = 14; +const CHROME_ROWS = 3; // header + selected-summary + footer const DEFAULT_PALETTE_WIDTH = 88; -const MAX_PALETTE_WIDTH = 104; +const MAX_PALETTE_WIDTH = 132; + +// The palette grows with the available terminal height: more commands visible +// on bigger screens, clamped so it never dominates a short terminal. +export function slashPaletteVisibleRows(maxRows?: number): number { + if (!Number.isFinite(maxRows)) return MIN_VISIBLE_ROWS; + return Math.max(MIN_VISIBLE_ROWS, Math.min(MAX_VISIBLE_ROWS, Math.floor(maxRows ?? 0) - CHROME_ROWS)); +} + +// Total rows the palette occupies (visible command rows + chrome) — used by the +// caller to reserve overlay height so it lines up with the prompt. +export function slashPaletteReservedRows(maxRows?: number): number { + return slashPaletteVisibleRows(maxRows) + CHROME_ROWS; +} + +// Back-compat default reservation when no height budget is supplied. +export const SLASH_PALETTE_ROWS = MIN_VISIBLE_ROWS + CHROME_ROWS; function clampPaletteWidth(width?: number): number { const available = Number.isFinite(width) ? Math.floor(width ?? DEFAULT_PALETTE_WIDTH) : DEFAULT_PALETTE_WIDTH; @@ -108,22 +125,25 @@ export function SlashPalette({ selectedIndex, provider, width, + maxRows, }: { query: string; userCommands: AgentChatSlashCommand[]; selectedIndex: number; provider?: AgentChatProvider | null; width?: number; + maxRows?: number; }) { const rows = paletteCommands(query, userCommands, { provider }); if (!query.startsWith("/") || !rows.length) return null; const paletteWidth = clampPaletteWidth(width); + const visibleRows = slashPaletteVisibleRows(maxRows); const total = rows.length; const safeIndex = Math.max(0, Math.min(selectedIndex, total - 1)); - const half = Math.floor(VISIBLE_ROWS / 2); + const half = Math.floor(visibleRows / 2); let start = Math.max(0, safeIndex - half); - let end = Math.min(total, start + VISIBLE_ROWS); - start = Math.max(0, end - VISIBLE_ROWS); + let end = Math.min(total, start + visibleRows); + start = Math.max(0, end - visibleRows); const window = rows.slice(start, end); const aboveCount = start; const belowCount = total - end; @@ -160,7 +180,7 @@ export function SlashPalette({ value: bodyLine(rowText, paletteWidth), }; }); - while (rowLines.length < VISIBLE_ROWS) { + while (rowLines.length < visibleRows) { rowLines.push({ kind: "blank" as const, value: bodyLine("", paletteWidth) }); } const footer = bottomLine( diff --git a/apps/ade-cli/src/tuiClient/components/TerminalPane.tsx b/apps/ade-cli/src/tuiClient/components/TerminalPane.tsx index 7a0c667d3..b246249e8 100644 --- a/apps/ade-cli/src/tuiClient/components/TerminalPane.tsx +++ b/apps/ade-cli/src/tuiClient/components/TerminalPane.tsx @@ -23,6 +23,17 @@ type TerminalPaneProps = { width: number; height: number; hiddenBottomRows?: number; + /** Rows scrolled up from the live bottom. 0 = pinned (auto-follow). */ + scrollOffset?: number; + /** Count of new lines that arrived while scrolled up; renders the "↓ N new" chip. */ + pendingNewCount?: number; + /** + * Reports the maximum rows the buffer can scroll back (so app.tsx can clamp + * keyboard scroll requests) plus the current plain-text of the visible window + * (so app.tsx can copy the visible region via its existing writeClipboardText). + * Cheap: only fires when the rendered window actually changes. + */ + onViewportMetrics?: (metrics: { maxScrollable: number; visibleText: string }) => void; }; type HeadlessTerminalInstance = InstanceType; @@ -292,9 +303,16 @@ function snapshotCellFromXtermCell(cell: XtermBufferCell | undefined): TerminalS }; } -function styledRowsFromTerminal(terminal: HeadlessTerminalInstance, maxRows: number): TerminalStyledRow[] { +function styledRowsFromTerminal( + terminal: HeadlessTerminalInstance, + maxRows: number, + scrollUpRows = 0, +): TerminalStyledRow[] { const buffer = terminal.buffer.active; - const start = Math.max(0, buffer.viewportY); + // viewportY is the live bottom. Scrolling up reads earlier lines, clamped so + // we never index before the start of the buffer (which includes scrollback). + const baseStart = Math.max(0, buffer.viewportY); + const start = Math.max(0, baseStart - Math.max(0, Math.floor(scrollUpRows))); const rows: TerminalStyledRow[] = []; for (let row = 0; row < Math.max(0, maxRows); row += 1) { const line = buffer.getLine(start + row); @@ -311,6 +329,19 @@ function styledRowsFromTerminal(terminal: HeadlessTerminalInstance, maxRows: num return rows; } +/** Rows of scrollback above the live viewport that can still be revealed by scrolling up. */ +function terminalMaxScrollableRows(terminal: HeadlessTerminalInstance | null): number { + if (!terminal) return 0; + return Math.max(0, terminal.buffer.active.viewportY); +} + +/** Flatten styled rows to plain text for clipboard copy (reuses app's writeClipboardText). */ +function plainTextFromStyledRows(rows: TerminalStyledRow[]): string { + return rows + .map((row) => row.runs.map((run) => run.text).join("").replace(/\s+$/u, "")) + .join("\n"); +} + export function styledRowsFromSnapshotRows(rows: TerminalSnapshotRow[], maxRows: number): TerminalStyledRow[] { return rows.slice(0, Math.max(0, maxRows)).map((row) => styledRowFromCells(row.cells, row.text)); } @@ -343,8 +374,14 @@ export function TerminalPane({ width, height, hiddenBottomRows = 0, + scrollOffset = 0, + pendingNewCount = 0, + onViewportMetrics, }: TerminalPaneProps) { const spinFrame = useSpinFrame(); + // Scrollback only applies to the live (non-attached) preview. When attached, + // Claude owns the terminal and we always follow the bottom. + const effectiveScrollOffset = attached ? 0 : Math.max(0, Math.floor(scrollOffset)); const effectiveHiddenBottomRows = attached ? 0 : hiddenBottomRows; const contentWidth = attached ? Math.max(1, width - 2) : width; const visibleHeight = attached ? Math.max(1, height - 1) : height; @@ -403,8 +440,16 @@ export function TerminalPane({ useEffect(() => { const terminal = terminalRef.current; if (!terminal) return; - const start = chunkIndexRef.current; - if (liveChunks.length < start) chunkIndexRef.current = 0; + // Incremental write: only the chunks past chunkIndexRef are fed to xterm, so + // the write cursor keeps advancing as new chunks append (this is what the + // app.tsx ref/timer flush relies on — no destructive per-chunk slice(-500) + // that would pin length and freeze this loop). If the owner trims the array + // (length shrinks below our cursor), reset xterm and replay the retained + // tail so the visible buffer stays consistent without duplication. + if (liveChunks.length < chunkIndexRef.current) { + terminal.reset(); + chunkIndexRef.current = 0; + } for (let index = chunkIndexRef.current; index < liveChunks.length; index += 1) { terminal.write(liveChunks[index] ?? "", () => setRenderTick((tick) => tick + 1)); } @@ -417,9 +462,21 @@ export function TerminalPane({ return transcriptPreviewRows(preview.transcript, rows); } const terminal = terminalRef.current; - if (terminal) return styledRowsFromTerminal(terminal, rows); + if (terminal) return styledRowsFromTerminal(terminal, rows, effectiveScrollOffset); return fallbackPreviewRows(preview, rows); - }, [liveChunks.length, preview, renderTick, rows, snapshotRows]); + }, [effectiveScrollOffset, liveChunks.length, preview, renderTick, rows, snapshotRows]); + + // Surface scrollback bounds + the current visible text to the owner so it can + // clamp keyboard scroll requests and copy the visible region. Cheap: gated on + // the values that actually change the window. Only meaningful for the live + // (non-attached) xterm-backed preview. + const reportMetrics = onViewportMetrics; + useEffect(() => { + if (!reportMetrics) return; + const terminal = terminalRef.current; + const maxScrollable = snapshotRows?.length ? 0 : terminalMaxScrollableRows(terminal); + reportMetrics({ maxScrollable, visibleText: plainTextFromStyledRows(lines) }); + }, [lines, reportMetrics, snapshotRows]); const status = attached ? "CLAUDE CONTROL · Ctrl+T returns to ADE · Ctrl+] escape" @@ -429,6 +486,8 @@ export function TerminalPane({ ? "closed, resumable · Enter resumes" : "closed"; + const scrolledBack = !attached && effectiveScrollOffset > 0; + const showNewChip = scrolledBack && pendingNewCount > 0; const content = ( <> @@ -436,6 +495,13 @@ export function TerminalPane({ {attached ? `${spinFrame} ${title}` : title} {status} + {showNewChip ? ( + // Amber "attention" chip: new output landed while the user is reading + // scrollback. Press End / Shift+Down-to-bottom to jump and clear it. + {` ↓ ${pendingNewCount} new`} + ) : scrolledBack ? ( + {` ↑ scrollback · End to follow`} + ) : null} {lines.slice(0, visibleHeight).map((line, index) => ( diff --git a/apps/ade-cli/src/tuiClient/components/TerminalScrollState.ts b/apps/ade-cli/src/tuiClient/components/TerminalScrollState.ts new file mode 100644 index 000000000..8f3e65acc --- /dev/null +++ b/apps/ade-cli/src/tuiClient/components/TerminalScrollState.ts @@ -0,0 +1,107 @@ +/** + * Scrollback + "new output" state for the Claude live PTY pane (TerminalPane). + * + * Claude turns render through a headless xterm fed by `pty_data` chunks. When the + * user scrolls up to read earlier output we must (a) stop auto-following the + * bottom and (b) surface an amber "↓ N new" chip counting lines that arrived + * while they were scrolled away. This module holds the small, framework-free + * state shape + pure helpers shared between app.tsx (owner of the per-session + * map) and TerminalPane.tsx (consumer/renderer). Keeping it pure keeps the + * hot path allocation-free and unit-testable without React. + */ + +/** Per-session scrollback position + pending-new-output counter. */ +export type TerminalScrollState = { + /** + * Rows scrolled up from the live bottom. 0 = pinned to bottom (auto-follow); + * >0 = user is reading scrollback and new output should NOT yank them down. + */ + scrollOffset: number; + /** Lines of new output that arrived while scrolled up; drives the "↓ N new" chip. */ + pendingNewCount: number; +}; + +export type TerminalScrollBySessionId = Record; + +export const TERMINAL_SCROLL_AT_BOTTOM: TerminalScrollState = { + scrollOffset: 0, + pendingNewCount: 0, +}; + +/** How many rows one PageUp/PageDown moves, relative to the visible window. */ +export function terminalPageStep(visibleRows: number): number { + return Math.max(1, Math.floor(Math.max(1, visibleRows) / 2)); +} + +/** Read a session's scroll state, defaulting to "pinned at bottom". */ +export function readTerminalScroll( + byId: TerminalScrollBySessionId, + sessionId: string | null | undefined, +): TerminalScrollState { + if (!sessionId) return TERMINAL_SCROLL_AT_BOTTOM; + return byId[sessionId] ?? TERMINAL_SCROLL_AT_BOTTOM; +} + +/** + * Clamp a requested scroll offset to the valid window. + * `maxScrollable` is (buffer rows above the viewport that can still be revealed). + */ +export function clampTerminalScrollOffset(offset: number, maxScrollable: number): number { + const ceiling = Math.max(0, Math.floor(maxScrollable)); + if (!Number.isFinite(offset)) return 0; + return Math.max(0, Math.min(ceiling, Math.floor(offset))); +} + +/** + * Apply a relative scroll delta (negative = toward older output / up, + * positive = toward newest / down) producing the next state. Scrolling all the + * way back to the bottom clears the pending-new counter (the user has caught up). + */ +export function scrollTerminalBy( + current: TerminalScrollState, + delta: number, + maxScrollable: number, +): TerminalScrollState { + // Up (older) increases offset; our `delta` convention: up = +, down = -. + const nextOffset = clampTerminalScrollOffset(current.scrollOffset + delta, maxScrollable); + // No-op scroll past the clamp (offset unchanged) must not allocate a new state + // object — but still clear a stale pending counter once we're pinned at bottom. + if (nextOffset === current.scrollOffset) { + return nextOffset === 0 && current.pendingNewCount !== 0 + ? { scrollOffset: 0, pendingNewCount: 0 } + : current; + } + return { + scrollOffset: nextOffset, + pendingNewCount: nextOffset === 0 ? 0 : current.pendingNewCount, + }; +} + +/** Jump to the live bottom and clear the "N new" counter (chip dismiss / End). */ +export function jumpTerminalToBottom(current: TerminalScrollState): TerminalScrollState { + if (current.scrollOffset === 0 && current.pendingNewCount === 0) return current; + return TERMINAL_SCROLL_AT_BOTTOM; +} + +/** + * Record that `arrivedRows` of fresh output landed. When pinned to the bottom we + * stay pinned (no chip). When scrolled up we accumulate the count so the chip can + * say how far behind the user is. + */ +export function noteTerminalNewRows( + current: TerminalScrollState, + arrivedRows: number, + maxScrollable = Number.POSITIVE_INFINITY, +): TerminalScrollState { + if (current.scrollOffset <= 0) return current; + const add = Math.max(0, Math.floor(arrivedRows)); + if (add === 0) return current; + // The render window starts at viewportY - scrollOffset and viewportY grows by + // `add` per arrived row, so advance scrollOffset by the same amount to keep the + // user's viewed content anchored to its absolute buffer line instead of + // drifting down a line per write. Clamp to the (grown) maxScrollable. + return { + scrollOffset: clampTerminalScrollOffset(current.scrollOffset + add, maxScrollable), + pendingNewCount: current.pendingNewCount + add, + }; +} diff --git a/apps/ade-cli/src/tuiClient/components/UsagePane.tsx b/apps/ade-cli/src/tuiClient/components/UsagePane.tsx new file mode 100644 index 000000000..4f490af63 --- /dev/null +++ b/apps/ade-cli/src/tuiClient/components/UsagePane.tsx @@ -0,0 +1,155 @@ +import React from "react"; +import { Box, Text } from "ink"; +import type { RightPaneContent } from "../types"; +import { theme } from "../theme"; +import { TokenBar, tokenBarColor } from "./FooterControls"; +import { useShimmerTick } from "../spinTick"; + +type UsageContent = Extract; +type QuotaWindow = NonNullable[number]; + +function endTruncate(value: string, max: number): string { + if (max <= 1) return value.length ? "…" : ""; + if (value.length <= max) return value; + return `${value.slice(0, Math.max(0, max - 1))}…`; +} + +function compactTokens(value: number | null | undefined): string { + if (value == null || !Number.isFinite(value)) return "—"; + if (value >= 1_000_000) return `${(value / 1_000_000).toFixed(1)}M`; + if (value >= 1_000) return `${(value / 1_000).toFixed(1)}k`; + return String(Math.round(value)); +} + +function formatCost(value: number | null | undefined): string { + if (value == null || !Number.isFinite(value)) return "—"; + if (value === 0) return "$0.00"; + if (value < 0.01) return "<$0.01"; + return `$${value.toFixed(2)}`; +} + +/** + * Reset countdown. `resetAt` is an epoch in *seconds* (matching the daemon's + * rate-limit `resetsAt` from `latestTokenStats`). `nowMs` is read fresh each + * spin tick so the timer ticks down while the pane is open — but only while the + * tick is advancing (real activity), so an idle pane never re-renders. + */ +function formatResetCountdown(resetAt: number | null | undefined, nowMs: number): string | null { + if (resetAt == null || !Number.isFinite(resetAt)) return null; + const remainingMs = resetAt * 1000 - nowMs; + if (remainingMs <= 0) return "now"; + const totalMinutes = Math.ceil(remainingMs / 60_000); + if (totalMinutes < 60) return `${totalMinutes}m`; + const hours = Math.floor(totalMinutes / 60); + const minutes = totalMinutes % 60; + if (hours < 24) return minutes ? `${hours}h ${minutes}m` : `${hours}h`; + const days = Math.floor(hours / 24); + const remHours = hours % 24; + return remHours ? `${days}d ${remHours}h` : `${days}d`; +} + +function QuotaWindowRow({ + window, + width, + nowMs, + marginTop, +}: { + window: QuotaWindow; + width: number; + nowMs: number; + marginTop: number; +}) { + const percent = Math.max(0, Math.min(100, Math.round(window.percent))); + const color = tokenBarColor(percent); + const countdown = formatResetCountdown(window.resetAt, nowMs); + // Reserve room for the "↻ " chip (+ a gap) only when a countdown is + // actually shown; otherwise just leave a small gutter. Previously this always + // subtracted a fixed 8, needlessly truncating labels when no countdown exists. + const reserved = countdown ? countdown.length + 4 : 2; + const labelWidth = Math.max(4, Math.floor(width) - reserved); + return ( + + + {endTruncate(window.label, labelWidth)} + {countdown ? ( + {`↻ ${countdown}`} + ) : null} + + + {/* TokenBar carries the ≥95% danger pulse (amber→red, gated on the spin + tick) for free, so the last filled cell blinks when a window is full. */} + + {` ${percent}%`} + + + ); +} + +export function UsagePane({ content, width }: { content: UsageContent; width: number }) { + const inner = Math.max(12, width - 4); + // Read the clock once per render. The hook only changes value while the spin + // tick is advancing (streaming / connecting), so an idle /usage pane consumes + // the tick but produces ZERO re-renders — the countdown is frozen at rest and + // resumes live the moment there's activity. + const tick = useShimmerTick(); + void tick; + const nowMs = Date.now(); + + if (content.loading) { + return ( + + Loading usage… + + ); + } + + if (content.error) { + return ( + + Usage unavailable + {endTruncate(content.error, inner * 3)} + + ); + } + + const windows = content.quotaWindows ?? []; + const session = content.session ?? null; + + return ( + + {windows.length ? ( + windows.map((window, index) => ( + + )) + ) : ( + Quota windows unavailable. + )} + + + This session + {session ? ( + <> + + ↑in + {compactTokens(session.input)} + {" ↓out "} + {compactTokens(session.output)} + + + cost + {formatCost(session.cost)} + + + ) : ( + No session usage yet. + )} + + + ); +} diff --git a/apps/ade-cli/src/tuiClient/components/designKit.tsx b/apps/ade-cli/src/tuiClient/components/designKit.tsx new file mode 100644 index 000000000..a0f66cacc --- /dev/null +++ b/apps/ade-cli/src/tuiClient/components/designKit.tsx @@ -0,0 +1,147 @@ +import React from "react"; +import { Box, Text } from "ink"; +import { theme } from "../theme"; + +/** + * designKit — the shared visual vocabulary for the ADE TUI. + * + * Every pane composes these primitives so the whole client reads as one + * designed product: the same titled-card section headers, the same status + * glyphs/colors, the same labeled chips, the same hairline rules and key-hint + * footers. Colors come exclusively from theme.ts tokens. + * + * Color discipline (the user is firm): violet (#A78BFA) is the brand accent and + * selection color; neutral tokens for borders/secondary text; amber for + * attention/awaiting. GREEN is reserved for two semantic signals only — a + * running spinner, and a success ✓/● glyph (matching Claude Code) — NEVER for + * borders, bars, fills, or idle chrome, where it reads as a glitch. + * + * All primitives are pure/cheap (no state, no effects) so they never add idle + * re-renders. + */ + +// ── Rules & dividers ─────────────────────────────────────────────────────── + +/** A hairline rule that fills `width` columns. */ +export function Rule({ width, color = theme.color.borderSoft }: { width: number; color?: string }) { + return {"─".repeat(Math.max(0, Math.floor(width)))}; +} + +// ── Section headers ────────────────────────────────────────────────────────── + +/** + * Titled-card section header: optional glyph + bold title, a hairline rule that + * fills the gap, and a right-aligned dim hint. The signature look that ties the + * panes together. + */ +export function SectionHeader({ + title, + hint, + color = theme.color.violet, + glyph, + width, + marginTop = 1, +}: { + title: string; + hint?: string; + color?: string; + glyph?: string; + width: number; + marginTop?: number; +}) { + const inner = Math.max(8, Math.floor(width)); + const used = (glyph ? glyph.length + 1 : 0) + title.length + 2 + (hint ? hint.length + 1 : 0); + const ruleLen = Math.max(1, inner - used); + return ( + + {glyph ? {`${glyph} `} : null} + {title} + {` ${"─".repeat(ruleLen)}${hint ? " " : ""}`} + {hint ? {hint} : null} + + ); +} + +// ── Status glyphs ──────────────────────────────────────────────────────────── + +export type StatusKind = "live" | "idle" | "done" | "failed" | "pending" | "info" | "warn"; + +const STATUS_GLYPH: Record = { + // Green only here for the two allowed semantic signals (live + done). + live: { glyph: "●", color: theme.color.running }, + done: { glyph: "✓", color: theme.color.done }, + failed: { glyph: "✗", color: theme.color.error }, + pending: { glyph: "◔", color: theme.color.attention }, + warn: { glyph: "▲", color: theme.color.warning }, + info: { glyph: "●", color: theme.color.info }, + idle: { glyph: "○", color: theme.color.t4 }, +}; + +export function statusGlyph(kind: StatusKind): { glyph: string; color: string } { + return STATUS_GLYPH[kind]; +} + +/** A colored status dot + optional label, e.g. `● live`, `✓ passing`. */ +export function StatusDot({ kind, label, bold }: { kind: StatusKind; label?: string; bold?: boolean }) { + const s = STATUS_GLYPH[kind]; + return ( + + {s.glyph} + {label ? ` ${label}` : ""} + + ); +} + +// ── Chips ──────────────────────────────────────────────────────────────────── + +/** A labeled value chip: dim label + colored value. `label value`. */ +export function Chip({ + label, + value, + valueColor = theme.color.t2, + active, +}: { + label?: string; + value: string; + valueColor?: string; + active?: boolean; +}) { + return ( + + {label ? {`${label} `} : null} + {value} + + ); +} + +/** A bracketed pill — used for primary actions/buttons. */ +export function Pill({ label, active, disabled }: { label: string; active?: boolean; disabled?: boolean }) { + const color = disabled ? theme.color.t5 : active ? theme.color.violet : theme.color.violetDeep; + return {`[ ${label} ]`}; +} + +// ── Selection rail ─────────────────────────────────────────────────────────── + +/** The leading violet rail glyph shown on a selected/focused row (else a space). */ +export function Rail({ on }: { on: boolean }) { + return {on ? theme.rail : " "}; +} + +// ── Key-hints footer ───────────────────────────────────────────────────────── + +/** Dim key-hint footer: `key action · key action · …` with keys in accent. */ +export function KeyHints({ items, marginTop = 1 }: { items: Array<[string, string]>; marginTop?: number }) { + return ( + + + {items.map(([key, action], index) => ( + + {index > 0 ? {" · "} : null} + {key} + {` ${action}`} + + ))} + + + ); +} diff --git a/apps/ade-cli/src/tuiClient/connection.ts b/apps/ade-cli/src/tuiClient/connection.ts index 13e15b4db..d2c4f585b 100644 --- a/apps/ade-cli/src/tuiClient/connection.ts +++ b/apps/ade-cli/src/tuiClient/connection.ts @@ -529,6 +529,7 @@ async function connectAttachedSocket(args: { socketPath: args.socketPath, request, ...createAdeActionHelpers(request), + onConnectionClose: (handler: () => void) => attachedClient.onClose(handler), onChatEvent: (callback: (event: AgentChatEventEnvelope) => void) => { const stopChatNotification = attachedClient.onNotification("chat/event", (params) => callback(params as AgentChatEventEnvelope), @@ -788,6 +789,8 @@ export async function connectToAde(args: { socketPath: null, request, ...createAdeActionHelpers(request), + // Embedded runtimes live in-process and never "drop" — no-op listener. + onConnectionClose: () => () => {}, onChatEvent: (callback) => chatEvents(callback), subscribeRuntimeEvents: async (subscriptionArgs, callback) => { const category = subscriptionArgs.category ?? "runtime"; diff --git a/apps/ade-cli/src/tuiClient/feedbackForm.ts b/apps/ade-cli/src/tuiClient/feedbackForm.ts new file mode 100644 index 000000000..739b67f44 --- /dev/null +++ b/apps/ade-cli/src/tuiClient/feedbackForm.ts @@ -0,0 +1,187 @@ +// Pure, framework-free state + serialization logic for the multiline feedback +// form rendered in the right pane. No React/ink imports so it can be unit +// tested in isolation. +// +// REUSE-ONLY: this module does NOT invent a daemon verb. It produces a +// FeedbackFormValues object that feeds the EXISTING buildFeedbackDraftInput() +// helper in ./feedback, whose output is sent through the existing +// conn.action("feedback", "prepareDraft" | "submitPreparedDraft", ...) path in +// app.tsx. We only swap the single-line truncated form for a multiline body + +// type selector + toggleable auto-context footer. + +import type { FeedbackCategory } from "../../../desktop/src/shared/types/feedback"; +import type { FeedbackFormValues } from "./feedback"; + +/** + * Type selector surfaced in the right pane: ‹ bug · idea · praise ›. These are + * the user-facing labels; each maps to an existing FeedbackCategory understood + * by buildFeedbackDraftInput so no server change is needed: + * bug -> "bug" + * idea -> "feature" + * praise -> "enhancement" (a positive product signal to keep/extend — not a + * "question", which would wrongly route a compliment to triage) + */ +export const FEEDBACK_TYPES = ["bug", "idea", "praise"] as const; +export type FeedbackType = (typeof FEEDBACK_TYPES)[number]; + +const TYPE_TO_CATEGORY: Record = { + bug: "bug", + idea: "feature", + praise: "enhancement", +}; + +/** Map a UI feedback type to the existing daemon FeedbackCategory. */ +export function feedbackTypeToCategory(type: FeedbackType): FeedbackCategory { + return TYPE_TO_CATEGORY[type]; +} + +/** + * Auto-captured context, read from existing app state at open/submit time. Each + * field is optional; missing fields are simply omitted from the serialized + * footer. This is what makes bug reports actionable without the user typing it. + */ +export interface ContextFooterInfo { + /** Active provider id, e.g. "anthropic". */ + provider?: string | null; + /** Active model id, e.g. "claude-opus-4-8". */ + model?: string | null; + /** Active lane / worktree name. */ + lane?: string | null; + /** Last error or notice surfaced to the user (most actionable for bugs). */ + lastError?: string | null; +} + +/** Serializable, framework-free state for the feedback form. */ +export interface FeedbackFormState { + /** Selected feedback type. */ + type: FeedbackType; + /** Raw multiline body (newline-separated). */ + text: string; + /** Whether the auto-context footer is appended to the report. */ + showContext: boolean; + /** Captured context snapshot (kept on state so submit uses current values). */ + context: ContextFooterInfo; +} + +export type FeedbackFormAction = + | { kind: "setType"; type: FeedbackType } + | { kind: "cycleType"; direction: 1 | -1 } + | { kind: "setText"; text: string } + | { kind: "appendChar"; char: string } + | { kind: "appendNewline" } + | { kind: "backspace" } + | { kind: "toggleContext" } + | { kind: "setContext"; context: ContextFooterInfo } + | { kind: "reset"; context?: ContextFooterInfo }; + +/** Initial form state. Context defaults to ON so reports are actionable. */ +export function feedbackFormInitialState( + context: ContextFooterInfo = {}, +): FeedbackFormState { + return { + type: "bug", + text: "", + showContext: true, + context: { ...context }, + }; +} + +/** Cycle to the next/previous type, wrapping at the ends. */ +export function cycleFeedbackType(current: FeedbackType, direction: 1 | -1): FeedbackType { + const idx = FEEDBACK_TYPES.indexOf(current); + const len = FEEDBACK_TYPES.length; + const next = (((idx + direction) % len) + len) % len; + return FEEDBACK_TYPES[next]; +} + +/** Pure reducer for the feedback form. Always returns a new object on change. */ +export function feedbackFormReducer( + state: FeedbackFormState, + action: FeedbackFormAction, +): FeedbackFormState { + switch (action.kind) { + case "setType": + if (state.type === action.type) return state; + return { ...state, type: action.type }; + case "cycleType": { + const type = cycleFeedbackType(state.type, action.direction); + if (type === state.type) return state; + return { ...state, type }; + } + case "setText": + if (state.text === action.text) return state; + return { ...state, text: action.text }; + case "appendChar": + return { ...state, text: state.text + action.char }; + case "appendNewline": + return { ...state, text: state.text + "\n" }; + case "backspace": + if (state.text.length === 0) return state; + return { ...state, text: state.text.slice(0, -1) }; + case "toggleContext": + return { ...state, showContext: !state.showContext }; + case "setContext": + return { ...state, context: { ...action.context } }; + case "reset": + return feedbackFormInitialState(action.context ?? state.context); + default: + return state; + } +} + +/** True when the form has a non-empty (non-whitespace) body to submit. */ +export function feedbackFormCanSubmit(state: FeedbackFormState): boolean { + return state.text.trim().length > 0; +} + +/** + * Render the context footer block appended as additional context. Returns an + * empty string when there is nothing useful to show. Lines are only emitted for + * fields that are present, so a footer never contains dangling labels. + */ +export function serializeContextFooter(context: ContextFooterInfo): string { + const lines: string[] = []; + const providerModel = [context.provider, context.model] + .filter((v): v is string => typeof v === "string" && v.trim().length > 0) + .join(" / "); + if (providerModel) lines.push(`Provider/Model: ${providerModel}`); + if (context.lane && context.lane.trim().length > 0) { + lines.push(`Lane: ${context.lane.trim()}`); + } + if (context.lastError && context.lastError.trim().length > 0) { + lines.push(`Last error/notice: ${context.lastError.trim()}`); + } + if (lines.length === 0) return ""; + return ["--- Context ---", ...lines].join("\n"); +} + +/** + * Derive the summary line from the (multiline) body: the first non-empty line, + * which is what buildFeedbackDraftInput expects in the required `summary` slot. + */ +export function feedbackSummaryLine(text: string): string { + for (const raw of text.split("\n")) { + const line = raw.trim(); + if (line.length > 0) return line; + } + return ""; +} + +/** + * Map the form state to the EXISTING FeedbackFormValues shape consumed by + * buildFeedbackDraftInput(). The first non-empty line becomes the summary; the + * full multiline body goes into details (newlines preserved verbatim); the + * toggleable context footer is placed in additionalContext. No new daemon verb + * or draft shape is introduced. + */ +export function feedbackFormToFormValues(state: FeedbackFormState): FeedbackFormValues { + const summary = feedbackSummaryLine(state.text); + const details = state.text.replace(/\s+$/u, ""); + const footer = state.showContext ? serializeContextFooter(state.context) : ""; + return { + category: feedbackTypeToCategory(state.type), + summary, + details, + ...(footer ? { additionalContext: footer } : {}), + }; +} diff --git a/apps/ade-cli/src/tuiClient/format.ts b/apps/ade-cli/src/tuiClient/format.ts index ed7f5cda1..23240b6e6 100644 --- a/apps/ade-cli/src/tuiClient/format.ts +++ b/apps/ade-cli/src/tuiClient/format.ts @@ -78,11 +78,12 @@ export type InlineRun = { italic?: boolean; code?: boolean; link?: boolean; + href?: string; color?: string; dim?: boolean; }; -type InlineFlags = { bold?: boolean; italic?: boolean; code?: boolean; link?: boolean }; +type InlineFlags = { bold?: boolean; italic?: boolean; code?: boolean; link?: boolean; href?: string }; function pushInlineRun(runs: InlineRun[], text: string, flags: InlineFlags): void { if (!text.length) return; @@ -91,7 +92,8 @@ function pushInlineRun(runs: InlineRun[], text: string, flags: InlineFlags): voi && (last.bold ?? false) === (flags.bold ?? false) && (last.italic ?? false) === (flags.italic ?? false) && (last.code ?? false) === (flags.code ?? false) - && (last.link ?? false) === (flags.link ?? false); + && (last.link ?? false) === (flags.link ?? false) + && last.href === flags.href; if (sameFlags && last) { last.text += text; return; @@ -101,6 +103,7 @@ function pushInlineRun(runs: InlineRun[], text: string, flags: InlineFlags): voi if (flags.italic) run.italic = true; if (flags.code) run.code = true; if (flags.link) run.link = true; + if (flags.href) run.href = flags.href; runs.push(run); } @@ -137,8 +140,9 @@ function walkInlineTokens(tokens: Token[], runs: InlineRun[], flags: InlineFlags case "link": { const link = token as Tokens.Link; const child: InlineRun[] = []; - if (link.tokens && link.tokens.length) walkInlineTokens(link.tokens, child, { ...flags, link: true }); - else pushInlineRun(child, link.text, { ...flags, link: true }); + const href = typeof link.href === "string" ? link.href : undefined; + if (link.tokens && link.tokens.length) walkInlineTokens(link.tokens, child, { ...flags, link: true, href }); + else pushInlineRun(child, link.text, { ...flags, link: true, href }); for (const c of child) runs.push(c); break; } @@ -232,9 +236,10 @@ function flattenInlineTokensToText(tokens: Token[] | undefined): string { continue; } if (token.type === "link") { - // Preserve only the visible label — the URL is dropped because the TUI - // doesn't render hyperlinks distinctly today. - out += flattenInlineTokensToText(generic.tokens) || (typeof generic.text === "string" ? generic.text : ""); + const link = generic as unknown as { href?: unknown; text?: unknown; tokens?: Tokens.Generic[] }; + const href = typeof link.href === "string" ? link.href : ""; + const label = flattenInlineTokensToText(generic.tokens) || (typeof generic.text === "string" ? generic.text : href); + out += href ? `[${label}](${href})` : label; continue; } if (generic.tokens && generic.tokens.length) { @@ -799,8 +804,22 @@ export function formatLaneLabel(lane: LaneSummary | null): string { export function formatSessionLabel(session: AgentChatSessionSummary): string { const label = (session.title ?? session.goal ?? session.summary ?? session.sessionId).trim(); - const state = session.awaitingInput ? " ?" : session.status === "active" ? " ●" : ""; - return `${label}${state}`; + const tag = session.orchestrationTag ? ` #${session.orchestrationTag}` : ""; + const completion = session.completion?.status; + const state = session.archivedAt + ? " ×" + : session.awaitingInput + ? " ?" + : session.status === "active" + ? " ●" + : completion === "blocked" + ? " !" + : completion === "partial" + ? " ◐" + : completion === "completed" + ? " ✓" + : ""; + return `${label}${tag}${state}`; } export function renderObject(value: unknown, maxLines = 24): string { @@ -829,3 +848,32 @@ export function summarizeDiffChanges(value: unknown): Array<{ path: string; addi }) .slice(0, 20); } + +/** + * Classify a single unified-diff line so the renderer can colorize it. + * Pure + theme-free so it stays testable; the caller maps the kind to a + * theme token. File-meta lines (`diff --git`, `index`, the `+++`/`---` + * header pair) are distinguished from real `+`/`-` content so they don't + * paint the whole file header green/red. + */ +export type DiffLineKind = "add" | "del" | "hunk" | "meta" | "context"; + +export function diffLineKind(line: string): DiffLineKind { + if (line.startsWith("@@")) return "hunk"; + if ( + line.startsWith("+++") || + line.startsWith("---") || + line.startsWith("diff --git") || + line.startsWith("index ") || + line.startsWith("new file") || + line.startsWith("deleted file") || + line.startsWith("rename ") || + line.startsWith("similarity ") || + line.startsWith("\\ No newline") + ) { + return "meta"; + } + if (line.startsWith("+")) return "add"; + if (line.startsWith("-")) return "del"; + return "context"; +} diff --git a/apps/ade-cli/src/tuiClient/helpIndex.ts b/apps/ade-cli/src/tuiClient/helpIndex.ts new file mode 100644 index 000000000..04afd97d9 --- /dev/null +++ b/apps/ade-cli/src/tuiClient/helpIndex.ts @@ -0,0 +1,217 @@ +import { + BUILTIN_COMMANDS, + COMMAND_CATEGORY_ORDER, + type BuiltinCommand, + type CommandCategory, +} from "./commands"; +import type { ClaudeKeybinding, TuiKeybindingAction } from "./keybindings"; + +/** + * helpIndex — pure grouping / filtering / ranking logic for the /help command + * reference pane. Kept render-free and dependency-light so it is unit-testable + * in isolation (see __tests__/helpIndex.test.ts). app.tsx feeds the resulting + * {@link HelpGroup}[] into the RightPaneContent `help` variant, and RightPane.tsx + * renders it. + */ + +export type HelpRow = { + /** Slash command name, e.g. "/commit". */ + name: string; + description: string; + /** Display keybind ("Ctrl+P") or undefined when the command has no binding. */ + keybind?: string; + source: "ade" | "user"; + category: CommandCategory; +}; + +export type HelpGroup = { + category: CommandCategory; + rows: HelpRow[]; +}; + +const UNCATEGORIZED: CommandCategory = "System"; + +/** + * A handful of slash commands open a surface that is *also* reachable via a + * global keybinding. The keybindings registry (Claude-config) is keyed by + * app-level action, not by command name, so we map the relevant commands onto + * their action here. Commands absent from this map render without a keybind chip + * (the common case). Actions must be members of {@link TuiKeybindingAction}. + */ +const COMMAND_KEYBIND_ACTION: Partial> = { + "/help": "app:help", + "/clear": "app:clear", + "/quit": "app:quit", + "/model": "chat:modelPicker", + "/undo": "chat:undo", + "/agents": "pane:agents", +}; + +/** + * Format a normalized chord (e.g. "ctrl+p", "shift+tab", "ctrl+o ctrl+m") for + * display in a keybind chip: capitalize each modifier/key segment. + */ +export function formatChordForDisplay(chord: string): string { + return chord + .split(" ") + .map((stroke) => + stroke + .split("+") + .map((part) => (part.length <= 1 ? part.toUpperCase() : part.charAt(0).toUpperCase() + part.slice(1))) + .join("+"), + ) + .join(" "); +} + +/** + * Resolve the display keybind for a command from the live keybindings registry, + * or undefined when the command is not bound to any global action. Degrades to + * undefined (never throws) when the registry is empty/missing — which is the + * default, since ADE ships no in-code default bindings (the Claude keybindings + * file is opt-in). The first matching, implemented binding wins. + */ +export function getKeybindForCommand( + name: string, + bindings: readonly ClaudeKeybinding[] | null | undefined, +): string | undefined { + if (!bindings || bindings.length === 0) return undefined; + const action = COMMAND_KEYBIND_ACTION[name]; + if (!action) return undefined; + const match = bindings.find((binding) => binding.implemented && binding.action === action && Boolean(binding.key)); + return match ? formatChordForDisplay(match.key) : undefined; +} + +/** + * Partition the built-in commands into ordered, non-empty category groups. + * Category order follows {@link COMMAND_CATEGORY_ORDER}; commands without an + * explicit category fall into the trailing "System" bucket. Optionally enriches + * each row with its bound keybind chip from the live registry. + */ +export function buildHelpIndex( + commands: readonly BuiltinCommand[] = BUILTIN_COMMANDS, + bindings?: readonly ClaudeKeybinding[] | null, +): HelpGroup[] { + const byCategory = new Map(); + for (const category of COMMAND_CATEGORY_ORDER) byCategory.set(category, []); + + for (const command of commands) { + const category = command.category ?? UNCATEGORIZED; + const bucket = byCategory.get(category) ?? byCategory.get(UNCATEGORIZED)!; + bucket.push({ + name: command.name, + description: command.description, + keybind: getKeybindForCommand(command.name, bindings), + source: "ade", + category, + }); + } + + const groups: HelpGroup[] = []; + for (const category of COMMAND_CATEGORY_ORDER) { + const rows = byCategory.get(category)!; + if (rows.length > 0) groups.push({ category, rows }); + } + return groups; +} + +/** + * Fuzzy subsequence test (same semantics as app.tsx's palette fuzzy matcher): + * does every char of `needle` appear in `haystack` in order? + */ +function fuzzySubsequence(haystack: string, needle: string): boolean { + let hi = 0; + let ni = 0; + while (hi < haystack.length && ni < needle.length) { + if (haystack[hi] === needle[ni]) ni += 1; + hi += 1; + } + return ni === needle.length; +} + +/** + * Score a help row against a query, mirroring app.tsx's palette ranking so the + * help pane ranks consistently with the command palette: + * exact name 1000 · name prefix 500 · per-token name 100 · desc 40 · fuzzy 20. + * A token that matches nowhere disqualifies the row (returns -1). Empty query + * returns 0 (no ranking signal — caller falls back to recents + alphabetical). + */ +export function helpMatchScore(query: string, row: HelpRow): number { + const normalizedQuery = query.trim().toLowerCase(); + if (!normalizedQuery) return 0; + const name = row.name.toLowerCase(); + const desc = row.description.toLowerCase(); + if (name === normalizedQuery || name === `/${normalizedQuery}`) return 1000; + if (name.startsWith(normalizedQuery) || name.startsWith(`/${normalizedQuery}`)) return 500; + const tokens = normalizedQuery.split(/\s+/).filter(Boolean); + let score = 0; + for (const token of tokens) { + if (name.includes(token)) score += 100; + else if (desc.includes(token)) score += 40; + else if (fuzzySubsequence(name, token)) score += 20; + else return -1; + } + return score; +} + +/** + * Apply the live filter, fuzzy ranking, and recents floatation to the indexed + * groups, returning a fresh set of groups (empty groups dropped). + * + * Ranking within each group: + * 1. recents first (most-recent wins), then + * 2. higher fuzzy score, then + * 3. alphabetical by name. + * With an empty filter every command is kept (score 0) so the pane shows the + * full reference; recents still float to the top of their group. + */ +export function buildHelpRows( + groups: readonly HelpGroup[], + filterQuery: string, + recents: readonly string[] = [], +): HelpGroup[] { + const query = filterQuery.trim(); + // recents[0] is most-recent ⇒ give it the highest rank weight. + const recentRank = new Map(); + recents.forEach((name, index) => { + if (!recentRank.has(name)) recentRank.set(name, recents.length - index); + }); + + const result: HelpGroup[] = []; + for (const group of groups) { + const scored: Array<{ row: HelpRow; score: number; recent: number }> = []; + for (const row of group.rows) { + const score = helpMatchScore(query, row); + if (query && score < 0) continue; // token didn't match anywhere + scored.push({ row, score, recent: recentRank.get(row.name) ?? 0 }); + } + scored.sort((a, b) => { + if (a.recent !== b.recent) return b.recent - a.recent; + if (a.score !== b.score) return b.score - a.score; + return a.row.name.localeCompare(b.row.name); + }); + if (scored.length > 0) { + result.push({ category: group.category, rows: scored.map((entry) => entry.row) }); + } + } + return result; +} + +/** + * Flatten grouped rows into the linear navigation order (group order preserved, + * rows in their ranked order) so ↑↓ can index a single list and the renderer can + * map a flat index back to a row. + */ +export function flattenHelpRows(groups: readonly HelpGroup[]): HelpRow[] { + const flat: HelpRow[] = []; + for (const group of groups) flat.push(...group.rows); + return flat; +} + +/** + * Push a command name onto the front of a recents list (most-recent-first), + * de-duplicating and capping length. Pure — returns a new array. + */ +export function pushRecent(recents: readonly string[], name: string, limit = 5): string[] { + const next = [name, ...recents.filter((existing) => existing !== name)]; + return next.slice(0, limit); +} diff --git a/apps/ade-cli/src/tuiClient/hitTestRegistry.ts b/apps/ade-cli/src/tuiClient/hitTestRegistry.ts index a8e4504c4..ad9345f99 100644 --- a/apps/ade-cli/src/tuiClient/hitTestRegistry.ts +++ b/apps/ade-cli/src/tuiClient/hitTestRegistry.ts @@ -99,6 +99,10 @@ export function useHitTest(): HitTestContextValue { return context; } +export function useHoveredHitId(): string | null { + return useContext(HitTestContext)?.hoveredId ?? null; +} + export function useHitTestTarget(target: HitTarget | null | false | undefined): boolean { const { registry, hoveredId } = useHitTest(); const latestTargetRef = useRef(null); diff --git a/apps/ade-cli/src/tuiClient/jsonRpcClient.ts b/apps/ade-cli/src/tuiClient/jsonRpcClient.ts index 25c2bf5f0..d5ca02334 100644 --- a/apps/ade-cli/src/tuiClient/jsonRpcClient.ts +++ b/apps/ade-cli/src/tuiClient/jsonRpcClient.ts @@ -23,15 +23,13 @@ export class JsonRpcClient { private buffer = Buffer.alloc(0); private pending = new Map(); private notificationHandlers = new Map void>>(); + private closeHandlers = new Set<() => void>(); private closed = false; constructor(private readonly socket: net.Socket) { socket.on("data", (chunk: Buffer | string) => this.handleData(chunk)); - socket.on("error", (error) => this.rejectAll(error)); - socket.on("close", () => { - this.closed = true; - this.rejectAll(new Error("ADE RPC socket closed.")); - }); + socket.on("error", (error) => this.handleSocketClosed(error)); + socket.on("close", () => this.handleSocketClosed(new Error("ADE RPC socket closed."))); } static connect(socketPath: string): Promise { @@ -86,12 +84,43 @@ export class JsonRpcClient { } close(): void { + // Mark closed before tearing down so the subsequent socket "close" event is + // treated as intentional and does NOT fire the unexpected-close handlers. this.closed = true; this.rejectAll(new Error("ADE RPC socket closed.")); this.socket.end(); this.socket.destroy(); } + /** + * Register a listener fired when the socket drops unexpectedly (peer close or + * error) — but NOT on an intentional `close()`. Returns an unsubscribe fn. + */ + onClose(handler: () => void): () => void { + if (this.closed) { + handler(); + return () => {}; + } + this.closeHandlers.add(handler); + return () => { + this.closeHandlers.delete(handler); + }; + } + + private handleSocketClosed(error: Error): void { + const wasClosed = this.closed; + this.closed = true; + this.rejectAll(error); + if (wasClosed) return; + for (const handler of [...this.closeHandlers]) { + try { + handler(); + } catch { + // A listener throwing must not break the others or the teardown. + } + } + } + onNotification(method: string, handler: (params: unknown) => void): () => void { const handlers = this.notificationHandlers.get(method) ?? new Set<(params: unknown) => void>(); handlers.add(handler); diff --git a/apps/ade-cli/src/tuiClient/keybindings/index.ts b/apps/ade-cli/src/tuiClient/keybindings/index.ts index 4d2c4124b..d79b0e973 100644 --- a/apps/ade-cli/src/tuiClient/keybindings/index.ts +++ b/apps/ade-cli/src/tuiClient/keybindings/index.ts @@ -38,6 +38,7 @@ const SUPPORTED_ACTION_VALUES = [ "app:clear", "app:quit", "app:copyAdeDeeplink", + "app:openCommandPalette", "history:search", "history:previous", "history:next", diff --git a/apps/ade-cli/src/tuiClient/multiChatLayout.ts b/apps/ade-cli/src/tuiClient/multiChatLayout.ts index 1a4ac3fb5..bbc775db8 100644 --- a/apps/ade-cli/src/tuiClient/multiChatLayout.ts +++ b/apps/ade-cli/src/tuiClient/multiChatLayout.ts @@ -61,14 +61,21 @@ export function computeTileRects(n: 1 | 2 | 3 | 4 | 5 | 6, width: number, height const pattern = PATTERNS[n]; const cols = pattern[0]?.cols ?? 1; const rows = pattern[0]?.rows ?? 1; - const colW = Math.max(1, Math.floor(safeWidth / cols)); - const rowH = Math.max(1, Math.floor(safeHeight / rows)); - return pattern.map((tile) => ({ - x: tile.col * colW, - y: tile.row * rowH, - w: tile.colSpan * colW, - h: tile.rowSpan * rowH, - })); + // Snap each column/row boundary to an integer cell edge that distributes the + // remainder across the grid, so the tiles fill the full pane with no dead + // margin on the right/bottom (floor(width/cols) left up to cols-1 dead cells). + const colEdge = (index: number) => Math.round((index * safeWidth) / cols); + const rowEdge = (index: number) => Math.round((index * safeHeight) / rows); + return pattern.map((tile) => { + const x = colEdge(tile.col); + const y = rowEdge(tile.row); + return { + x, + y, + w: Math.max(1, colEdge(tile.col + tile.colSpan) - x), + h: Math.max(1, rowEdge(tile.row + tile.rowSpan) - y), + }; + }); } export function canRenderMultiChatGrid(count: number, width: number, height: number): boolean { diff --git a/apps/ade-cli/src/tuiClient/pendingInput.ts b/apps/ade-cli/src/tuiClient/pendingInput.ts index a569665b2..675987beb 100644 --- a/apps/ade-cli/src/tuiClient/pendingInput.ts +++ b/apps/ade-cli/src/tuiClient/pendingInput.ts @@ -50,9 +50,11 @@ export function latestPendingApproval(events: AgentChatEventEnvelope[]): Pending return { itemId: event.itemId, description, + // Permission grants (write/network/external scope) keep the typed + // high-stakes confirmation. Only plan_approval / model_selection were + // intentionally relaxed to the one-key card. highStakes: mode === "approval" && ( request?.kind === "permissions" - || request?.kind === "plan_approval" || looksHighStakesApproval(description, event.detail) ), mode, diff --git a/apps/ade-cli/src/tuiClient/spinTick.tsx b/apps/ade-cli/src/tuiClient/spinTick.tsx index aa95b9b15..29404cc4e 100644 --- a/apps/ade-cli/src/tuiClient/spinTick.tsx +++ b/apps/ade-cli/src/tuiClient/spinTick.tsx @@ -44,3 +44,9 @@ export function useDotPulse(): string { const index = Math.floor(tick / 3) % DOT_FRAMES.length; return DOT_FRAMES[index]!; } + +// Raw tick for callers that compute their own motion (e.g. a shimmer that sweeps +// a bright cell across a label). Advances every ~100ms. +export function useShimmerTick(): number { + return useContext(SpinTickContext); +} diff --git a/apps/ade-cli/src/tuiClient/theme.ts b/apps/ade-cli/src/tuiClient/theme.ts index dacfe6252..5e71c6639 100644 --- a/apps/ade-cli/src/tuiClient/theme.ts +++ b/apps/ade-cli/src/tuiClient/theme.ts @@ -25,6 +25,9 @@ const T5 = "#4A4955"; // Brand violet family const VIOLET = "#A78BFA"; const VIOLET_DEEP = "#7C3AED"; +// Darker violets used for the wordmark's layered 3D drop shadow. +const VIOLET_DEEPER = "#5B21B6"; +const VIOLET_DEEPEST = "#3B1675"; // Status family const RUNNING = "#22C55E"; @@ -154,6 +157,8 @@ export const theme = { accentDim: VIOLET_DEEP, violet: VIOLET, violetDeep: VIOLET_DEEP, + violetDeeper: VIOLET_DEEPER, + violetDeepest: VIOLET_DEEPEST, // Text fg: T1, diff --git a/apps/ade-cli/src/tuiClient/types.ts b/apps/ade-cli/src/tuiClient/types.ts index 21096dda4..812d1931a 100644 --- a/apps/ade-cli/src/tuiClient/types.ts +++ b/apps/ade-cli/src/tuiClient/types.ts @@ -8,6 +8,7 @@ import type { AgentChatDroidPermissionMode, AgentChatEventEnvelope, AgentChatEventHistorySnapshot, + AgentChatContextUsage, AgentChatInteractionMode, AgentChatModelInfo, AgentChatOpenCodePermissionMode, @@ -21,6 +22,7 @@ import type { } from "../../../desktop/src/shared/types/chat"; import type { LaneSummary } from "../../../desktop/src/shared/types/lanes"; import type { BufferedEvent } from "../eventBuffer"; +import type { HelpGroup } from "./helpIndex"; export type RuntimeMode = "attached" | "embedded"; @@ -51,6 +53,12 @@ export type AdeCodeConnection = { action(domain: string, action: string, args?: Record): Promise; actionList(domain: string, action: string, argsList: unknown[]): Promise; onChatEvent(callback: (event: AgentChatEventEnvelope) => void): () => void; + /** + * Fired when the underlying transport drops unexpectedly (attached socket + * only; embedded runtimes never drop). Optional so partial test doubles and + * legacy callers stay compatible. Returns an unsubscribe fn. + */ + onConnectionClose?(handler: () => void): () => void; subscribeRuntimeEvents( args: { category?: BufferedEvent["category"] | null; cursor?: number; limit?: number; replay?: boolean }, callback: (event: BufferedEvent) => void, @@ -76,6 +84,7 @@ export type AdeCodeModelState = { opencodePermissionMode: AgentChatOpenCodePermissionMode; droidPermissionMode: AgentChatDroidPermissionMode; cursorModeId: string | null; + cursorAvailableModeIds: string[]; cursorConfigValues: Record; }; @@ -134,15 +143,46 @@ export type ModelPickerRightPaneContent = { surface: "chat" | "new-chat"; query: string; searchMode: boolean; + showAll: boolean; selection: ModelPickerRightPaneSelection; providerTabKey?: string | null; focusedIndex: number; + footerFocus?: SetupPaneRowKind | null; + settingsRows?: SetupPaneRow[]; + laneId?: string | null; + laneLabel?: string | null; }; +// Serializable state carried on the feedback form's RightPaneContent. Mirrors +// the framework-free FeedbackFormState in feedbackForm.ts (type + multiline body +// + toggleable auto-context footer) so the right-pane render, the keyboard input +// guard, and the submit path all read/write the same object. `feedback: +// "submitted"` is the transient flag that switches the pane to the success check. +export interface FeedbackContextMeta { + type?: "bug" | "idea" | "praise"; + body?: string; + showContext?: boolean; + provider?: string | null; + model?: string | null; + lane?: string | null; + lastError?: string | null; + feedback?: "submitted"; +} + export type RightPaneContent = | { kind: "empty" } | ModelPickerRightPaneContent - | { kind: "help"; title: string } + | { + kind: "help"; + title: string; + // Live filter text the user has typed into the help search field. + filterQuery?: string; + // Focused row in the flattened (filtered) command list, 0-based. + selectedIndex?: number; + // Grouped + filtered + ranked rows produced by helpIndex.ts and passed + // down from app.tsx. Undefined ⇒ the pane builds nothing (empty state). + groupedRows?: HelpGroup[]; + } | { kind: "status"; rows: Array<[string, string]> } | { kind: "list"; @@ -155,30 +195,41 @@ export type RightPaneContent = }; } | { kind: "details"; title: string; body: string } + | { kind: "context-usage"; title: string; usage: AgentChatContextUsage | null; error?: string | null } | { kind: "diff"; title: string; files: Array<{ path: string; additions?: number; deletions?: number; body?: string }> } | { kind: "chat-info"; info: ChatInfoSnapshot } | { - kind: "new-chat-setup"; - laneId: string; - laneLabel: string; - rows: SetupPaneRow[]; - } - | { - kind: "model-setup"; - rows: SetupPaneRow[]; + // /usage pane: provider quota window(s) + this session's tokens & cost. + // `quotaWindows` undefined ⇒ no quota data in the snapshot (the daemon + // exposes at most a single rate-limit window today), so the pane degrades + // to the session block only. + kind: "usage"; + title?: string; + loading?: boolean; + error?: string | null; + quotaWindows?: Array<{ id: string; label: string; percent: number; resetAt?: number | null }>; + session?: { input: number | null; output: number | null; cost: number | null } | null; } | { kind: "form"; title: string; - command: "new-lane" | "rename" | "pr-open" | "feedback" | "lane-delete" | "new-lane-from-unstaged"; + command: "new-lane" | "rename" | "lane-rename" | "pr-open" | "feedback" | "lane-delete" | "new-lane-from-unstaged" | "chat-delete"; description?: string; laneId?: string; + sessionId?: string; + chatDelete?: { + sessionId: string; + title: string; + }; laneDelete?: { laneId: string; laneName: string; branchRef: string | null; dirty: boolean; }; + // Present only for the feedback form (command === "feedback"): the + // multiline form's serializable state (see FeedbackContextMeta). + feedback?: FeedbackContextMeta; fields: Array<{ name: string; label: string; diff --git a/codexGoal.md b/codexGoal.md index d1614aeb0..2f11618a2 100644 --- a/codexGoal.md +++ b/codexGoal.md @@ -1,753 +1,313 @@ -# ADE CLI sessions: finish correctness, UX, and performance proof - -You are taking over the `app-control-fixes` lane in: - -`/Users/arul/ADE/.ade/worktrees/app-control-fixes-d55c0422` - -The user is debugging ADE Work-tab CLI sessions across Codex, Claude, Cursor, -OpenCode, and Droid. They are frustrated because prior smoke testing created -confusing stale sessions, stale green status indicators, duplicate resumed -rows, auto-closing PTYs, and high memory/lag. Do not give a theoretical answer. -Trace the real code, fix the real issue, and verify in the dev Electron app. - -## Non-negotiable expectations - -- Preserve user changes in the dirty worktree. Do not reset or revert unrelated - files. -- Use Node 22 for desktop commands: - `PATH=$HOME/.asdf/installs/nodejs/22.13.1/bin:$PATH`. -- Use the dev Electron app as the UI source of truth, not Safari. -- Use Codex Computer Use or CDP to verify the actual Electron UI behavior. -- Stop and report clearly if a provider cannot work because auth/setup is - missing. Do not fake a passing result. -- Run small focused tests while iterating. Do not jump straight to full test - suites until the implementation and smoke proof are complete. -- When testing CLI sessions, clean up the sessions/processes you create. - -## Current known state from the previous pass - -Several fixes are already present on this branch. Re-read the code before -trusting this list, but these are the intended current changes: - -- Dead/stale CLI rows should no longer stay green after app restart. Detached or - killed PTY-backed sessions should render as ended/red. -- Sending a resume message to a dead CLI session should reuse the same session - row and create a new PTY under that same session id, not create a duplicate - sidebar row. -- Resume composer UI was simplified to message-only; the previous model and - runtime metadata should be reused for the resume. -- Cursor CLI launch defaults should use `cursor-agent --model auto`, not GPT - model names. -- Cursor CLI resume should preserve the Cursor session id and should not append - a stray literal `n` to the resume id. -- PTY termination paths were changed to kill the process tree instead of only - the top-level PTY process. -- Droid CLI exists on the machine, but Droid is blocked by account/subscription - setup unless the user has since authenticated it. - -Important files likely involved: - -- `apps/desktop/src/main/services/pty/ptyService.ts` -- `apps/desktop/src/main/utils/terminalSessionSignals.ts` -- `apps/desktop/src/main/services/sessions/sessionService.ts` -- `apps/desktop/src/shared/cliLaunch.ts` -- `apps/desktop/src/shared/types/sessions.ts` -- `apps/desktop/src/renderer/components/terminals/cliLaunch.ts` -- `apps/desktop/src/renderer/components/terminals/useWorkSessions.ts` -- `apps/desktop/src/renderer/components/terminals/TerminalsPage.tsx` -- `apps/desktop/src/renderer/components/terminals/WorkViewArea.tsx` -- Settings/runtime availability files for Cursor SDK and CLI model discovery. - Find these with `rg "Cursor|cursor-agent|CURSOR_API_KEY|models.list|list-models" apps/desktop/src`. - -## Task 1: fully investigate the PTY lag and memory issue - -The user observed that only four ADE CLI sessions made the computer feel slow -and memory-heavy. Four normal terminal sessions should not do that. Treat this -as a real product bug until disproven. - -Do a focused performance/resource investigation before making guesses: - -1. Launch the dev desktop app from `apps/desktop` with the current lane code. - Use a throwaway ADE home/project root if needed so smoke sessions do not - pollute the user's real state. -2. Create a small controlled set of CLI sessions: one each for Codex, Claude, - Cursor, and OpenCode. Skip Droid unless auth/subscription is available. -3. Record process tree and RSS before, during, after stop/delete, and after app - restart. Include Electron main, Electron renderer, node child processes, - PTYs, provider CLIs, and any lingering descendants. -4. Inspect whether ADE is doing expensive renderer work: - - transcript re-render frequency - - session list polling/subscription churn - - title/summary extraction or preview parsing - - hidden terminal rendering - - unbounded transcript buffers or IPC payloads -5. Inspect whether main process leaves stale resources: - - process trees after stop/delete - - timers - - event subscriptions - - file watchers - - session list intervals - - leaked PTY objects -6. Add instrumentation only if needed, and remove or gate noisy logging before - finalizing. - -Expected outcome: - -- Either fix the root cause, or produce a concrete measured bottleneck with a - small high-confidence fix plan. -- If you fix it, add tests or a smoke assertion that would catch the regression - where feasible. -- Confirm cleanup leaves no stale provider CLI processes from your smoke run. - -## Task 2: fix Cursor model availability UX for CLI and SDK separately - -Current bad UX: - -- The ADE UI model picker does not show Cursor models until the Cursor SDK is - configured. -- That is wrong because Cursor CLI sessions can still be available through the - local `cursor-agent` binary even when `@cursor/sdk` / `CURSOR_API_KEY` is not - configured. - -Desired UX: - -- ADE should check both Cursor availability paths: - - Cursor SDK/native runtime availability via `@cursor/sdk` and - `CURSOR_API_KEY`. - - Cursor CLI availability via the local `cursor-agent` binary and its CLI - model listing, for example `cursor-agent models` or - `cursor-agent --list-models`. -- If only Cursor CLI is available, show Cursor models in model selection with a - small `CLI only` tag. -- If only Cursor SDK/chat runtime is available, show Cursor models with a small - `Chat only` tag. -- If both are available, show Cursor models normally with no tag. -- Do not block CLI model selection just because the Cursor SDK settings card - says sign-in required. -- Keep copy concise and stateful. - -Implementation guidance: - -- Do not hard-code only `auto` unless there is no reliable discovery path. - Prefer real CLI discovery and cache it with sane invalidation/error handling. -- If SDK and CLI return overlapping model ids, merge them by model id and keep - source availability metadata. -- If a model exists only in CLI discovery, it must still be launchable by - Cursor CLI sessions. -- If a model exists only in SDK discovery, it must not be offered for Cursor CLI - launch unless Cursor CLI accepts it. -- Add focused tests for: - - SDK unavailable + CLI available => models visible with `CLI only`. - - SDK available + CLI unavailable => models visible with `Chat only`. - - both available => merged models, no source tag. - - neither available => current sign-in/setup UI remains understandable. - -## Task 3: smoke every permission mode for each supported CLI runtime - -The user explicitly asked for every permission mode on every runtime that has a -CLI session option: - -- Codex -- Claude -- Cursor -- OpenCode -- Droid only if auth/subscription is actually available - -Use one model per runtime. For Cursor, use `auto` or another real Cursor CLI -model, not GPT model ids. For each runtime, use two reasoning/autonomy levels -where that runtime exposes them. If a runtime does not have an equivalent -reasoning control, document that as "not applicable" with the exact help/doc -evidence. - -Before testing, verify current CLI flags against installed help output and, if -needed, official/runtime docs: - -- `codex --help` and `codex resume --help` -- `claude --help` -- `cursor-agent --help` and `cursor-agent models` or `cursor-agent --list-models` -- `opencode --help` and `opencode run --help` -- `droid --help` and `droid exec --help` - -For each runtime and mode: - -1. Launch from the ADE UI or the same IPC path the UI uses. -2. Confirm the spawned command contains the expected permission/autonomy flags. -3. Confirm the prompt opens the PTY immediately after sending a message. -4. Send 2-3 small messages back and forth where possible. -5. Stop/kill/close ADE, reopen it, and confirm old CLI rows appear ended/red, - not green and not gray. -6. Resume by sending a message to the dead row. -7. Confirm the same session row is reused in-place, with no duplicate sidebar - row. -8. Confirm the resume command uses the right provider/session id and preserved - launch metadata. -9. Delete the session after it is stopped or dead; deletion should not fail - with "Running terminal sessions must be...". -10. Confirm no stale processes from that session remain. - -If a runtime cannot be tested because setup is missing, stop and report the -exact blocker. For Droid, the acceptable blocker is the local Droid CLI saying -there is no active subscription or no credentials. - -## Task 4: verify app close/reopen behavior - -This is separate from normal stop/delete. The required product behavior is: - -- If the Electron app exits, live PTYs are gone. -- On next launch, ADE should not pretend those process-local PTYs are still - live. -- The old rows should show ended/red. -- The transcript should still be viewable. -- The user should be able to type a resume message into the same row. -- Resume should create a new PTY backing the same session row. - -Do this for Codex, Claude, Cursor, and OpenCode. Droid only if setup permits. - -## Task 5: clean up old smoke/test sessions in the dev ADE app - -The prior pass created confusing "soak" and smoke rows. Before final smoke -proof, clean your own test sessions out of the dev ADE home/project state. - -If deletion fails because ADE thinks a dead PTY is running: - -- Trace why the session still reports running. -- Fix the status/enrichment/delete guard path. -- Verify the user can delete dead/unreachable sessions from the UI. - -Do not delete user-owned real sessions unless the user explicitly asks. - -## Task 6: run validation and final commands - -After fixes and smoke proof: - -1. Run focused tests for the touched surfaces. -2. Run: - - `npm --prefix apps/desktop run typecheck` - - relevant focused desktop Vitest files - - `npm --prefix apps/desktop run lint` if touched code should be linted -3. If broad validation is requested, follow `AGENTS.md` validation order. -4. The user previously requested the repo commands: - - `/Users/arul/ADE/.claude/commands/automate.md` - - `/Users/arul/ADE/.claude/commands/finalize.md` - -Before running broad automate/finalize-style checks, make sure the actual -implementation and smoke proof above are done. Do not run huge suites as a -substitute for the missing UI/runtime verification. - -## Required final report - -The final response must be concrete and must include: - -- What was fixed. -- What was measured for lag/memory and the before/after or blocker. -- Cursor model UX behavior for SDK-only, CLI-only, and both-available cases. -- A matrix of runtime x permission mode x reasoning/autonomy levels tested. -- For each runtime, whether app close/reopen showed ended/red and resumed - in-place without duplicates. -- Any provider blockers, with exact command output summary. -- Validation commands run and their results. -- Confirmation that smoke sessions/processes were cleaned up. - -## Handoff update: 2026-05-26 16:35 EDT - -This goal is not complete. Resume from this file and the current worktree state. -The worktree is intentionally dirty from earlier passes; do not reset it. The -changes from this pass are focused in: - -- `apps/desktop/src/main/services/pty/ptyService.ts` -- `apps/desktop/src/main/services/pty/ptyService.test.ts` -- `apps/desktop/src/shared/cliLaunch.ts` -- `apps/desktop/src/main/utils/terminalSessionSignals.ts` -- `apps/desktop/src/renderer/components/terminals/cliLaunch.test.ts` -- this `codexGoal.md` - -### Socket and lane discipline - -- This lane socket is `/tmp/ade-runtime-app-control-fixes-d55c0422.sock`. -- Another active lane socket exists at - `/tmp/ade-runtime-ui-clean-up-3470f34e.sock`. Do not touch or kill it. -- User explicitly wants: if this lane socket is already up, reuse it. If not, - create a new socket only for this lane when launching the desktop app. -- Dev launch command used: - ```bash - PATH=$HOME/.asdf/installs/nodejs/22.13.1/bin:$PATH \ - ADE_PROJECT_ROOT=/Users/arul/ADE \ - ADE_DEV_RUNTIME_SOCKET_PATH=/tmp/ade-runtime-app-control-fixes-d55c0422.sock \ - NO_DEVTOOLS=1 \ - npm run dev:desktop -- --skip-runtime-build - ``` -- The dev app may rebuild stale CLI/main bundles before opening Electron. That - happened in the latest run and is expected. - -### Computer Use workflow that worked best - -The user specifically wants real Work-tab testing through Codex Computer Use, -not direct CLI launch flags. Keep using this flow: - -1. Start the dev Electron app from this worktree, pointed at this lane socket. -2. Before any UI action in a new assistant turn, call Computer Use - `get_app_state({"app":"Electron"})`. -3. Confirm the state shows the local dev Electron app: - - bundle `com.github.Electron` - - HTML URL includes `localhost:5173` - - Work tab URL is `/work?...` -4. Interact with the Work tab exactly as a user would: - - click the existing lane row - - click an existing stopped CLI row or create a new Work CLI session - - type into the Work composer - - click Send - - use the visible Stop button for cleanup -5. Use shell/SQLite only for evidence after the UI action: - - process tree/RSS - - transcript tail - - `.ade/ade.db` row state - - no lingering provider processes - -Do not use Safari as the parity reference. Do not bypass the Work tab with -direct CLI flags except for harmless help/docs/probe commands. - -### Official/runtime docs checked in this pass - -Use these as the runtime truth sources when continuing: - -- OpenAI Codex CLI docs: - `https://developers.openai.com/codex/cli/reference` - - Current docs say `codex resume` accepts the same global flags as `codex`, - including model and sandbox overrides. - - Current docs list `codex exec resume` for non-interactive resume, but ADE - Work-tab CLI sessions are interactive TUI sessions. -- OpenAI Codex security/permissions docs: - `https://developers.openai.com/codex/security` -- Claude Code CLI docs: - `https://docs.anthropic.com/en/docs/claude-code/cli-usage` - - `--permission-mode default|acceptEdits|plan|bypassPermissions` are valid. - - `--resume` resumes a specific session. -- Claude permission docs: - `https://docs.anthropic.com/en/docs/claude-code/iam` - - Confirms the permission-mode meanings. -- Cursor CLI docs: - `https://docs.cursor.com/en/cli/overview` - `https://docs.cursor.com/en/cli/reference/parameters` - `https://docs.cursor.com/en/cli/using` - - Confirms `cursor-agent --resume`, `--model`, `--force`, `--mode plan`, - and command approval behavior. - - Cursor modes doc confirms Agent/Ask/Plan behavior. -- OpenCode CLI docs: - `https://dev.opencode.ai/docs/cli/` - - Confirms bare `opencode` starts the TUI, and `opencode run` is the - non-interactive path with `--interactive`, `--session`, `--continue`, - `--model`, `--agent`, `--replay`, and `--replay-limit`. -- Factory Droid CLI docs: - `https://docs.factory.ai/cli/configuration/cli-reference` - `https://docs.factory.ai/cli/configuration/settings` - `https://docs.factory.ai/cli/user-guides/auto-run` - - Confirms bare `droid` is interactive and `droid ""` starts the - same interactive CLI with initial context. - - Confirms `droid exec` is non-interactive. - - Confirms `--auto low|medium|high`, `--session-id`, and - `--skip-permissions-unsafe` for `droid exec`. - - Confirms interactive Droid uses settings such as `model`, - `reasoningEffort`, `sessionDefaultSettings.interactionMode`, and - `sessionDefaultSettings.autonomyLevel`. - -Local installed help also confirmed: - -- `droid --help`: "Running 'droid' without any options starts interactive mode. - Provide an inline prompt to start the session with initial context." -- `droid exec --help`: "Execute a single command (non-interactive mode)" and - lists `--auto low|medium|high`, `--skip-permissions-unsafe`, - `--session-id`, `--model`, and `--reasoning-effort`. - -### What was verified with Computer Use - -The following was driven from the real Work tab in Electron: - -- The Work tab was open on lane `cli perf resource smoke 20260526`. -- A stopped Cursor Agent row was selected: - `b491b60d-5e97-4e8c-8aca-70f652f5f5c8`. -- The row had previously printed - `MATRIX_CURSOR_AGENT_RESUME_AFTER_REOPEN_526` after an app quit/reopen. -- After reloading the patched main bundle, a Work-tab follow-up was sent: - `Print MATRIX_CURSOR_AGENT_PATCHED_DIRECT_RESUME_526 and then wait.` -- The same session row was reused in place. It did not create a duplicate - sidebar row. -- DB evidence after sending: - - `id`: `b491b60d-5e97-4e8c-8aca-70f652f5f5c8` - - `title`: `Print MATRIX_CURSOR_AGENT_PATCHED_DIRECT_RESUME_526 then wait` - - `status`: `running` during smoke, later returned to follow-up/ended state - after Cursor printed the marker and waited. - - `pty_id`: `b52eeea6-4955-49a5-9fcb-69c567818490` - - `owner_pid`: Electron PID at the time, `21997` before tsup restarted it. - - `resume_command` remained - `cursor-agent --model auto --resume 53c58376-3e41-4e89-a417-138712566865` - - `resume_metadata_json` preserved provider `cursor`, target id - `53c58376-3e41-4e89-a417-138712566865`, permission mode `default`, model - `auto`. -- Process tree evidence while the patched resume was alive: - - `cursor-agent --model auto --resume 53c58376-...` was a child of Electron - and its own process group. - - Cursor again spawned AWS MCP sidecars: - `uv tool uvx awslabs.aws-iac-mcp-server@latest`, - `uv tool uvx awslabs.aws-pricing-mcp-server@latest`, and their Python - children. -- Transcript evidence: - - Old pre-patch resume had zsh/asdf noise: - `/Users/arul/.asdf/completions/asdf.bash:98: command not found: complete`. - - The patched follow-up appended - `MATRIX_CURSOR_AGENT_PATCHED_DIRECT_RESUME_526` without new asdf/zsh - startup noise. - -The latest CUA state after the marker printed showed the Cursor row as -"Add a follow-up" rather than a visible running terminal. It did not need a -Stop click at that point. Reconfirm with `ps` when resuming. - -### Fix 1: non-interactive clean shell for resumed CLI sessions - -Problem found: - -- Resumed ended CLI sessions were being relaunched by starting an interactive - shell and typing the resume command into it. -- For Cursor this produced user shell startup noise: - `/Users/arul/.asdf/completions/asdf.bash:98: command not found: complete`. -- It also made process ownership/cleanup harder to reason about. - -Patch made: - -- `apps/desktop/src/main/services/pty/ptyService.ts` - - Added `directShellLaunchForCommandLine(...)`. - - On non-Windows, resumed command lines now launch as: - `/bin/bash --noprofile --norc -lc `. - - Applied this to: - - `sendToSession(...)` resume path. - - `reattachChatCli(...)` resume path. -- `apps/desktop/src/main/services/pty/ptyService.test.ts` - - Updated tests so resumed CLI follow-ups assert direct non-interactive bash - spawn instead of typed startup commands. - - Cursor resumed follow-up test now asserts no startup command is typed before - readiness and that the follow-up message is submitted normally. - - OpenCode replay resume test now inspects spawn args. - -Validation run: - -```bash -PATH=$HOME/.asdf/installs/nodejs/22.13.1/bin:$PATH \ -npm --prefix apps/desktop run test -- --run src/main/services/pty/ptyService.test.ts -t "sendToSession|reattach" -``` - -Result: - -- Passed. -- Vitest output: `1 passed`, `21 passed | 118 skipped` inside the filtered file - run. - -Important nuance: - -- In `ps`, bash may not remain visible because `bash -lc` can exec the final - command. That is okay. The evidence to check is: no interactive zsh prompt, - no `.asdf/completions/asdf.bash` noise, correct resume command, and cleanup of - the provider process tree. - -### Fix 2: Droid Work-tab launches should be interactive, not `droid exec` - -Problem found: - -- ADE fresh Droid Work-tab launches were built with `droid exec ...`. -- Factory docs and local help say `droid exec` is non-interactive. -- That contradicts the Work-tab expectation and the user's explicit test - requirement that CLI sessions stay up for a while and support follow-ups. - -Patch started: - -- `apps/desktop/src/shared/cliLaunch.ts` - - Fresh Droid Work-tab launches now build an interactive command using - `droid --settings "$ADE_DROID_SETTINGS" ""` through - `/bin/bash -lc`, not `droid exec`. - - Droid model IDs now strip ADE's `droid/` prefix before passing them to the - Factory CLI/settings. Example: `droid/gpt-5.4` becomes `gpt-5.4`. - - Temporary Droid settings now include: - - `model` - - `reasoningEffort` - - `sessionDefaultSettings.interactionMode` - - `sessionDefaultSettings.autonomyLevel` - - for plan/spec mode, `specModeModel` and `specModeReasoningEffort` - - Droid resume command generation now carries preserved model/reasoning into - the temp settings file. -- `apps/desktop/src/main/utils/terminalSessionSignals.ts` - - Mirrored the Droid temp-settings/model-prefix behavior for resume command - reconstruction from terminal signals. -- `apps/desktop/src/renderer/components/terminals/cliLaunch.test.ts` - - Updated the Droid launch test to expect an interactive Droid command and - settings JSON instead of `droid exec`. - - Added resume override coverage for Droid model/reasoning/autonomy. - -This Droid patch has not yet been validated. Run tests before trusting it. - -Recommended next validation for this patch: - -```bash -PATH=$HOME/.asdf/installs/nodejs/22.13.1/bin:$PATH \ -npm --prefix apps/desktop run test -- --run \ - src/renderer/components/terminals/cliLaunch.test.ts \ - src/main/utils/terminalSessionSignals.test.ts \ - -t "Droid|droid|resume-time model" -``` - -Then run a real Work-tab Droid launch only if Droid auth/subscription is -available. If blocked, capture the exact Droid CLI error and put it in the -final matrix. - -### Runtime matrix status - -Not complete. Current visible Work-tab rows show broad partial coverage: - -- Cursor: - - Plan long session printed `MATRIX_CURSOR_PLAN_AUTO_LONG_526`. - - Agent long session printed `MATRIX_CURSOR_AGENT_AUTO_LONG_526`. - - Ask session printed `MATRIX_CURSOR_ASK_AUTO_CLI_526`. - - Agent resumed after app reopen printed - `MATRIX_CURSOR_AGENT_RESUME_AFTER_REOPEN_526`. - - Patched direct resume smoke printed - `MATRIX_CURSOR_AGENT_PATCHED_DIRECT_RESUME_526`. -- Claude: - - Default, Plan, Accept/Edit rows exist and have markers around - `MATRIX_CLAUDE_*_HAIKU_526`. - - Need verify exact process cleanup and close/reopen behavior with current - patch state. -- Codex: - - Default/Plan/Edit rows exist and have markers around - `MATRIX_CODEX_*_MED_526` and `MATRIX_CODEX_RESUME_PATCHED_UI_526`. - - Codex often showed `linear` MCP startup incomplete. Treat that as a real - runtime startup warning, not a blocker to Codex CLI itself unless the tested - task needs Linear. -- OpenCode: - - Edit/Plan rows exist and have markers around - `MATRIX_OPENCODE_*_BIGPICKLE_526`. - - One OpenCode Plan row preview still shows Kitty graphics payload text: - `Gi=31337,s=1,v=1,a=q,t=d,f=24;AAAA`. This needs follow-up. It may be an - ANSI/terminal-preview filtering issue or an old row from before a preview - fix. -- Droid: - - Not completed. - - Droid CLI exists at `/Users/arul/.local/bin/droid`. - - Must check auth/subscription through the Work tab or harmless local CLI - probes before claiming coverage. - - Fresh launch code has just been changed to interactive Droid but is not - validated. - -The matrix needs to be converted from "visible rows exist" into proof: - -- For each runtime/mode, query the DB row for `resume_command`, - `resume_metadata_json`, `status`, `pty_id`, `ended_at`, and transcript path. -- Check transcript markers. -- Check process tree before/after stop/app quit. -- Use UI close/reopen and in-place resume where still missing. - -### Performance/resource findings so far - -The clearest measured resource issue is provider-side process fan-out, especially -Cursor: - -- A single live Cursor session can spawn: - - `cursor-agent` - - `uv tool uvx awslabs.aws-iac-mcp-server@latest` - - `uv tool uvx awslabs.aws-pricing-mcp-server@latest` - - Python children for those MCP servers. -- Two Cursor sessions produced multiple AWS MCP sidecar trees. -- Earlier samples had `cursor-agent` around hundreds of MB RSS and Python MCP - sidecars with non-trivial RSS. The latest post-wait sample had smaller Cursor - RSS but sidecars still present. -- `cursor-agent mcp list` only showed `posthog`; the AWS sidecars appear to be - coming from Cursor/plugin/runtime configuration outside ADE's direct MCP - list. Do not disable them with invented flags. Find official Cursor-supported - config if continuing. - -ADE-side issues fixed/started: - -- Resume relaunch no longer uses the user's interactive zsh startup path. -- Droid fresh launch is being aligned to interactive sessions so it can stay up - instead of exiting like an automation command. - -Still needed for performance: - -- Measure renderer/main churn, not just process RSS. -- Inspect terminal preview/title extraction for hidden or old rows, especially - the OpenCode Kitty payload preview. -- Confirm stop/delete/app-close cleanup leaves no provider descendants for each - runtime. - -### Commands and queries that were useful - -Socket/process checks: - -```bash -lsof -nU | rg '/tmp/ade-runtime-(app-control-fixes-d55c0422|ui-clean-up-3470f34e)\\.sock|COMMAND' -ps -axo pid,ppid,pgid,rss,command | rg 'app-control-fixes-d55c0422|cursor-agent|awslabs|opencode|claude|codex|droid' -``` - -DB row check for the Cursor resume smoke: - -```bash -sqlite3 /Users/arul/ADE/.ade/ade.db \ -"select id,title,status,pty_id,owner_pid,owner_process_started_at,resume_command,substr(resume_metadata_json,1,300),transcript_path from terminal_sessions where id='b491b60d-5e97-4e8c-8aca-70f652f5f5c8';" -``` - -Transcript checks: - -```bash -tail -n 160 /Users/arul/ADE/.ade/transcripts/b491b60d-5e97-4e8c-8aca-70f652f5f5c8.log -rg -n 'asdf\\.bash|command not found: complete|MATRIX_CURSOR_AGENT_PATCHED_DIRECT_RESUME_526|cursor-agent --model auto --resume' \ - /Users/arul/ADE/.ade/transcripts/b491b60d-5e97-4e8c-8aca-70f652f5f5c8.log -``` - -Official/help probes: - -```bash -codex --help -codex resume --help -claude --help -cursor-agent --help -cursor-agent models -opencode --help -opencode run --help -droid --help -droid exec --help -``` - -### Immediate next steps when resuming - -1. Reconfirm current working tree and do not revert unrelated dirty files. -2. Check no smoke provider process is still running from this handoff: - ```bash - ps -axo pid,ppid,pgid,rss,command | rg 'cursor-agent|opencode|claude|codex|droid|awslabs|cli-perf-resource-smoke-20260526' - ``` -3. If the dev app is not running, relaunch with this lane socket. Do not touch - `/tmp/ade-runtime-ui-clean-up-3470f34e.sock`. -4. Run the focused tests for the unvalidated Droid/CLI launch changes: - ```bash - PATH=$HOME/.asdf/installs/nodejs/22.13.1/bin:$PATH \ - npm --prefix apps/desktop run test -- --run \ - src/renderer/components/terminals/cliLaunch.test.ts \ - src/main/utils/terminalSessionSignals.test.ts \ - -t "Droid|droid|resume-time model" - ``` -5. Rerun the PTY focused test if `ptyService.ts` changes further: - ```bash - PATH=$HOME/.asdf/installs/nodejs/22.13.1/bin:$PATH \ - npm --prefix apps/desktop run test -- --run \ - src/main/services/pty/ptyService.test.ts \ - -t "sendToSession|reattach" - ``` -6. Use Computer Use to continue the Work-tab matrix. Do not substitute a direct - CLI run for the Work-tab launch. -7. Finish stop/delete/app-close/reopen proof by runtime. -8. Fix remaining confirmed bugs: - - OpenCode Kitty graphics payload in previews if reproducible on fresh rows. - - Any Droid launch/auth/resume issue exposed by Work-tab smoke. - - Any stale green/deletion guard issue still present after app close/reopen. -9. Only after proof is complete, run broader validation from AGENTS.md as - appropriate. - -### Current cleanup note - -At handoff time the latest Cursor patched resume had already printed its marker -and returned to "Add a follow-up" in the Work tab. A final `ps` check should be -done on resume anyway because Cursor sidecars can outlive the obvious row if a -cleanup path regressed. - -Final stop-state check after this handoff: - -- The dev Electron/Vite/tsup processes launched from this lane were stopped with - `Ctrl-C`; the dev launcher ran `app.process_cleanup_now`. -- The lane runtime socket remained up and was not killed: - `/tmp/ade-runtime-app-control-fixes-d55c0422.sock`. -- The other active lane socket was only inspected and was not touched: - `/tmp/ade-runtime-ui-clean-up-3470f34e.sock`. -- A process check after stopping the dev app still showed provider/runtime - descendants unrelated to the dev Electron process, including AWS MCP sidecars, - Claude/Codex sessions, and a Droid `droid exec` process from earlier matrix - work. Treat these as part of the remaining cleanup/audit work: identify which - terminal session owns each process from ADE's DB/transcripts, stop them - through the Work tab where possible, and only kill manually after confirming - ownership. - -## Handoff update: 2026-05-26 16:45 EDT - -User asked to stop, update this file, push, and report. - -Latest work completed after the prior handoff: - -- Validated the previously untested Droid launch patch against the installed - Droid CLI help: - - `droid --help` says bare `droid` starts interactive mode and an inline - prompt starts the same interactive CLI with initial context. - - `droid exec --help` says `exec` is non-interactive and exposes - `--auto low|medium|high`, `--model`, `--reasoning-effort`, - `--spec-model`, and `--spec-reasoning-effort`. -- Confirmed the generated Work-tab Droid command now uses interactive Droid via - `droid --settings "$ADE_DROID_SETTINGS" ""`, not `droid exec`. -- Found and fixed a real resume-metadata bug in - `apps/desktop/src/main/utils/terminalSessionSignals.ts`: - - After the interactive Droid settings-file patch, Droid launch metadata - lived inside the generated `printf %s > "$ADE_DROID_SETTINGS"` - command. - - `parseTrackedCliLaunchConfig(...)` was still looking only for plain CLI - flags or unescaped JSON fragments. - - Result before fix: a generated Droid edit launch parsed as only - `{ permissionMode: "plan" }`, losing model, reasoning effort, and autonomy. - - Result after fix: the same generated launch parses as - `{ permissionMode: "edit", model: "claude-sonnet-4-6", reasoningEffort: - "high" }`. -- Updated focused tests: - - `apps/desktop/src/main/utils/terminalSessionSignals.test.ts` now covers - generated Droid settings JSON for edit/auto and plan/spec settings. - - `apps/desktop/src/renderer/components/terminals/cliLaunch.test.ts` now - asserts the shell-escaped JSON format produced by `quoteShellArg(...)`. - -Validation run after this patch: - -```bash -PATH=$HOME/.asdf/installs/nodejs/22.13.1/bin:$PATH \ -npm --prefix apps/desktop run test -- --run \ - src/renderer/components/terminals/cliLaunch.test.ts \ - src/main/utils/terminalSessionSignals.test.ts -``` - -Result: - -- Passed. -- `2 passed`, `70 passed`. - -Runtime/UI state when stopped: - -- Dev Electron/Vite/tsup was not running after the user interruption. -- I had restarted this lane's runtime socket during the aborted dev launch - because the launcher detected a build-hash change: - `/tmp/ade-runtime-app-control-fixes-d55c0422.sock`. -- This lane runtime should be stopped for this handoff because the user asked - to stop. -- The other lane socket remains off-limits: - `/tmp/ade-runtime-ui-clean-up-3470f34e.sock`. -- The Work-tab Computer Use smoke for Droid was not completed. The app state - was inspected and the Work tab was visible, but no new Droid session was - launched before the user stopped the run. - -Files from this latest stop-point that should be committed/pushed as one -focused follow-up: - -- `apps/desktop/src/shared/cliLaunch.ts` -- `apps/desktop/src/main/utils/terminalSessionSignals.ts` -- `apps/desktop/src/main/utils/terminalSessionSignals.test.ts` -- `apps/desktop/src/renderer/components/terminals/cliLaunch.test.ts` -- `codexGoal.md` - -Remaining high-priority work: - -1. Resume through Codex Computer Use from the Work tab and run an actual Droid - UI smoke if Droid auth/subscription permits. -2. Finish the runtime matrix and app close/reopen proof for Codex, Claude, - Cursor, OpenCode, and Droid if available. -3. Clean or account for old smoke sessions and provider sidecars through the - Work tab, then verify no stale provider descendants remain. -4. Investigate the OpenCode Kitty graphics payload still visible in one old - Work sidebar preview. -5. Continue performance/resource measurement from real Work-tab UI evidence. +# ADE `ade code` TUI — finish Phase 3, build Phase 4 + 5 + +## Kickoff prompt (paste this to the agent) + +> You're continuing the ADE TUI parity pass on branch `ade/tui-parity-pass` in +> this worktree. The TUI is the Ink/React terminal client at +> `apps/ade-cli/src/tuiClient/` (`ade code`). Implement, in order: the **remaining +> Phase 3** items, then **all of Phase 4**, then **all of Phase 5**, exactly as +> specified in this file (`codexGoal.md`). Work in small, committed, tested +> increments — after each task run `npx tsc -p tsconfig.json --noEmit` and the +> relevant `npx vitest run ` from `apps/ade-cli/`, and add a focused unit +> test for any new pure helper. Do NOT regress the 842 passing tests. `app.tsx` +> is a ~10k-line monolith touched by most tasks: edit it sequentially (no +> parallel agents writing it concurrently); use read-only agents only for +> investigation. Follow the existing patterns described below. Build with +> `npm run build` in `apps/ade-cli` before declaring a task done. The user runs +> the live TUI for validation — keep each increment shippable and eyeball-able. + +--- + +## Context & ground rules + +**Where:** `apps/ade-cli/src/tuiClient/` — `app.tsx` (the monolith: state, input +handling, command dispatch, render), plus `components/` (ChatView, RightPane, +Drawer, MultiChatGrid, ModelPicker, FooterControls, ApprovalPrompt, Header, +SlashPalette, MentionPalette), and helpers (`adeApi.ts`, `connection.ts`, +`jsonRpcClient.ts`, `hitTestRegistry.ts`, `theme.ts`, `format.ts`, +`aggregate.ts`, `spinTick.tsx`, `commands.ts`, `types.ts`). It speaks ADE +JSON-RPC to the runtime daemon and shares types/logic with the desktop under +`apps/desktop/src/shared/`. The desktop renderer (`apps/desktop/src/renderer/`) +is the design reference — match its semantics, not its layout (this is a +width-constrained terminal: Ink `Box`/`Text` only, colors from `theme.ts`). + +**Build / test (from `apps/ade-cli/`):** +- Typecheck: `npx tsc -p tsconfig.json --noEmit` +- Scoped tests: `npx vitest run src/tuiClient/__tests__/` +- Full TUI-ish suite: `npx vitest run` +- Bundle (must pass before "done"): `npm run build` + +**Live test (the user does this; you can smoke it):** rebuild, then +`ADE_DEFAULT_ROLE=cto ADE_HOME=/Users/admin/.ade-tui-parity node apps/ade-cli/dist/cli.cjs runtime start` +and `ADE_HOME=/Users/admin/.ade-tui-parity node apps/ade-cli/dist/cli.cjs --socket code` +(a dedicated isolated daemon; rebuilding changes the build hash so restart the +daemon after each build). Shut down with `runtime stop`. + +**Conventions / patterns already in place — reuse, don't reinvent:** +- **Theme:** all colors via `theme.ts` tokens (`theme.color.*`, `theme.provider(family)`, + `theme.lane(lane)`, `theme.rail`). Brand violet `#A78BFA` is the accent; + selected/focused = violet, neutral borders = `theme.color.border`. No raw hex, + no green for "healthy/idle" chrome (green reads as a glitch — reserve it for + the running spinner only). +- **Hit-test / mouse:** `hitTestRegistry.ts` — `useHitTestTarget({id, rect, onClick, zIndex})` + returns an `isHovered` boolean; the move handler in `app.tsx` (`hoverTest`, + grep `hoverTest`) sets `hoveredHitId` which flows via `HitTestProvider`. Mouse + parsing: `parseTerminalMouseInput` (SGR/rxvt/X10), `decodeMouseButton`. Many + app-level targets are registered in the render pass in `app.tsx` (grep + `addFooterInlineTarget`, `appHitTargetIdsRef`, `registry.register`). +- **Streaming:** chat events are coalesced (`flushPendingChatEvents` / + `scheduleChatFlush` / `CHAT_EVENT_FLUSH_MS`); a single shared `displayBlocks` + (`aggregateChatBlocks`) is threaded into ChatView + the `render*`/`compute*` + helpers. Don't add per-token work or new full-transcript walks. +- **Grid:** `multiView` ("grid exists") is decoupled from `gridViewActive` + ("grid shown") via `setGridView(active)` + `gridViewActiveRef`. Submit/scroll/ + selection routing and the grid sync effect already gate on `gridViewActive`. +- **Footer inline cells:** the cell order is a single source of truth — + `inlineRowCellOrder({providerLocked, fastSupported, reasoningSupported, subagentsVisible})` + (exported from `app.tsx`). Keyboard nav, mouse down-cycle, and hit-tests all + derive from it. Add new cells there. +- **Prompt input:** `applyCoalescedPromptInput` segments coalesced chunks + (Ink merges fast keystrokes). Reuse the prompt helpers (`insertPromptText`, + `deletePromptBackward`, etc.). +- **Right pane:** `RightPaneContent` is a discriminated union in `types.ts`; + `RightPane.tsx` renders each `kind`. Forms use the `{kind:"form", command, fields}` + shape; submit handled in `app.tsx` (grep `form.command ===`). +- Line numbers in this file are approximate (the monolith shifts) — **grep for + the named symbol** to find the current site. + +**Out of scope / non-goals (do not build):** 2D React-Flow graph canvas, +multi-project tabs, Monaco-grade editing, full structured automation-rule editor. + +--- + +# PHASE 3 — remaining runtime UX + model picker + +Already done (do not redo): model-picker glyph/color unification + `⌕` search + +shortId/alias search; Codex `custom` preset cycle fix; footer fast/reasoning +reachability via `inlineRowCellOrder`; shimmer working indicator. + +## 3.1 — Codex approval × sandbox readout (S) +**Goal:** When provider is `codex`, show the resolved approval policy × sandbox +pair in the footer so it's legible even when the preset word is `custom`/`config-toml`. +**Files:** `app.tsx` (`resolveCodexPreset`, `permissionSummary`, `permissionOptionsDetail`), +`components/FooterControls.tsx`. +**Approach:** Add a pure helper `codexApprovalSandboxLabel(modelState)` near +`resolveCodexPreset` returning e.g. `"on-request · workspace-write"` from +`modelState.codexApprovalPolicy` / `codexSandbox`. Pass a `permissionDetail?: string|null` +prop to `FooterControls` and render it dim immediately after the permission cell +(only when provider === codex). Keep the headline preset word as-is. +**Acceptance:** Codex footer shows the approval/sandbox pair; switching presets +updates it; non-codex providers unaffected; unit-test the label helper. + +## 3.2 — Cursor modes from the runtime snapshot (M) +**Goal:** Cursor permission cycling should use the session's actual available +modes, not the static `CURSOR_AVAILABLE_MODE_IDS`. +**Files:** `types.ts` (`AdeCodeModelState`), `app.tsx` (model-state normalize/ +restore sites — grep `cursorModeId`, `cursorModeSnapshot`; `cyclePermission` +cursor branch; `permissionOptionsDetail` cursor branch), shared type +`AgentChatCursorModeSnapshot` in `apps/desktop/src/shared/types/chat.ts`. +**Approach:** Add `cursorAvailableModeIds: string[]` to `AdeCodeModelState` +(default `[]`); populate it from `configSession.cursorModeSnapshot?.availableModeIds` +everywhere `cursorModeId` is set from a snapshot. Add a resolver +`cursorModeIdsForState(modelState)` = snapshot ids when non-empty else the static +fallback (mirror desktop `AgentChatComposer` behavior). Use it in the +`cyclePermission` cursor branch and `permissionOptionsDetail`. `cursorModeLabel` +already handles unknown ids. +**Acceptance:** With a Cursor session whose snapshot lists a subset of modes, +cycling only visits those modes; with no snapshot, the static list is used. + +## 3.3 — Plan-approval card (M) +**Goal:** Render plan-mode / approval requests as a one-key approve/reject card +instead of forcing the typed high-stakes path. +**Files:** `pendingInput.ts` (the request → `PendingApproval` mapping), +`components/ApprovalPrompt.tsx`, `app.tsx` (the pending-approval render + the +approval resolution path — grep `pendingApproval`, `resolvePendingApproval`). +Also handle an orchestration `model_selection` request kind if present. +**Approach:** Detect plan-approval / model-selection request kinds in +`pendingInput.ts` and surface them as a structured `ApprovalPrompt` with labeled +choices; wire keys (e.g. `y`/`n` or numbered) + clickable footer buttons (reuse +the existing approval footer items pattern). Don't break the existing high-stakes +modal path. +**Acceptance:** A plan/approval request shows a readable card with one-key +accept/reject; resolving sends the right response. + +## 3.4 — Structural model-picker unification (L) +**Goal:** One picker for both `/model` and the new-chat flow. Retire the duplicate +inline `model-setup` / `new-chat-setup` rows as the *model* surface; fold +Permissions / Fast / Output-style into a slim settings strip inside +`ModelPickerPane`; add auth dots + sign-in hints + per-row reasoning chips. +**Files:** `components/ModelPicker/ModelPickerPane.tsx` (presentation), +`components/ModelPicker/modelPickerLayout.ts` (+ `types.ts` in that dir), +`tuiClient/types.ts` (`ModelPickerRightPaneContent`, maybe retire `model-setup`), +`components/RightPane.tsx` (the `model-setup`/`new-chat-setup` block + `modelPickerInputs`), +`app.tsx` (`openModelRow`, `modelSetupRows`/`modelPickerRows`, `openNewChatSetup`, +`commitModelPickerSelection`, the setup-row keyboard branch, the `aiStatus` +threading). Desktop reference: `apps/desktop/src/renderer/components/.../ModelPicker/` +(`ModelListRow.tsx`, `ModelPickerRail.tsx`) and `useProviderAuthStatus.ts` +(`familiesFromStatus`). +**Approach (sequence — ship pieces independently):** +1. **Reasoning chip** on the focused/active row (port `REASONING_LABELS`); cycle + via the existing `modelPicker:increaseEffort`/`decreaseEffort` actions. +2. **Auth dots + sign-in hint:** port pure `familiesFromStatus` into + `modelPickerLayout.ts`; thread `aiStatus` through `modelPickerInputs`; render a + 1-cell red/amber dot after each rail glyph and a `Sign in: /login ` + hint when the active rail provider is unauthed. +3. **"Show all models" toggle** (desktop `authOnly`): add `showAll` to the picker + state + an `authOnly` filter in `buildModelPickerLayout`; bind a key + hit-test. +4. **Settings strip + retire duplicate rows (the big one):** render Permissions/ + Fast/Output-style as a compact focusable strip at the bottom of `ModelPickerPane` + driven by the existing `buildSetupRows` (`SetupPaneRow`); extend the picker + state with `footerFocus?: SetupPaneRowKind`; Tab/arrows cycle into the strip and + reuse the existing `handleSetupRow`. Repoint `/effort` and `openNewChatSetup` + to open the unified picker; delete `openModelRow`, the `model-setup` kind, and + the inline `model-setup`/`new-chat-setup` rendering block once their rows feed + the strip. New-chat-only affordances (lane label, "prompt now"/background + dispatch, Apply) survive as picker header/footer actions. +**Cautions:** the picker re-renders on every keystroke (keep it pure/cheap; no +per-row IPC — precompute auth status and pass it in). Width-degrade all chips/dots +via the existing `endTruncate`/`innerWidth` budget. +**Acceptance:** `/model` and new-chat show the same picker; reasoning chip + auth +dots + show-all work; permissions/fast/output-style are editable inside the picker; +the old inline setup rows are gone; tests for the layout function extended. + +--- + +# PHASE 4 — full mouse control + global navigation + +The hover pipeline is wired (`hoverTest` fires on move, `hoveredHitId` flows via +`HitTestProvider`, `useHitTestTarget` returns is-hovered) but **only `MultiChatGrid` +consumes it**. Make every interactive surface mouse-driven, with hover affordances. + +## 4.1 — Universal hover (L) +**Goal:** Hovering any clickable row tints it. Consume `hoveredId` in `Drawer` +(lane/chat rows), `FooterControls` (cells/buttons), `ModelPicker` (rail + rows), +`RightPane` (list/diff/file rows, form fields), `ApprovalPrompt`. +**Files:** the component files above + `app.tsx` (where their hit-test targets are +registered — grep `registry.register`, `appHitTargetIdsRef`; many rows are +registered centrally in the render pass). +**Approach:** For each clickable region that already registers a hit-test target, +pass the hovered state down (or have the row call `useHitTestTarget` with its id + +rect) and tint on match (e.g. `theme.color.borderActive` background or violet +text). The move handler already re-renders on hover change, so this is mostly +plumbing. Keep the registration the single source (don't double-register). +**Acceptance:** moving the mouse over drawer lanes/chats, footer cells, model rows, +right-pane rows highlights the row under the cursor; clicking still works. + +## 4.2 — Wheel routed to the pane under the cursor (M) +**Goal:** The wheel scrolls whatever pane the pointer is over, not only the center +transcript. (Grid tiles already scroll-under-cursor — keep that.) +**Files:** `app.tsx` (wheel handler — grep `mouse.kind === "wheel"`), `RightPane.tsx`. +**Approach:** Add scroll-offset state for the right pane (copy ChatView's +`sliceRows`/`maxScrollOffsetForRows`/`scrollOffsetRows` machinery) so `/diff` and +detail/list panes become scrollable instead of truncating. In the wheel handler, +dispatch by pointer region: drawer → drawer scroll; right pane → right-pane offset; +center → existing transcript/tile logic. +**Acceptance:** wheel over the drawer, right pane, and center each scroll the +correct region; long `/diff` and detail panes scroll. + +## 4.3 — Clickable chat links (M) +**Goal:** URLs in chat are openable (OSC-8 + click). +**Files:** `format.ts` (link runs — grep `link`, `LINK_COLOR`; the href is +currently dropped, see the comment "doesn't render hyperlinks distinctly today"), +`components/ChatView.tsx` (`InlineSpans` link branch), `app.tsx` +(`openExternal`/external-open path — grep the PR-url open). +**Approach:** Carry the href on the link `InlineRun`; emit an OSC-8 hyperlink +escape around the visible text; register a hit target over the link rect that +calls the existing external-open helper. Verify the OSC-8 sequence is width-0 (no +layout shift). +**Acceptance:** a URL in an assistant message is underlined, OSC-8 clickable in +supporting terminals, and a mouse click opens it. + +## 4.4 — Ctrl+K command / lane / chat palette (L) +**Goal:** A global fuzzy palette to jump to lanes, chats, and commands (like the +desktop `CommandPalette` / Claude Code's `/`-less quick switch). +**Files:** new overlay component (model it on `components/SlashPalette.tsx`), +`keybindings/index.ts` (add `app:openCommandPalette`), `app.tsx` (state + render + +key handling), `commands.ts` (reuse `paletteCommands`). +**Approach:** Ctrl+K opens an overlay listing: built-in + user slash commands, +lanes (jump/switch), and chats (jump/switch). Fuzzy filter as you type (reuse the +slash/mention palette filtering); ↑↓ + mouse hover to select; Enter runs/jumps; +Esc closes. Selecting a lane/chat routes through `applyDrawerChatSelection` +(so grid re-entry works); selecting a command dispatches via the existing +command runner. +**Acceptance:** Ctrl+K opens; typing filters across commands/lanes/chats; Enter +jumps or runs; mouse hover + click work; Esc closes; no conflict with Ctrl+R +(history) or other bindings. + +## 4.5 — `[` / `]` lane cycling + `/switch` restores last chat + reverse pane cycle (S) +**Files:** `app.tsx` (grep `cycleScope`/`[`/`]` currently bound only in the model +picker; `/switch` handler ~grep `"/switch"`; `tabs:previous`). +**Approach:** Bind `[`/`]` (when not in a text field/palette) to cycle the active +lane prev/next. Make `/switch ` restore that lane's last-active chat +(`lastChatByLaneRef`). Fix `tabs:previous` aliasing forward (make it reverse). +**Acceptance:** `[`/`]` move between lanes; `/switch` lands on the last chat; +reverse pane-cycle goes backward. + +--- + +# PHASE 5 — chat management completeness + +## 5.1 — Delete / archive / unarchive chat (L) +**Goal:** Manage chat sessions from the TUI (the runtime supports it; the TUI has +no wrappers and never filters archived chats). +**Files:** `adeApi.ts` (add `deleteSession`/`archiveSession`/`unarchiveSession` +wrappers — confirm the exact action names via the runtime action registry, +`apps/desktop/src/main/services/adeActions/registry.ts` chat/session domain), +`app.tsx` (session list — **filter out `session.archivedAt`**; add drawer chat-row +actions + `/chat …` commands + a confirm gate), `commands.ts` (add the commands), +`components/Drawer.tsx` (a click-× / hotkey on chat rows), `types.ts` if a form is +needed. +**Approach:** Mirror the lane-management pattern already in place +(`/lane archive|unarchive|delete` + drawer hotkeys r/a/x + delete-risk preflight): +add `/chat rename|archive|unarchive|delete` (or reuse `/rename` for chat title), +drawer hotkeys on the selected chat row, and a confirm for delete. Filter +`!session.archivedAt` from the displayed session list (grep where sessions are +listed/filtered) so externally-archived chats stop polluting the drawer/grid; +add an "archived chats" listing. +**Acceptance:** can delete/archive/unarchive a chat from the drawer + slash +commands; archived chats are hidden from the normal list and listable on demand; +delete is confirmed. + +## 5.2 — Browse / search chats (M) +**Goal:** `/chats` is filterable; `/switch` resolves chats (not just lanes); +Ctrl+R recalls. +**Files:** `app.tsx` (`/chats`, `/switch`, Ctrl+R history-search — grep +`"/chats"`, `"/switch"`, `historySearch`, `cycleScope` (remove dead code)). +**Approach:** Make `/chats` list the active lane's chats with a filter; make +`/switch` accept a chat reference and resolve it via `applyDrawerChatSelection` +(grid re-entry aware); make Ctrl+R recall prompt history (fix the path that +currently can't recall) and remove the dead `cycleScope`. +**Acceptance:** `/chats` filters; `/switch ` switches chats; Ctrl+R recalls +prior prompts. + +## 5.3 — Session legibility: tag + completion + status glyphs (M) +**Goal:** Surface session tag, completion, and a colored wait/running glyph in the +drawer/grid so state reads at a glance. +**Files:** `format.ts` (tag rendering — grep `tag`, currently invisible), +`components/Drawer.tsx`, `chatInfo.ts`. +**Approach:** Render the session tag where chats are listed; add per-chat status +glyphs (running spinner / amber awaiting / dim ended) consistent with the grid +tile glyphs already added (`ChatView` tile header). Bucket by status/time if +useful. +**Acceptance:** tagged chats show their tag; chat rows show a clear status glyph. + +## 5.4 — `/context` visual breakdown + relax the Claude gate (M) +**Goal:** `/context` shows a visual token/context breakdown and works wherever the +runtime supports it (not Claude-only, text-only). +**Files:** `app.tsx` (`/context` handler — grep `"/context"`, `getContextUsage`), +`components/RightPane.tsx`, reuse the `TokenBar` from `FooterControls.tsx`. +**Approach:** Render context usage as a visual breakdown (a `TokenBar`-style bar + +per-bucket lines) in the right pane; relax the `provider === "claude"` gate where +the runtime returns usage for other providers. +**Acceptance:** `/context` shows a visual breakdown; works for supported non-Claude +providers; degrades gracefully when unavailable. + +--- + +## Definition of done (each task) +1. Typecheck clean (`tsc --noEmit`). +2. Relevant scoped vitest green + a new unit test for any pure helper added. +3. Full `npx vitest run` green (currently 842 tests — don't regress). +4. `npm run build` succeeds (verifies the bundled CLI). +5. TUI-appropriate (Ink Box/Text, theme tokens, width-degrades) and consistent + with the patterns above. Commit per task with a clear message. diff --git a/plans/tui-parity-roadmap.md b/plans/tui-parity-roadmap.md new file mode 100644 index 000000000..77548dbd2 --- /dev/null +++ b/plans/tui-parity-roadmap.md @@ -0,0 +1,4396 @@ +# ADE TUI — Parity & Polish Roadmap + +_Generated by the `tui-parity-audit` workflow. 19 domains audited (19 planned)._ + +## Implementation status + +- **Phase 1 — Robustness + streaming-perf foundation: ✅ DONE** (branch `ade/tui-parity-pass`). + - ChatView: `React.memo` on `ChatRow`/`InlineSpans`; transcript split at the first live block so the 100ms spinner tick only rebuilds trailing live blocks, not the whole history (`ChatView.tsx`). + - Streaming coalescing queue: token events buffer and flush in one batched render (~40fps) instead of one render per token; lifecycle edges (status/done/user_message/error/subagent) force-flush (`app.tsx`, `CHAT_EVENT_FLUSH_MS`). + - Per-session dedup batched per-flush; active-session transcript uses incremental reserve/append batched into a single setState. + - Single shared `aggregateChatBlocks` hoisted in `app.tsx` (`displayBlocks`) and threaded into ChatView + the scroll/selection/selectable helpers (was ~4 full-transcript walks per render). + - Reconnect: `JsonRpcClient.onClose` → `AdeCodeConnection.onConnectionClose` → `app.tsx` detects attached-socket drop, clears streaming, shows a "reconnecting…" notice, and the probe re-attaches (no more frozen UI). + - Conflict-aware `/pull` and `/reparent`: post-op `git.getConflictState` check surfaces conflicted files instead of a false "Pull complete"; `/pull --continue` / `--abort` resolve via `rebase*`/`merge*` keyed on conflict kind. + - Verified: typecheck clean, full ade-cli suite (829 tests, +4 new), production bundle builds. **Not yet exercised against a live streaming runtime** — recommend a manual run. + - **Code review pass applied** (7-angle review): fixed a dropped-streaming-token bug (spurious `refreshState` dep + buffer discarded on effect re-bind → now reads `clearedAt`/`refreshState` via refs, re-binds only on connection change), a `/clear` re-add race (flush re-applies the `clearedAt` filter), a boundary-spacer off-by-one (keyed off block kinds), an ineffective `React.memo` (historical rows pre-indexed so `sliceRows` reuses identities), and `isChatFlushEdge` over-matching `subagent_progress`. +- **Phase 2 — Grid + lane management: ✅ DONE.** + - Grid: ✅ Shift+Tab reverse focus (was cycling permission); ✅ geometry remainder so tiles fill the pane (no dead margin); ✅ per-tile lane-color border + rail + multi-state status glyph (animated spinner/awaiting/ended); ✅ wheel scrolls the tile under the cursor; ✅ click-to-focus + click-× (already via hit-test registry). + - Lane identity + management: ✅ lane-color rail in the Drawer `LaneCard`; ✅ `/lane rename` (inline or form, fixes the chat-only `/rename` trap); ✅ `/lane archive` + `/lane unarchive ` + `/lane archived` listing (`listLanes({includeArchived})`); ✅ delete-risk preflight (`getDeleteRisk` summary in the delete form); ✅ per-lane Drawer hotkeys (`r` rename, `a` archive, `x` delete on the selected card). + - Verified: typecheck clean, full ade-cli suite (833 tests, +3 new for `formatLaneDeleteRisk` and `/lane` command parsing), bundle builds. + - **Pending (smaller, deferred):** streamed delete teardown (lane-delete progress events), spatial 2D arrow tile nav, lane color/icon editing. +- **Live-feedback batch (from a real `ade code` run): ✅ DONE & committed.** + - Grid tiles: neutral borders, only the focused tile violet; lane color lives inside (rail/title), not the border; minimap focused tile violet. + - Footer context bar: removed the green `<50%` tier (it read as a glitch) → violet/amber/red. + - Backspace: segment coalesced input chunks so fast type+backspace bursts apply the delete (Ink only flags a lone DEL/BS) — `applyCoalescedPromptInput`. + - **Grid/new-chat redesign:** decoupled "grid exists" (`multiView`) from "grid shown" (`gridViewActive`). New chat leaves grid into a single chat but keeps the grid resumable; navigating to a tile re-enters it; `^g`/footer adds the current chat (errors if full) or resumes; submit/scroll/selection routing + the grid sync effect respect `gridViewActive` (fixes new-chat messages landing in the focused tile). +- **Phase 3 — runtime UX + model picker: ✅ DONE** (Codex-built, reviewed + fixed here). + - ✅ Structural model-picker unification (single picker; permission/fast/output-style folded into the footer; auth dots + sign-in hints + reasoning chips), Codex approval/sandbox readout, Cursor modes from the runtime `availableModeIds` snapshot, plan-approval card. + - ✅ Earlier: canonical brand glyphs/colors, de-duplicated heading, `⌕` search on shortId + aliases; Codex `custom` approval×sandbox no longer discarded; shared `inlineRowCellOrder` makes fast/reasoning mouse+keyboard reachable exactly when supported. +- **Phase 4 — full mouse control + global nav: ✅ DONE** (Codex-built, reviewed + fixed here). Hover/hit-test sweep across surfaces; Ctrl/Cmd+K command palette (`CommandPalette.tsx`) with command/lane/chat routing. +- **Phase 5 — chat management completeness: ✅ DONE** (Codex-built, reviewed + fixed here). +- **Phase 6 — PR management + colorized scrollable diffs: ✅ DONE.** + - ✅ Colorized, fully-scrollable `/diff` right pane: per-file headers (`▸ path +N −N`, counts neutral to match ChatView) + hunk lines tinted by kind (add=green, del=red, hunk=violet, meta/context=dim) via a pure `diffLineKind` classifier; removed the 8-line/file cap, flows the whole diff through the scroll window (bounded per file at 600 lines). + - ✅ PR mutations: `/pr land` (two-step confirm before an irreversible merge, method merge|squash|rebase), `/pr comment`, `/pr approve`, `/pr request-changes` — registered in `commands.ts`. +- **Phase 7 — visual/motion delight: ✅ DONE.** ✅ Shimmer on the "model working…" label (`useShimmerTick`); ✅ AdeWordmark vertical brand gradient (pure, zero idle cost); ✅ TokenBar fill easing + danger pulse, gated entirely on the activity tick (static when idle, no timers). *Deliberately skipped the literal time-based fade-in completion glyph: the spin tick stops at idle, so a post-completion fade would freeze — it would add render churn for no reliable benefit, against the "don't break perf" principle.* +- **Phase 8: not started.** Terminals, settings, automations, orchestration, usage/onboarding (L–XL). +- **Recommended next:** run the current build against the live runtime to eyeball Phases 3–7 end-to-end (model-picker unification, mouse/palette, colorized diffs, PR mutations, wordmark/TokenBar motion), then decide on Phase 8. + +## ADE TUI Parity & Polish — Executive Layer + +The TUI already nails the core agent loop: it launches and drives all five runtimes (plus ollama/lmstudio), renders multi-session grids, handles steer/interrupt, and matches desktop on message quality and the Claude-compat status line. Where it falls short is everything *around* that loop — lane lifecycle (rename/color/archive/delete-with-confidence/merge), PR management beyond "create and open", file/diff inspection, terminals, settings, and automations are partial or entirely absent, and several surfaces are actively broken (frozen UI on socket drop, false "Pull complete" on conflict, Shift+Tab changing permissions instead of focus, fast mode unreachable by mouse/keyboard). The deepest structural debt is performance: the render pipeline does zero event coalescing (one full React render per token), walks the entire transcript ~4× per token, and rebuilds every row 10×/sec on the spinner tick — which makes the client feel janky precisely when it's working hardest. Visually the foundation is strong (violet brand palette, wordmark, BootHero, three spinner families) but motion and color movement are missing, and user-assigned lane colors are invisible in the very Drawer where lanes are distinguished. The right framing is companion, not replacement: decline the 2D graph canvas, multi-project tabs, Monaco-grade editing, and full automation-rule editing as documented non-goals, and instead make every high-frequency flow legible, responsive, and pointer-driven in the terminal. Getting there is roughly a sequenced 8-phase effort, front-loaded on robustness + streaming perf because all the motion/visual delight work compounds on top of that foundation. + +### Top 10 highest-leverage changes + +1. **Coalesce streaming setState into a ~16–33ms per-frame queue, force-flush on lifecycle edges** (`app.tsx:5084`) — collapses N renders/sec to ~30/sec; single biggest smoothness win — **L** +2. **Hoist one shared `aggregateChatBlocks` memo and thread blocks to all four consumers** (`app.tsx:3249/3272/3297`, `ChatView.tsx:1540`) — kills 3 of 4 redundant full-transcript walks per token — **M** +3. **Reconnect state machine + mid-turn streaming reset** (`jsonRpcClient.ts:30`, `connection.ts`, `types.ts`, `app.tsx:5287`) — no more silently frozen UI on attached-socket drop — **M** +4. **Conflict-aware `/pull` and `/reparent` with continue/abort** (`app.tsx:6429/6042`, `git.*ConflictState`) — stops the false "Pull complete" on conflict — **M** +5. **Decouple spinner frame from row construction + `React.memo` on `ChatRow`/`InlineSpans`** (`ChatView.tsx:1168/1554`, `spinTick.tsx:14`) — stops the whole transcript rebuilding 10×/sec — **L** +6. **Universal hover: consume `hoveredId` in Drawer/Footer/ModelPicker/RightPane/ApprovalPrompt** (`app.tsx:7862`, `hitTestRegistry.ts:102`) — highest "feels-alive" win; pipeline already re-renders on move — **M** +7. **Grid Shift+Tab reverse focus + spatial arrow nav + wheel-targets-tile-under-cursor** (`app.tsx:8099/8344`, `MultiChatGrid.tsx`) — fixes user-prioritized grid input bug — **M** +8. **Footer cell-order derivation: fast reachable by mouse+keyboard, Codex approval/sandbox readout + 'custom' round-trip** (`app.tsx:7237/8126/9435/9496`) — all-runtimes footer correctness — **M** +9. **Preserve `event.diff` through aggregation, then colorized scrollable diffs (green/red gutter, `@@` headers)** (`aggregate.ts:25`, `ChatView.tsx:731`, `RightPane.tsx:1115`) — turns the flattest surface into a Claude-Code-grade diff; unblocks PR/conflict work — **L** +10. **Lane accent rail/dot in Drawer + Header runtime/connection health chip; shimmer the non-Claude "model working…" label** (`Drawer.tsx:369/439`, `Header.tsx`, `theme.ts:234`, `spinTick.tsx`, `ChatView.tsx:901`) — lane identity becomes visible; the marquee motion moment — **M** + +### Recommended implementation workflows + +**WF-1 — Robustness & streaming-perf foundation.** Builds the coalescing queue, single shared aggregation, spinner decoupling + memoized rows, incremental per-session dedup, reconnect state machine, and conflict-aware pull/reparent. *Depends on:* nothing. Must land first — every tick-driven visual item (WF-6) compounds on the 4×-aggregation/full-row-rebuild cost until this ships. + +**WF-2 — Input & mouse foundation.** Universal hover everywhere, wheel routed to the pane under the cursor (add RightPane scroll-offset state), de-duplicated render-coupled footer hit targets, clickable OSC-8 chat links, Ctrl+K global command/lane/chat palette, `[`/`]` lane cycling. *Depends on:* nothing structurally — can run in parallel with WF-1; light touch on the perf-sensitive move handler so coordinate with WF-1 on hot-path edits. + +**WF-3 — Grid + lane management.** Shift+Tab reverse focus + spatial arrow nav + wheel-targets-tile, per-tile lane accent + multi-state status glyph, lane color rail/icon in Drawer, lane rename + color/icon editing, delete preflight (`getDeleteRisk`) + streamed teardown, archive/unarchive view, per-lane drawer hotkeys/context menu. *Depends on:* WF-2 (hover/hit-test plumbing for tile + lane-row affordances), WF-1 (responsive grid under streaming). + +**WF-4 — Runtime UX & semantics.** Footer cell-order derivation (fast reachable, Codex approval/sandbox readout + 'custom' round-trip), Cursor modes from runtime snapshot, plan-approval card + orchestration `model_selection` card, reasoning-effort focus fix. *Depends on:* WF-2 (footer hit-target plumbing). Independent of WF-3; can run in parallel with it. + +**WF-5 — Model picker parity.** Full provider rail seeding, shortName/alias search restore, discovery loading state, inline reasoning/fast chips, auth dots / sign-in hint / auth-only toggle, PageUp/Down/Home/End nav. *Depends on:* WF-2 (hover) and WF-4 (shared runtime/auth-status threading); lightest workflow, good parallel filler. + +**WF-6 — PR + chat management.** Colorized scrollable `/diff` with per-file expand + staged/unstaged split, expandable hunks in chat from preserved `event.diff`; PR merge/land + submitReview + addComment; structured pr-detail content kind + multi-PR targeting; PR files/commits/diffs; delete/archive/unarchive chat + `!archivedAt` filter; real browse/search (`/chats`, `/switch` resolves chats, Ctrl+R recall); `/context` visual breakdown. *Depends on:* WF-1 (perf), WF-2 (scroll/hit-test/links), and the diff-data-loss fix (preserve `event.diff`) which should land at the head of this workflow. + +**WF-7 — Polish, animation & conflict intelligence.** Shimmer working label, fade-in completion glyph, animated TokenBar, connect spinner + boot-hero reveal, gradient wordmark, warmer empty/loading states; textual conflict-risk surface + risk-tinted stack tree; AI conflict-resolution proposal + merge-simulation. *Depends on:* WF-1 (shared aggregation + spinner decoupling — non-negotiable prerequisite) and WF-6 (colorized diff infra feeds the conflict surfaces). + +**WF-8 — Remaining surfaces (terminals, settings, automations, orchestration, usage/onboarding).** Coalesced terminal output + 500-chunk desync fix + multi-terminal + scrollback; `providerReadinessRows` render + API-key store/verify/delete + config-trust; read-only automations list + run history + NL create; orchestration phase/plan panel + phantom-spinner fix + lead task actions + Linear work-board; `/usage` quota pane + first-run onboarding + searchable `/help` + multiline feedback. *Depends on:* WF-1 (terminal output reuses coalescing pattern), WF-2 (hover/click on the new panes). Largest scope; sequence by impact; declines the documented non-goals. + +--- + +## Feature Parity Matrix & Phased Roadmap + +Legend: ✓ full · ◐ partial · ✗ missing · ⚠ glitchy. All file paths are under `apps/ade-cli/src/tuiClient/` unless noted. + +### 1) Parity matrix + +| Domain | Capability | Desktop | TUI | Top gap | +|---|---|---|---|---| +| **Lanes** | Switch active lane | ✓ | ✓ | — | +| Lanes | List / stack-DFS tree | ✓ | ✓ | No folder grouping; archived hidden | +| Lanes | Create lane (name + base) | ✓ | ◐ | Base is free-text, no branch picker (`app.tsx:4357`) | +| Lanes | Create child / import branch / attach | ✓ | ✗ | No `createChild`/`importBranch`/`attach` paths | +| Lanes | Rescue unstaged into new lane | ✓ | ✓ | — | +| Lanes | Rename lane | ✓ | ✗ | `/rename` only renames the chat (`commands.ts:31`) | +| Lanes | Color / icon / tags | ✓ | ✗ | Header reads color; Drawer ignores it (`Drawer.tsx:369,439,665`) | +| Lanes | Archive / unarchive | ✓ | ✗ | `includeArchived:false` hardcoded (`adeApi.ts:55`) | +| Lanes | Delete lane | ✓ | ◐ | No `getDeleteRisk`, no streamed teardown (`app.tsx:6682`) | +| Lanes | Reparent / restack | ✓ | ◐ | Typed-only, no picker, no dirty/rebase guard (`app.tsx:6003`) | +| Lanes | Merge/integrate/rebase lifecycle | ✓ | ✗ | Entire stack-integration workflow absent | +| Lanes | Per-lane drawer affordance (manage/delete hotkey) | ✓ | ✗ | Only `DrawerLaneAction='new-lane'` (`app.tsx:206`) | +| **Grid** | Add / remove panes | ✓ | ◐ | Capped at 6; chats only (`multiChatLayout.ts`) | +| Grid | Focus navigation | ✓ | ◐ | Shift+Tab mis-routed to `cyclePermission` (`app.tsx:8099/8344`) | +| Grid | Per-tile lane accent chrome | ✓ | ✗ | Tile colored cyan/gray only (`ChatView.tsx`,`MultiChatGrid.tsx`) | +| Grid | Per-tile multi-state status glyph | ✓ | ◐ | Single streaming dot (`ChatView.tsx:1604`) | +| Grid | Spatial arrow tile nav | ✓ | ✗ | Arrows scroll the focused tile only | +| Grid | Geometry / minimap | ✓ | ◐ | Floor-div dead margin; non-clickable minimap | +| Grid | Resize / reorder / presets / persistence | ✓ | ✗ | Fixed `PATTERNS`, in-memory only | +| Grid | Wheel-scrolls-tile-under-cursor | ✓ | ◐ | Wheel scrolls focused tile (`app.tsx:8039`) | +| **Runtimes** | Launch/drive all 5 (+ollama/lmstudio) | ✓ | ✓ | — | +| Runtimes | Claude 5-way permission | ✓ | ✓ | No per-mode tone on footer cell | +| Runtimes | Codex approval×sandbox | ✓ | ◐ | 'custom' lost on cycle (`app.tsx:7237`); no independent cells | +| Runtimes | Cursor modes | ✓ | ◐ | Static 4 modes; ignores `availableModeIds` snapshot | +| Runtimes | Droid / OpenCode autonomy | ✓ | ✓ | — | +| Runtimes | Reasoning effort | ✓ | ◐ | Dead focus stop when no tiers (`app.tsx:8126/9496`) | +| Runtimes | Fast mode | ✓ | ◐ | Can't enable by mouse/keyboard; only keybind (`app.tsx:9435`) | +| Runtimes | Plan-approval prompt | ✓ | ◐ | Forced to typed high-stakes path (`pendingInput.ts:53`) | +| Runtimes | Orchestration model_selection | ✓ | ✗ | No handler anywhere | +| **Model picker** | Provider rail | ✓ | ◐ | Seeded from present models only (`modelPickerLayout.ts:130`) | +| Model picker | Favorites / recents / search | ✓ | ◐ | Search drops shortName/aliases (`modelPickerLayout.ts:212`) | +| Model picker | Discovery loading state | ✓ | ✗ | Refresh wired but no "Checking provider…" (`RightPane.tsx:1042`) | +| Model picker | Reasoning/fast row chips | ✓ | ✗ | Rows show name + active/local only | +| Model picker | Auth dots / sign-in / auth-only | ✓ | ✗ | No `providerAuthStatus` threaded | +| Model picker | Keyboard / mouse nav | ✓ | ✓ | No PageUp/Down/Home/End | +| **Chat mgmt** | Create / rename / tag | ✓ | ◐ | Tag invisible everywhere (`format.ts:800`) | +| Chat mgmt | Delete chat | ✓ | ✗ | No `deleteSession` wrapper (`adeApi.ts`) | +| Chat mgmt | Archive / unarchive | ✓ | ✗ | No filter `!archivedAt` (`app.tsx:2841`) | +| Chat mgmt | Message rewind / edit-resend | ✓ | ✗ | No rewind primitive in TUI | +| Chat mgmt | History browse & search | ✓ | ◐ | `/switch` lanes-only; Ctrl+R can't recall (`app.tsx:7399/7613`) | +| Chat mgmt | Multi-session grid | ✓ | ✓ | — | +| Chat mgmt | Steer / interrupt | ✓ | ✓ | — | +| Chat mgmt | Token/cost + context usage | ✓ | ◐ | `/context` Claude-gated, text-only (`app.tsx:5814`) | +| Chat mgmt | Message rendering quality | ✓ | ✓ | — | +| **Nav** | Lane / chat switching | ✓ | ✓ | `/switch` doesn't restore last chat (`app.tsx:6251`) | +| Nav | Global command palette (Cmd/Ctrl+K) | ✓ | ✗ | No `app:openCommandPalette` (`SlashPalette.tsx:119`) | +| Nav | Slash / @-mention palette | ✓ | ✓ | — | +| Nav | Top-level view nav (TabNav) | ✓ | ✗ | No `setActiveView`/G-chords | +| Nav | Project switching / tabs | ✓ | ✗ | One project per process | +| Nav | `[`/`]` lane cycling | ✓ | ✗ | Bound only in model-picker (`app.tsx:8840`) | +| Nav | Pane focus cycling | ✓ | ◐ | `tabs:previous` aliases forward (`app.tsx:7674`) | +| Nav | Switch transition affordance | ✓ | ◐ | No center-pane "switching…" state | +| **PRs** | List per-lane / per-repo | ✓ | ◐ | Only `prs[0]`; no all-repo list (`app.tsx:6064`) | +| PRs | View detail / checks / reviews | ✓ | ◐ | Flat plaintext, hard caps, no scroll | +| PRs | View diffs / files / commits | ✓ | ✗ | No `getFiles/getCommits/getFileDiff` calls | +| PRs | Create PR | ✓ | ◐ | Inline path drops body (`app.tsx:6107`) | +| PRs | Merge / land | ✓ | ✗ | No `pr.land` call anywhere | +| PRs | Submit review / comment | ✓ | ✗ | No `submitReview`/`addComment` | +| PRs | Update title/labels/reviewers/close/rerun | ✓ | ✗ | Zero mutation beyond create | +| PRs | AI code review | ✓ | ✗ | No `/review`, no review bridge | +| PRs | Open in browser / deeplink | ✓ | ✓ | — | +| **Terminals** | Discover / list | ✓ | ◐ | Claude-only filter (`adeApi.ts:85`) | +| Terminals | Live output | ✓ | ◐ | 500ms poll; 500-chunk desync bug (`app.tsx:5174`) | +| Terminals | Input / attach | ✓ | ◐ | Claude-only; no echo, 1 RPC/keystroke (`app.tsx:4192`) | +| Terminals | Multiple terminals | ✓ | ✗ | Either/or with grid (`app.tsx:10144`) | +| Terminals | Scrollback / search / copy / links | ✓ | ✗ | Only top `visibleHeight` rows painted | +| Terminals | Session mgmt (rename/pin/bulk) | ✓ | ✗ | No management surface | +| **Files/diffs** | File tree / explorer | ✓ | ✗ | No browser surface | +| Files/diffs | Per-file diff (hunks) | ✓ | ◐ | 8 lines/file, 20-file cap, no color/scroll (`RightPane.tsx:1115`) | +| Files/diffs | Agent edit diffs in chat | ✓ | ✗ | `event.diff` discarded at aggregation (`aggregate.ts:25`) | +| Files/diffs | Syntax highlight in diffs | ✓ | ✗ | Engine exists, not wired to diffs | +| Files/diffs | Image preview | ✓ | ◐ | Shells out; dims parsed but unused (`app.tsx:6746`) | +| Files/diffs | Quick-open / text search | ✓ | ✗ | No fuzzy open / project search | +| Files/diffs | Editing / save | ✓ | ✗ | Read-only | +| **Settings** | API key store/verify/delete | ✓ | ✗ | Read-only `listApiKeys` (`adeApi.ts:330`) | +| Settings | Provider readiness view | ✓ | ⚠ | `providerReadinessRows` dead code (`app.tsx:3320`) | +| Settings | CLI provider login | ✓ | ◐ | No Cursor/Droid in-TUI auth (`app.tsx:1651`) | +| Settings | Config trust confirmation | ✓ | ✗ | No `projectConfig.*` calls | +| Settings | Appearance / preferences | ✓ | ✗ | Fixed dark palette (`theme.ts:150`) | +| Settings | Claude-compat diagnostics | ✓ | ✓ | — | +| **Automations** | List / create / edit / run / history | ✓ | ✗ | Entire product invisible; only `/ade` escape hatch | +| **Orchestration** | Subagent roster + transcript | ✓ | ✓ | Phantom spinner bug (`app.tsx:3243`) | +| Orchestration | Plan/phase/task panel | ✓ | ✗ | `orchestrationRunId` pass-through only (`app.tsx:358`) | +| Orchestration | Lead task actions / CTO console | ✓ | ✗ | No driving a run | +| Orchestration | Linear work-board / timeline | ✓ | ◐ | One-shot commands, raw JSON (`linearCommands.ts`) | +| **Review/conflict** | Lane diff view | ✓ | ◐ | No color/scroll/staged split | +| Review/conflict | Live conflict state (continue/abort) | ✓ | ✗ | `/pull` swallows conflicts (`app.tsx:6429`) | +| Review/conflict | Merge simulation / AI resolve | ✓ | ✗ | Needs runtime registration + wiring | +| **Remote/sync** | Remote/SSH target + selection | ✓ | ✗ | `RuntimeMode='attached'\|'embedded'` (`types.ts:25`) | +| Remote/sync | Connection status surface | ✓ | ◐ | Only connecting spinner; no health chip | +| Remote/sync | Reconnection robustness | ✓ | ⚠ | Drop undetected, UI freezes (`jsonRpcClient.ts:30`) | +| Remote/sync | Sync / multi-device / pairing | ✓ | ✗ | Absent | +| **Usage/help/Linear/deeplinks** | Provider quota panel | ✓ | ✗ | `/usage` is SDK passthrough (`commands.ts:42`) | +| | Cost display | ✓ | ◐ | Last-turn only, vanishes | +| | First-run onboarding | ✓ | ✗ | None | +| | Help / keymap | ✓ | ◐ | Static 11-line pane (`RightPane.tsx:773`) | +| | Linear commands | ✓ | ◐ | run/sync/ingress dump raw JSON (`app.tsx:6212`) | +| | Deeplink copy / open | ✓ | ◐ | Lane/PR only; no inbound resolve | +| | Feedback submission | ✓ | ◐ | Single-line truncated fields (`RightPane.tsx:1210`) | +| | Status line (Claude-compat) | ✓ | ✓ | — | +| **Mouse** | Enable / parse / passthrough | ✓ | ✓ | 1003 floods stdin | +| Mouse | Hover affordances | ✓ | ✗ | Only grid tiles read hovered id (`app.tsx:7862`) | +| Mouse | Wheel scroll routing | ✓ | ◐ | Only center transcript | +| Mouse | Clickable links | ✓ | ◐ | Styled but href dropped (`format.ts:234`) | +| Mouse | Drag / resize / right-click | ✓ | ◐ | Drag-to-grid only; left-click only | +| **Performance** | Streaming event coalescing | ✓ | ✗ | 1 render/token (`app.tsx:5084`) | +| Performance | Shared transcript aggregation | ✓ | ⚠ | Aggregates ~4×/render (`app.tsx:3249/3272/3297`) | +| Performance | Row windowing / memo | ✓ | ✗ | Full transcript rebuilt; no `React.memo` (`ChatView.tsx`) | +| Performance | Spinner cadence isolation | ✓ | ⚠ | Whole transcript rebuilds every 100ms (`spinTick.tsx:14`) | +| **Visual/motion** | Working/thinking spinner | ✓ | ◐ | Flat dot-pulse (non-Claude) | +| Visual/motion | Shimmer / gradient | ✓ | ✗ | None anywhere | +| Visual/motion | Colored diffs | ✓ | ✗ | `+N −M` only (`aggregate.ts:25`) | +| Visual/motion | Connect/boot animation | ✓ | ⚠ | Tick runs, nothing consumes it (`app.tsx:3243`) | +| Visual/motion | Completion glyph | ✓ | ✗ | Plain `[done]` notice | +| Visual/motion | Status line / token meter | ✓ | ✓ | TokenBar static | + +--- + +### P0 — blocks "everything works in the TUI" + +These are correctness/robustness defects and the highest-impact missing capabilities that make the TUI feel broken or unusable for core flows. Grouped by theme. + +**A. Connection & robustness (silent failure / frozen UI)** +- Attached socket drop is never detected → frozen, stale UI with no retry. Add `onClose` to `JsonRpcClient` (`jsonRpcClient.ts:30-34`), thread through `AdeCodeConnection` (`types.ts`/`connection.ts`), and drive a reconnect state machine in `app.tsx` (the reconnect probe at `app.tsx:5287-5319` early-returns when attached). Reset `streaming` on mid-turn loss (`app.tsx:6952` only covers the send path). +- `/pull --rebase|--merge` reports success even on conflict (`app.tsx:6429`); `/reparent` runs rebase with no conflict handling (`app.tsx:6042`). Wire `git.getConflictState` and add continue/abort. + +**B. Streaming performance (the dominant jank source)** +- No event coalescing — one full React render per token (`app.tsx:5084`). Add a ref-backed 16–33ms coalescing queue, force-flush on status/done/user_message. +- Transcript aggregated ~4× per render (`app.tsx:3249`, `:3272`, `:3297`, `ChatView.tsx:1541`). Hoist a single `aggregateChatBlocks` memo and thread blocks to all consumers. +- Whole transcript rows rebuilt every 100ms spinner tick (`ChatView.tsx:1554/1568`, `spinTick.tsx:14`); no `React.memo` on `ChatRow` (`ChatView.tsx:1168`). Decouple spinner frame from history rows; memoize per-block rows. + +**C. Grid input bug (user-prioritized surface)** +- Shift+Tab in a grid changes agent permission instead of focusing the previous tile (`app.tsx:8099` matches `!key.shift`; falls through to `cyclePermission` at `:8344`). Add a reverse-focus branch. + +**D. Footer control reachability (all-runtimes UI correctness)** +- Fast mode can't be enabled by mouse or keyboard — only the keybind (`app.tsx:9435` hit-test gated on `codexFastMode`; absent from inline-cell order at `:8126/9496`). Reasoning is a dead focus stop when unsupported. Derive the cell order from applicable cells; register fast's hit-test unconditionally when supported. +- Codex 'custom' approval×sandbox combos lost on cycle (`app.tsx:7237`). Stop discarding; show resolved approval/sandbox on focus. + +**E. Chat management data bugs** +- Archived chats never filtered from the session list (`app.tsx:2841`) — externally-archived chats pollute the drawer/grid. Filter `!session.archivedAt` now. +- No delete/archive/unarchive wrappers in `adeApi.ts` despite runtime support. + +**F. Files/diff data loss (cross-cutting; blocks review/visual/conflict work)** +- `file_change` diff body discarded at aggregation (`aggregate.ts:25`, drops `event.diff` after `diffStats()` at `:175`) — agent edits are never inspectable. Add `diff?: string` to `FileChangeEntry`. +- `/diff` truncates silently to 8 lines/20 files with no scroll and no +/- coloring (`RightPane.tsx:1115-1133`, `format.ts:830`). + +**G. Lane identity invisible (user-prioritized lane mgmt)** +- Lane custom color shown in Header but ignored in Drawer (`Drawer.tsx:369/439/665/670`); `theme.lane()` already exists (`theme.ts:234`). Render an accent rail/dot. + +--- + +### Phased roadmap + +Sequenced by dependency then user-stated priority (grid → lane mgmt → runtimes UI → model picker → mouse → lane/chat switching → chat mgmt + perf → PRs). Effort: S/M/L per item; phase size in parentheses. + +#### Phase 1 — Stop the bleeding: robustness + streaming perf (M–L) +Foundational; everything else sits on a responsive, non-freezing client. +- P0-A reconnect state machine + streaming reset (`app.tsx`, `jsonRpcClient.ts`, `connection.ts`, `types.ts`) — M. +- P0-B conflict-aware `/pull` and `/reparent` with continue/abort (`app.tsx:6429/6042`, `git.*ConflictState/Continue/Abort`) — M. +- P0-B perf: coalescing queue + single shared aggregation + spinner decoupling + `React.memo` rows (`app.tsx:5084/3249`, `ChatView.tsx:1168/1554`, `spinTick.tsx`) — L. +- Per-session dedup made incremental (`eventDedup.ts`, `app.tsx:5098`) — S. + +Ships: smooth token streaming, no frozen UI on disconnect, no false "Pull complete". + +#### Phase 2 — Grid + lane management + lane identity (M–L) +First two user-prioritized surfaces; small, high-visibility wins. +- Grid Shift+Tab reverse focus (P0-C); lane accent on tile chrome; multi-state per-tile status glyph; spatial arrow nav; wheel-targets-tile-under-cursor (`MultiChatGrid.tsx`, `ChatView.tsx`, `multiChatLayout.ts`, `app.tsx`) — M. +- Lane color rail in Drawer (P0-G) + icon glyph in rows (`Drawer.tsx`, `theme.ts:234`) — S. +- Lane rename + color/icon editing via `/lane …` and a Manage pane; fix the `/rename`-only-chats trap (`commands.ts:31`, `app.tsx:5943`) — M. +- Delete preflight (`getDeleteRisk`) + streamed teardown (`lanes.delete.event`) (`app.tsx:6651`) — M. +- Archive/unarchive + archived view (`adeApi.ts:55`) — M. +- Per-lane drawer hotkeys/context menu beyond `new-lane` (`app.tsx:206`) — M. + +Ships: grid feels deliberate; lanes are renameable, colorable, deletable-with-confidence, archivable. + +#### Phase 3 — All runtimes with proper UI/semantics + model picker parity (M–L) +User-prioritized; mostly localized footer/picker work. +- Footer cell-order derivation; fast reachable by mouse+keyboard; Codex approval/sandbox readout + 'custom' round-trip (P0-D) (`app.tsx:7237/8126/9435/9496`, `FooterControls.tsx`) — M. +- Cursor modes/config from runtime snapshot; persist `cursorModeSnapshot` (`app.tsx:334/4927`, `types.ts`) — L. +- Plan-approval card with one-key approve/reject (`pendingInput.ts:53`, `ApprovalPrompt.tsx`); orchestration `model_selection` card (`pendingInput.ts`) — M. +- Model picker: seed full provider rail + restore shortName/alias search (`modelPickerLayout.ts:130/212`) — S. +- Model picker: discovery loading state + inline reasoning/fast chips (`RightPane.tsx:1042`, `ModelPicker/`) — M. +- Model picker: auth dots / sign-in hint / auth-only toggle (`RightPane.tsx`, `ModelPicker/ModelPickerPane.tsx`) — M. + +Ships: every runtime's permission/fast/reasoning semantics are authorable and legible; picker matches desktop discovery/auth surfacing. + +#### Phase 4 — Full mouse control + global navigation (M–L) +User-prioritized; navigation discoverability + pointer-first feel. +- Universal hover: consume `hoveredId` in Drawer/Footer/ModelPicker/RightPane; throttle move processing (drop 1003 for 1002) (`app.tsx:7862`, `hitTestRegistry.ts:102`) — L. +- Wheel routed to pane under cursor; de-dup footer hit targets (`app.tsx:8037/9589`) — M. +- Clickable chat links (href + OSC 8 + hit target) (`format.ts:234`, `ChatView.tsx:1115`) — M. +- Ctrl+K global command/lane/chat jump palette (`SlashPalette.tsx`, `keybindings/index.ts`, `app.tsx`) — L. +- Global `[`/`]` lane cycling (context-scoped) + `/switch` restores last chat + `tabs:previous` reverse (`app.tsx:6251/7674/8840`) — S. + +Ships: hover everywhere, scroll/click that targets what's under the cursor, Cmd-K discoverability, fast lane cycling. + +#### Phase 5 — Chat management completeness (M) +Builds on Phase 1 perf + Phase 4 nav. +- Delete + archive/unarchive chat (`deleteSession/archiveSession/unarchiveSession` wrappers in `adeApi.ts`, drawer action + `/chat …`, confirm gate); filter `!archivedAt` (P0-E, `app.tsx:2841`) — L. +- Real browse/search: filterable `/chats`, `/switch` resolves chats, fix Ctrl+R recall + remove dead `cycleScope` (`app.tsx:6219/6232/7399/7613`) — M. +- Surface session completion/tag + colored wait glyph; status/time bucketing (`format.ts:800`, `Drawer.tsx`) — M. +- `/context` visual breakdown + relax Claude gate where supported (`app.tsx:5814`) — M. + +Ships: chats are deletable/archivable, browsable/searchable, and legible at a glance. + +#### Phase 6 — PR management & viewing (L) +User-prioritized; depends on the scrollable/colorized diff infra from Phase 7-prep below, so land the diff foundation first within this phase. +- Colorized, scrollable `/diff` pane with per-file expand + staged/unstaged split (P0-F, `RightPane.tsx:1115`, `format.ts:830`); expandable colorized hunks in chat from preserved `event.diff` (`aggregate.ts:25`, `ChatView.tsx`) — L. +- PR merge/land with method picker + confirm; submitReview approve/request-changes; addComment (`app.tsx`, `pr.land/submitReview/addComment`) — M. +- Structured pr-detail content kind (state chip, mergeable, base→head, reviewers, labels, checks, threads) replacing heuristic DetailsPane; multiple-PR targeting (stop `prs[0]`) (`app.tsx:6064/3871`, `RightPane.tsx`, `types.ts`) — L. +- PR diffs/files/commits (`pr.getFiles/getCommits/getFileDiff`) — L. +- Fix lane-details check counting + inline `/pr open` body + '…N more' indicators (`app.tsx:3904/6107`, `rightPaneFormatters.ts`) — S. +- `/prs` all-repo list with filters/search/CI+review glyphs — L. + +Ships: review, merge, and inspect PRs (incl. real diffs) from the terminal. + +#### Phase 7 — Visual & motion delight + conflict/review intelligence (M–L) +Layered on the now-shared aggregation and colorized diffs. +- Shimmer working label (non-Claude), fade-in completion glyph, animated TokenBar, connect spinner, boot-hero reveal (`ChatView.tsx`, `FooterControls.tsx`, `AdeWordmark.tsx`, `spinTick.tsx`) — M. +- Textual conflict-risk surface (`/conflicts`/`/risk` via `conflicts.getBatchAssessment/listOverlaps`, already viewerAllowed) + risk-tinted stack tree — M. +- AI conflict-resolution proposal (prepare→request→apply/undo) and merge-simulation preview (needs `conflicts.simulateMerge` runtime registration first) — L. + +Ships: the TUI feels alive and surfaces the graph's conflict intelligence textually. + +#### Phase 8 — Terminals, settings, automations, orchestration, usage/onboarding (L–XL) +Lower-frequency or larger-scope surfaces; sequence by impact. +- Terminals: stream-driven coalesced output + fix 500-chunk desync; scrollback nav + copy; generalize beyond Claude (gate chrome-stripping on `toolType==='claude'`) (`app.tsx:5174`, `TerminalPane.tsx`, `adeApi.ts:85`) — L. +- Settings: render `providerReadinessRows` (kill dead code, `app.tsx:3320`); masked API key store/verify/delete; config-trust confirmation — L. +- Automations: read-only list + run history (port `formatAutomationRunDetail`), lifecycle slash commands, NL `/automation create` — L. +- Orchestration: phase/plan summary for runs; fix phantom spinner + roster line-math (`app.tsx:3243`, `chatSubagents.ts:346`); lead task actions; Linear work-board pane — L. +- Usage/onboarding: `/usage` quota pane (`usage.getUsageSnapshot`, reuse TokenBar); first-run welcome + searchable `/help` from `BUILTIN_COMMANDS`; formatted Linear run/sync output; multiline feedback + AI assist (`commands.ts:42`, `RightPane.tsx:773/1210`, `app.tsx:6212/6712`) — L. + +Ships: the remaining desktop products become reachable/legible in the terminal. + +**Explicitly out of scope (decline for TUI parity):** 2D React-Flow graph canvas; multi-project tabs; Monaco-grade file editing/save; full structured automation rule editor (defer behind read-only + NL-create). Track as documented non-goals, not gaps. + + +--- + +## Polish, Performance & Delight Plan + +This plan targets the ADE TUI client (`apps/ade-cli/src/tuiClient/`) and makes it feel like Claude Code — colorful, animated, smooth, fully mouse-driven — without changing product behavior. Every item below is grounded in source I verified this pass (file/line references are real). Items are ordered within each subsection and tagged P0/P1/P2. + +Key architectural fact that constrains the plan (verified): Claude turns do NOT render in `ChatView` — `submitPrompt` (app.tsx:7004) routes them through `startClaudeTerminalSession`, and the render tree mounts a `TerminalPane` (app.tsx:10166) streaming Claude Code's own live PTY (its native spinner + "esc to interrupt" chrome). So `showChatWorkingIndicator = provider !== "claude"` (app.tsx:3248, ChatView.tsx:1549) is correct-by-design. All ChatView motion/visual work below applies to the codex/cursor/gemini path; Claude already animates via its own chrome. + +### Performance & smoothness + +The render pipeline is structurally heavier than desktop's and does zero event coalescing. There is **no sync I/O on the render/input hot path** (verified: `spawnSync`/`readFileSync` are confined to lazy `useState` initializers and one-shot `$EDITOR`/command handlers; `highlightCache.ts` is a solid fnv1a32-keyed LRU at 500 entries / 50MB), so the wins are all about coalescing, sharing aggregation, decoupling the spinner, and memoizing rows. + +- **[P0] Coalesce streaming setState into a per-frame queue.** `connection.onChatEvent` (app.tsx:5084-5161) fires `setEventsBySessionId` (5098) + `setEvents` (5105) synchronously per envelope — verified, no debounce/queue — so a fast stream drives one full React render per token, each re-running the aggregation below. Add a ref-backed pending-envelope queue plus a 16–33ms flush timer that applies both setters in a single batched update; **force-flush immediately** on lifecycle edges (`status`/`done`/`user_message`, already branched at app.tsx:5119-5160) and for visible grid tiles, mirroring desktop's `scheduleQueuedEventFlush`. Single biggest smoothness win; collapses N renders/sec to ~30/sec. Files: `app.tsx`, `connection.ts`. + +- **[P0] Compute `aggregateChatBlocks` once and thread blocks through all consumers.** The O(n) walk over the entire event list runs **four times per token** from independently-memoized sites that don't share the result: `computeChatScrollMaxOffset` (app.tsx:3249), `renderChatVisibleSelectionRows` (app.tsx:3272), `renderChatSelectableRowTexts` (app.tsx:3297), and ChatView's own `useMemo` (ChatView.tsx:1540 — verified `aggregateChatBlocks({events,notices,activeSession,expandedLineIds})`). All four invalidate together on every `displayEvents` change. Hoist a single app-level `useMemo` keyed on `{events,notices,activeSession,expandedLineIds}` producing `AggregatedBlock[]`, and refactor the three `render*`/`compute*` helpers to accept blocks instead of re-deriving. Eliminates 3 of the 4 redundant full-transcript walks. Files: `app.tsx`, `aggregate.ts`, `ChatView.tsx`. + +- **[P0] Decouple the spinner frame from full row construction.** `SpinTickProvider` wraps the whole app (app.tsx:10104) and ticks every 100ms (spinTick.tsx:14). ChatView's `rows` memo depends on `brailleFrame/spinFrame/dotPulse` (ChatView.tsx:1554-1568, verified deps array) and `rowsForBlocks` threads those frames into ALL blocks, so the **entire transcript's rows rebuild ~10x/sec during streaming** even though only the live indicator needs the frame. Render historical/non-live rows frame-independently (memoize on `blocks`+`width` only) and split the live tail (active-turn indicator, running-tool glyph, compaction spinner) into its own small component that consumes the tick. Files: `ChatView.tsx`, `spinTick.tsx`. + +- **[P1] Wrap `ChatRow`/`InlineSpans` in `React.memo` with stable keys and per-block row caching.** Verified zero `React.memo` in ChatView.tsx; `ChatRow` (1168) and `InlineSpans` (1102) are plain functions rendered in a `.map` (1593-1595), so Ink reconciles every row each render/tick. Cache `rowsForBlock` output keyed on block identity (live blocks bypass), and memoize the row components so unchanged history isn't re-wrapped/re-highlighted/re-diffed. Matches desktop's memoized `EventRow`/`MarkdownBlock`. File: `ChatView.tsx`. + +- **[P1] Make the per-session dedup incremental.** `setEventsBySessionId` (app.tsx:5098) calls `appendDedupedTuiEvent` → `dedupeTuiEvents([...prev, envelope])` (eventDedup.ts:106) which recomputes `tuiEventDedupKey` for every event, and the key embeds `JSON.stringify(envelope.event)` (eventDedup.ts:21 correlation branch, 32 fallback) — verified O(n) with full serialization per incoming event. The active-session path already uses the incremental `reserveTuiEventDedupKey`/`appendReservedTuiEvent` (eventDedup.ts:66-99, app.tsx:5103-5117); reuse that pattern with a per-session key `Set` for the `bySessionId` map. Optional follow-up: drop the `JSON.stringify` suffix from the correlation branch when stable IDs exist (eventDedup.ts:21). Files: `eventDedup.ts`, `app.tsx`. + +- **[P2] Ref-buffer PTY chunks and flush on a timer.** `pty_data` (app.tsx:5173-5177, verified) does `setTerminalLiveChunks(prev => [...(prev[sid]??[]), data].slice(-500))` per chunk — an O(n) array rebuild plus a parent App re-render (which re-flows chat aggregation when both panes are mounted) on every chunk. `TerminalPane` already writes incrementally via `chunkIndexRef`. Buffer chunks in a ref and flush on a 16ms timer, or push straight into the headless xterm and bump a render tick. NOTE: also fixes the known saturation bug where a pinned-at-500 buffer stops advancing the write cursor (the `liveChunks.length < start` guard never trips once length stays 500). Files: `app.tsx`, `TerminalPane.tsx`. + +- **[P2] Row windowing for very long transcripts.** `visibleRowsForBlocks` (ChatView.tsx:1269) builds rows for the ENTIRE block list then `sliceRows` trims to viewport afterward — cost scales with transcript length, not the visible window. Once the cheaper wins land, build rows only for blocks near the scroll window (analogous to desktop's virtualization above 60 rows). Add a 500-event/large-code-block stress test (none exists today — only `aggregate.test.ts`/`ChatView.test.tsx`). File: `ChatView.tsx`. + +- **[P2] Stop the idle 100ms tick during connect.** `spinTickActive` includes `mode === "connecting"` (app.tsx:3245, verified) but nothing in the connect render consumes a frame, so the 100ms interval re-renders for nothing. Either drop `connecting` from `spinTickActive` or actually consume a braille frame (see Visual subsection). File: `app.tsx`. + +### Full mouse control + +Mouse enable + parsing is solid (verified): DECSET `1000/1002/1003/1006/1015` enabled on mount (app.tsx:2009) and torn down on exit (2001); the attached terminal gets raw stdin. The hover pipeline is fully wired — `hoverTest` fires on move (app.tsx:7862-7870, change-gated on `target.id`), `hoveredHitId` flows through `HitTestProvider`, and `useHitTestTarget` (hitTestRegistry.ts:102-115) returns an is-hovered boolean. **But only `MultiChatGrid` calls `useHitTestTarget`** — Drawer, RightPane, FooterControls, ModelPicker, ApprovalPrompt have zero hover consumers despite the per-move re-render already happening. Wheel scroll is gated to the center transcript only (app.tsx:8039), and links are styled but not clickable. + +- **[P0] Make hover universal.** Pass each clickable id down (or call `useHitTestTarget`) and tint on `hoveredId` match in `Drawer.tsx` lane/chat rows, `FooterControls.tsx` cells, `ModelPicker`, `RightPane.tsx` list/diff/file rows, and `ApprovalPrompt`. The pipeline already re-renders on every move (app.tsx:7862), so this is the highest "feels-alive" win for the least risk. Files: `Drawer.tsx`, `FooterControls.tsx`, `RightPane.tsx`, `ModelPicker/`, `ApprovalPrompt.tsx`. + +- **[P1] Route wheel scroll to the pane under the cursor.** Replace the center-pane-only gate (app.tsx:8037-8044, verified `inCenterPane && inTranscriptRows`) with a dispatch that scrolls the region under the pointer via per-pane offsets. Today drawer/right-pane/terminal ignore the wheel entirely. Prerequisite: RightPane currently has **no scroll-offset state at all** — add it (copy ChatView's `sliceRows`/`maxScrollOffsetForRows`/`scrollOffsetRows` machinery) so `/diff` and details panes become scrollable instead of truncating to 8 lines. Files: `app.tsx`, `RightPane.tsx`. + +- **[P1] De-duplicate and render-couple the footer hit targets.** Verified hardcoded targets at `columns-38/26/15` (app.tsx:9589-9606: `footer:lanes`/`footer:pane`/`footer:chat-info`) computed independently of what `FooterControls` actually renders — drift can mis-click in non-overlapping cells. Derive these rects from the same layout `FooterControls.tsx` paints. Files: `app.tsx`, `FooterControls.tsx`. + +- **[P1] Make chat links openable.** Link runs are tagged (format.ts:103/137-141) and rendered with `LINK_COLOR` + underline (ChatView.tsx:1115-1120), but the href is dropped (format.ts:234-238, verified comment "doesn't render hyperlinks distinctly today"). Carry the href on the link run, emit OSC-8 hyperlinks, and register a hit target calling `openExternal` (the PR-url open path already exists at app.tsx:8932). Lower risk since styling is done. Files: `format.ts`, `ChatView.tsx`, `app.tsx`. + +- **[P1] Add reverse/spatial grid tile focus + wheel-targets-tile.** `Shift+Tab` in a grid falls through (app.tsx:8099 matches only `key.tab && !key.shift`) to the global handler and cycles permission (8344) instead of focusing the previous tile — verified bug. Add reverse focus and 2D arrow movement, and route the wheel to the tile under the pointer. Files: `app.tsx`, `MultiChatGrid.tsx`. + +- **[P2] Clickable rows/actions in Drawer, RightPane, roster, and PR panes.** Extend `useHitTestTarget` so clicking a lane/chat/diff-file/PR-check/review-thread/subagent row selects or expands it, and add click-× archive on chat rows and a click-to-merge affordance on PR rows. Reuses existing `hitTestRegistry` + `decodeMouseButton` (app.tsx:1739). Note: `decodeMouseButton` handles left-click only — double/right-click and divider-drag pane resize remain out of scope unless explicitly requested. Files: `Drawer.tsx`, `RightPane.tsx`, `subagentPane.ts`, `app.tsx`. + +- **[P2] Throttle/coalesce hover processing under heavy target counts.** The move handler is already change-gated, but `register` does `targets = [...filter, target]` (hitTestRegistry.ts:53) — O(n) filter+spread whenever an inline target identity changes each render. Memoize target objects so re-register churn doesn't grow with target count; optionally drop DECSET 1003 (any-motion) for 1002 to reduce stdin flood. Files: `hitTestRegistry.ts`, `app.tsx`. + +### Visual & motion delight + +The static foundation is genuinely strong (verified `theme.ts`: violet brand family `VIOLET #A78BFA`/`VIOLET_DEEP #7C3AED`, status family `RUNNING #22C55E`/`ERROR #EF4444`/`ATTENTION #F59E0B`, per-provider brand colors, `PLAN_MODE #22D3EE`; plus the ANSI-shadow wordmark, BootHero, TokenBar, three spinner families in `spinTick.tsx`). The gaps are motion and color movement. All items below reuse existing tokens — no new palette. + +- **[P0] Shimmer the "model working…" label (non-Claude path) via the spin tick.** Replace the flat violet `✦ model working…` (`activeTurnRows`, ChatView.tsx:901-911) with a terminal shimmer: split the label into per-character runs and ramp brightness/violet across them driven by a new `useShimmerFrame` helper in `spinTick.tsx` (a moving bright cell over `t3 #908FA0` → `violet` → `t1 #F0F0F2`). Closest analog to desktop's `ade-shimmer-text`; the marquee "alive" moment for codex/cursor/gemini turns. Must NOT apply to Claude turns. Files: `spinTick.tsx`, `ChatView.tsx`. + +- **[P1] Colorize diff hunk lines green/red with a left gutter.** Verified the raw diff is **discarded at aggregation**: `FileChangeEntry` (aggregate.ts:25-33) keeps only `path/kind/status/additions/deletions`, never the diff text, even though the event carries `diff: string`. `filesChangedGroupRows` (ChatView.tsx:731-794) renders only badge + path + `+N −M`, and `/diff` in RightPane.tsx renders the whole body in one dim `theme.color.t3` Text with no `+`/`-` coloring and an 8-line silent truncation. Thread the raw diff through `FileChangeEntry`, then emit real hunk lines: `theme.color.running` for `+`, `theme.color.error` for `-`, dim context, violet `@@` headers, subtle `▎` gutter (cap N lines/file). Optionally pipe through `highlightCache` for syntax. Turns the flattest TUI surface into a Claude-Code-grade diff. Higher effort because the data must first be retained. Files: `aggregate.ts`, `ChatView.tsx`, `RightPane.tsx`, `format.ts`. + +- **[P1] Fade-in success/failure glyph on turn completion (ChatView path).** On `done`/`status completed` for non-Claude turns, render a brief emphasized green `✓ done` (red `✗ failed` on error) ramping brightness over 1–2 ticks before settling to the muted summary, mirroring desktop's `ade-fade-in` CheckCircle. Today completion is visually indistinct from any other notice (format.ts `[done] …`). Files: `app.tsx`, `format.ts`, `ChatView.tsx`. + +- **[P1] Animated "Connecting to runtime…" affordance.** `spinTickActive` already runs during `connecting` (app.tsx:3245) but the only feedback is a static dim "Loading lanes…" (Drawer.tsx:217-219) and a static BootHero. Consume a braille frame in the connect render so boot shows live progress (and pair with the P2 "stop idle tick" fix so we either animate or stop ticking). Files: `app.tsx`, `Drawer.tsx`, `ChatView.tsx`. + +- **[P1] Lane accent color in the Drawer + persistent runtime/connection chip in Header.** `theme.lane(lane)` (theme.ts:234, returns `lane.color || violet`) is honored by the Header but NOT the Drawer — verified `LaneCard` uses `theme.color.violet/t1` (Drawer.tsx:369/439) and MiniDrawer uses status color, so user-assigned lane colors are invisible where lanes are actually distinguished. Render the lane color as a left rail/dot in `LaneCard`. Separately, add a right-aligned Header chip showing runtime/link health (`● local` / `● remote` / `◌ connecting` / `✕ disconnected`) color-coded via theme — the connect spinner is transient and users have no ambient connection sense. Files: `Drawer.tsx`, `Header.tsx`. + +- **[P2] Animate the TokenBar fill and pulse near the danger threshold.** The bar (FooterControls.tsx:27-37) is static and consumes no tick; `tokenBarColor` (FooterControls.tsx:20-24) already encodes green→violet→amber→red thresholds. Ease the filled-cell count toward target across a few ticks and blink the last filled cell when percent ≥ 95 so context exhaustion is *felt*. Reuse the same primitive for animated `/usage` quota meters (5h/weekly/monthly bars). Files: `FooterControls.tsx`. + +- **[P2] Per-tile status + lane accent in the grid.** `MultiChatGrid` colors tiles only by focus and shows a single `●` streaming dot. Add a spinner for streaming, amber for awaiting, red for error, dim for ended, and tint each tile border/header by lane color (`theme.lane`). File: `MultiChatGrid.tsx`. + +- **[P2] Boot hero entrance reveal + gradient wordmark.** `AdeWordmark.tsx` has no animation hook (verified static two-tone). On first paint, reveal rows top-to-bottom over a few ticks and apply an `accent → violetDeep` per-row gradient. Memorable first-run moment with no steady-state cost. Files: `AdeWordmark.tsx`, `ChatView.tsx`. + +- **[P2] Fix `InlineSpans` code-run branch to honor dim/bold/italic.** ChatView.tsx:1116-1120 forces `theme.color.codeInline` and drops `dimColor/bold/italic` (unlike the default branch at 1123), so inline code in dim/quoted contexts is visually inconsistent. Pass the same flags through. File: `ChatView.tsx`. + +- **[P2] Warmer, motion-aware secondary empty/loading states + progress shimmer.** Replace bare dim "No plan yet." / "No changed files." / "No chats open." / "Loading lanes…" with short inviting copy and a small braille spinner while loading, and add a thin shimmer bar under long-running tool/runtime group headers (cycling bright cell across a `─` rule). Avoid generic AI phrasing. Files: `RightPane.tsx`, `Drawer.tsx`, `ChatView.tsx`, `MultiChatGrid.tsx`. + +#### Sequencing note +Land the three P0 performance fixes first (coalescer → shared aggregation → spinner decoupling): they directly enable the visual motion work, because shimmer/fade-in/TokenBar animation all add tick-driven re-renders that would otherwise compound the existing 4×-aggregation and full-row-rebuild costs. Universal hover (mouse P0) is independent and can proceed in parallel. + + +--- + +## Appendix — Per-domain audit reports + +
Lanes (create / manage / switch) (parity) + +```json +{ + "dimension": "Lanes (create / manage / switch)", + "summary": "Verified against the cited TUI files. The grep-based verb inventory is accurate: the only lane actions the TUI ever calls are list, create, reparent, createFromUnstaged, and delete (app.tsx:5927/6042/6564/6597/6684, adeApi.ts:54). There is NO rename, color/icon/tag editing, archive/unarchive, importBranch, attach/adopt, linkLinearIssues, or any rebase/integrate lifecycle. The create surface is exactly a 2-field form (name + free-text base branch) calling lane.create with only name + optional baseBranch (app.tsx:4357-4367, 6560-6586). Switching, the stack-DFS tree, rescue-unstaged, scoped delete, and typed reparent all work as described.\n\nTwo confirmed inconsistencies stand out. (1) The lane custom color is rendered in the Header (theme.lane(lane) at Header.tsx:29,67,69) and the helper exists (theme.ts:234, returns lane.color || violet), but the Drawer ignores it entirely: LaneCard sets nameColor to violet/t1 (Drawer.tsx:369,439) and MiniDrawer colors the rail via laneStatusColor and the name via violet/t1 (Drawer.tsx:665,670). So a lane's identity color shows in the header but is invisible in the list where lanes are actually distinguished — and since the TUI can't set color, desktop-colored lanes are indistinguishable in the drawer too. (2) /rename only renames the active chat (commands.ts:31, app.tsx:5943-5962) — there is no lane rename anywhere.\n\nDelete is functional with scope cycle, force, remote name, and exact-name confirm (app.tsx:6651-6701) but shows a static 'Deleting ...' body with no getDeleteRisk preflight and no lanes.delete.event subscription. Reparent is typed-only (/reparent, app.tsx:6003-6053); note the usage text DOES disclose 'runs git rebase' (line 6030), but there is no pre-apply dirty/rebase-in-progress guard and no parent/base picker. One additional affordance gap found: the only DrawerLaneAction enum value is 'new-lane' (app.tsx:206), so the drawer offers no single-key delete/rename/manage on a selected lane (arrows navigate, Enter enters/switches); every management action requires a slash command. Mouse support exists (click-to-select a lane, click the bottom row to create — app.tsx:7926-7947) but there is no right-click context menu equivalent to the desktop LaneContextMenu.", + "tuiStatus": [ + { + "feature": "Switch active lane", + "status": "full", + "details": "Drawer arrow-key navigation selects/enters lanes (app.tsx:9068-9149) and mouse click selects a lane (app.tsx:7939-7947); /switch lists lanes and jumps by id/name. Selecting a lane updates active lane + newest session. Confirmed.", + "tuiFiles": [ + "apps/ade-cli/src/tuiClient/components/Drawer.tsx", + "apps/ade-cli/src/tuiClient/app.tsx" + ] + }, + { + "feature": "List & group lanes (stack DFS tree)", + "status": "full", + "details": "laneTree.ts sortLanesForStackGraph + computeStackRowMeta draw ├─/└─ ASCII prefixes; Drawer renders status, PR pill, diff +/-, age, VM badge. Confirmed archived lanes are never listed (adeApi.ts:55 includeArchived:false) and there is no folder grouping.", + "tuiFiles": [ + "apps/ade-cli/src/tuiClient/laneTree.ts", + "apps/ade-cli/src/tuiClient/components/Drawer.tsx", + "apps/ade-cli/src/tuiClient/adeApi.ts" + ] + }, + { + "feature": "Create lane (primary / base branch)", + "status": "partial", + "details": "openNewLaneForm (app.tsx:4357-4367) is a 2-field form (name + free-text 'Base branch' placeholder 'default'); handler (app.tsx:6560-6586) calls lane.create with only name + optional baseBranch. No base-branch picker/validation, no default color seeding, no env-init/setup progress feedback. Confirmed.", + "tuiFiles": [ + "apps/ade-cli/src/tuiClient/app.tsx" + ], + "gap": "Base branch is an untyped free-text field with no branch list; desktop offers a validated select of local branches plus a current-branch marker." + }, + { + "feature": "Create child lane (parent + base override)", + "status": "missing", + "details": "No lane.createChild and the create form has no parent-lane selector or child base override. Verified the only create paths are 'new-lane' and 'new-lane-from-unstaged'. Users can only retro-fit a parent via /reparent after creating a root lane.", + "tuiFiles": [ + "apps/ade-cli/src/tuiClient/app.tsx" + ], + "gap": "No way to create a stacked child lane directly from the TUI." + }, + { + "feature": "Import existing branch as lane", + "status": "missing", + "details": "No lane.importBranch call and no branch picker (verb grep confirms). Desktop drives this through BranchPickerView with PR pills and author/staleness search.", + "tuiFiles": [ + "apps/ade-cli/src/tuiClient/app.tsx" + ], + "gap": "Cannot adopt an existing local/remote branch into a managed lane from the TUI." + }, + { + "feature": "Attach / adopt external worktree", + "status": "missing", + "details": "No lane.attach or adopt path in the TUI (verb grep confirms only create/reparent/createFromUnstaged/delete/list). Desktop has AttachLaneDialog / MultiAttachWorktreeDialog and a context-menu adopt action.", + "tuiFiles": [ + "apps/ade-cli/src/tuiClient/app.tsx" + ], + "gap": "Attached lanes can be switched to if they already exist, but cannot be created or adopted from the TUI." + }, + { + "feature": "Rescue unstaged work into new lane", + "status": "full", + "details": "openMoveUnstagedForm (app.tsx:4369-4387) + lane.createFromUnstaged (app.tsx:6588+) carries unstaged/untracked changes into a new child lane. Confirmed.", + "tuiFiles": [ + "apps/ade-cli/src/tuiClient/app.tsx" + ] + }, + { + "feature": "Rename lane", + "status": "missing", + "details": "/rename only renames the active CHAT (commands.ts:31 'Rename the active chat'; app.tsx:5943-5962, errors 'No active chat is selected'). No lane.rename verb exists anywhere. Confirmed.", + "tuiFiles": [ + "apps/ade-cli/src/tuiClient/app.tsx", + "apps/ade-cli/src/tuiClient/commands.ts" + ], + "gap": "No lane rename at all; /rename is also a naming trap since a user in a lane-management mindset will reach for it." + }, + { + "feature": "Lane color / icon / tags (appearance)", + "status": "missing", + "details": "No lane.updateAppearance verb. The Header DOES read color + icon glyph (Header.tsx:29,67-69 via theme.lane and laneIconGlyph), but the value can never be set from the TUI, and the Drawer ignores custom color entirely (LaneCard nameColor=violet/t1 at Drawer.tsx:369,439; MiniDrawer rail=laneStatusColor and name=violet/t1 at Drawer.tsx:665,670). Confirmed.", + "tuiFiles": [ + "apps/ade-cli/src/tuiClient/app.tsx", + "apps/ade-cli/src/tuiClient/components/Drawer.tsx", + "apps/ade-cli/src/tuiClient/components/Header.tsx", + "apps/ade-cli/src/tuiClient/theme.ts" + ], + "gap": "Color/icon/tags are read-only and the lane's identity color is invisible in the drawer where it matters most." + }, + { + "feature": "Archive / unarchive lane", + "status": "missing", + "details": "No archive/unarchive verb and includeArchived:false is hardcoded (adeApi.ts:55). Confirmed. A lane archived on desktop disappears from the TUI with no way to view or restore it.", + "tuiFiles": [ + "apps/ade-cli/src/tuiClient/app.tsx", + "apps/ade-cli/src/tuiClient/adeApi.ts" + ], + "gap": "No archive lifecycle; archived lanes are hidden and unrecoverable from the TUI." + }, + { + "feature": "Delete lane", + "status": "partial", + "details": "openLaneDeleteForm (app.tsx:4389+) + lane.delete (app.tsx:6651-6701) supports the three scopes (worktree/local_branch/remote_branch), force toggle, remote name, exact-name confirm; primary lane is blocked. Confirmed it does NOT call getDeleteRisk and does NOT subscribe to lanes.delete.event — body is a static 'Deleting ...' string (app.tsx:6682).", + "tuiFiles": [ + "apps/ade-cli/src/tuiClient/app.tsx" + ], + "gap": "No risk preflight and no streamed teardown progress; delete feels opaque vs desktop ManageLaneDialog DeleteProgressStrip." + }, + { + "feature": "Reparent / restack lane", + "status": "partial", + "details": "/reparent [stack-base-ref] (app.tsx:6003-6053) calls lane.reparent and lists targets when bare. Correction: the bare-usage text DOES disclose 'Moves the active lane under another parent and runs git rebase' (line 6030), so the rebase consequence is documented — but only in the listing text, not as a pre-apply guard. Functional but typed-only with no parent picker, no dirty/rebase-in-progress guard before applying, no base-branch picker.", + "tuiFiles": [ + "apps/ade-cli/src/tuiClient/app.tsx" + ], + "gap": "No interactive picker and no pre-apply dirty/rebase guard; user must know the parent id/name." + }, + { + "feature": "Set / change base branch", + "status": "partial", + "details": "Base branch is settable only at create time as free text and indirectly via /reparent's optional stack-base-ref (app.tsx:6041-6046). No standalone change-base action, no branch listing/validation. Confirmed.", + "tuiFiles": [ + "apps/ade-cli/src/tuiClient/app.tsx" + ], + "gap": "No first-class base-branch management with branch listing/validation." + }, + { + "feature": "Merge / integrate / rebase-run lifecycle", + "status": "missing", + "details": "No rebaseStart/Abort/Rollback/Push verbs, no rebase-suggestion banner, no auto-rebase surface (verb grep confirms). The LaneSummary carries rebaseSuggestion/autoRebaseStatus/conflictStatus that the TUI never reads. Confirmed.", + "tuiFiles": [ + "apps/ade-cli/src/tuiClient/app.tsx" + ], + "gap": "Entire stack-integration/rebase workflow is absent from the TUI." + }, + { + "feature": "Link Linear issue to lane", + "status": "missing", + "details": "No lane.linkLinearIssues verb in create or manage; create cannot attach a Linear issue. Separate /linear commands do not write a lane's metadata. Confirmed.", + "tuiFiles": [ + "apps/ade-cli/src/tuiClient/app.tsx" + ], + "gap": "lane.linearIssue is read but never written from the TUI." + }, + { + "feature": "Batch / multi-lane manage", + "status": "missing", + "details": "All TUI lane actions operate on a single active lane. Desktop LaneContextMenu + ManageLaneDialog support multi-select batch archive/delete. Confirmed.", + "tuiFiles": [ + "apps/ade-cli/src/tuiClient/app.tsx" + ], + "gap": "No multi-select lane operations." + }, + { + "feature": "Drawer per-lane action affordance (manage/delete/rename hotkey or context menu)", + "status": "missing", + "details": "New gap. The only DrawerLaneAction enum value is 'new-lane' (app.tsx:206). In the lanes section, arrows navigate cards and Enter enters/switches a lane (app.tsx:9068-9149); there is no single-key delete/rename/manage on a selected lane. Mouse click selects a lane and clicking the bottom row creates a new one (app.tsx:7926-7947), but there is no right-click context menu equivalent to the desktop LaneContextMenu. Every lane management action requires dropping to a slash command.", + "tuiFiles": [ + "apps/ade-cli/src/tuiClient/app.tsx" + ], + "gap": "No quick keyboard or context-menu affordance to manage a lane from the drawer; desktop right-click exposes color/copy-link/reveal/manage inline." + } + ], + "bugs": [ + { + "title": "Lane custom color is invisible in the Drawer (only Header honors it)", + "severity": "medium", + "file": "apps/ade-cli/src/tuiClient/components/Drawer.tsx", + "description": "Verified: theme.lane(lane) exists (theme.ts:234, returns lane.color || violet) and the Header uses it for the lane glyph + label (Header.tsx:29,67,69). The Drawer never does: LaneCard sets nameColor to theme.color.violet/t1 (Drawer.tsx:369) applied at line 439, and MiniDrawer colors the rail via theme.laneStatusColor(status) (Drawer.tsx:665) and the name via violet/t1 (Drawer.tsx:670). So a user-assigned lane color appears in the header but never in the list where lanes are distinguished, and desktop-colored lanes are indistinguishable in the TUI drawer." + }, + { + "title": "/rename only renames chats but reads like a lane action", + "severity": "low", + "file": "apps/ade-cli/src/tuiClient/commands.ts", + "description": "Verified: commands.ts:31 describes /rename as 'Rename the active chat' and app.tsx:5943-5962 only renames the active chat session (errors 'No active chat is selected'). There is no lane.rename path at all, so in a lane-management mental model /rename silently can never manage a lane." + }, + { + "title": "Delete flow ignores getDeleteRisk and lanes.delete.event progress", + "severity": "low", + "file": "apps/ade-cli/src/tuiClient/app.tsx", + "description": "Verified: the delete handler (app.tsx:6651-6701) neither fetches risk nor subscribes to progress; it sets a static body 'Deleting ...\\nScope: ...\\nForce: ...' (line 6682) then awaits lane.delete and refreshes. On a slow teardown the user gets no live feedback and no dirty/unpushed warning before the irreversible action." + }, + { + "title": "Archived lanes are permanently hidden and unrecoverable from the TUI", + "severity": "low", + "file": "apps/ade-cli/src/tuiClient/adeApi.ts", + "description": "Verified: adeApi.ts:55 hardcodes includeArchived:false and there is no archive/unarchive verb. A lane archived on desktop disappears from the TUI with no way to view or restore it, and a TUI user cannot archive to declutter (only hard-delete is available)." + } + ], + "polishOpportunities": [ + { + "title": "Render lane accent color as a left rail/dot in drawer cards", + "description": "Use theme.lane(lane) (already defined, theme.ts:234) for a colored leading rail/border in LaneCard (Drawer.tsx:369/439) and the MiniDrawer rail (Drawer.tsx:665), so colored lanes are instantly scannable and match the header. Today selection is the only visual differentiator (violet). Smallest, highest-value visual parity fix.", + "impact": "high" + }, + { + "title": "Inline color swatch picker in a lane manage/create flow", + "description": "Mirror the desktop swatch groups as a horizontal row of colored block glyphs navigable with arrows, with taken colors dimmed/checked. Brings the most-used lightweight personalization to the TUI without a heavy dialog (depends on adding a lane.updateAppearance call).", + "impact": "medium" + }, + { + "title": "Live delete-teardown progress strip", + "description": "Subscribe to lanes.delete.event and render a per-step list (Stopping processes / Closing terminals / Removing worktree / Deleting branch) with spinner→check transitions, reusing the existing useSpinFrame spinner already imported in Drawer.tsx. Makes destructive teardown feel trustworthy instead of a frozen 'Deleting...' line.", + "impact": "medium" + }, + { + "title": "Interactive parent/base picker + pre-apply guard for reparent and child-create", + "description": "Replace typed /reparent and the free-text base field with a right-pane list picker (parent lanes excluding descendants — reparentTargetsForLane at app.tsx:793 already computes valid targets; branch list with current marker) and add a dirty/rebase-in-progress block before applying. The 'runs git rebase' line already exists in usage text (app.tsx:6030) but only in the listing, not at the confirm step.", + "impact": "medium" + }, + { + "title": "Per-lane drawer hotkeys / context menu (delete / rename / manage)", + "description": "Add single-key affordances on a selected lane card (e.g. d=delete, r=rename, m=manage) and/or a right-click context menu, since today the only DrawerLaneAction is 'new-lane' (app.tsx:206) and every management action requires a slash command. Brings the drawer toward desktop LaneContextMenu ergonomics.", + "impact": "medium" + }, + { + "title": "Surface rebase suggestions / behind-parent state in the drawer", + "description": "LaneSummary carries parentStatus/rebaseSuggestion/autoRebaseStatus that the TUI never reads. A subtle 'behind N' chip or one-key 'sync from parent' affordance on stacked lanes would bring desktop LaneRebaseBanner intelligence in without a full graph.", + "impact": "medium" + }, + { + "title": "Show lane icon glyph in drawer rows (not just header)", + "description": "laneIconGlyph (Header.tsx:7-18) already maps LaneIcon→glyph (star/flag/bolt/shield/tag). Render that glyph as the leading marker in drawer LaneCard rows so icon-tagged lanes are distinguishable in the list; pair with a quick icon picker.", + "impact": "low" + } + ], + "recommendations": [ + { + "title": "Render lane.color in the Drawer (LaneCard + MiniDrawer)", + "description": "Use theme.lane(lane) (theme.ts:234) for a colored rail/border/leading glyph in LaneCard (Drawer.tsx:369/439) and MiniDrawer (Drawer.tsx:665/670) so desktop-assigned colors become visible immediately, independent of whether TUI editing lands. Smallest, highest-value visual parity fix.", + "effort": "S", + "priority": "P0" + }, + { + "title": "Add lane rename + color editing to the TUI", + "description": "Wire lane.rename and lane.updateAppearance (color first, icon/tags next) behind a '/lane rename', '/lane color', or a Manage-lane right pane, plus a drawer hotkey. Fixes the most glaring management gaps and the misleading /rename-only-renames-chats trap (commands.ts:31, app.tsx:5943).", + "effort": "M", + "priority": "P0" + }, + { + "title": "Stream delete teardown progress and add a pre-flight risk summary", + "description": "Call lane.getDeleteRisk when opening the delete form to show dirty/unpushed/running-process counts, and subscribe to lanes.delete.event to render a live step strip instead of the static 'Deleting...' body (app.tsx:6682). Makes the existing delete safer and on par with ManageLaneDialog.", + "effort": "M", + "priority": "P1" + }, + { + "title": "Add child-lane creation and a base-branch picker to the create flow", + "description": "Extend openNewLaneForm (app.tsx:4357) into modes (primary/child/import) calling lane.createChild and lane.importBranch, with a parent-lane list picker and a real branch list (current marker) instead of the free-text 'Base branch' field. Brings create toward CreateLaneDialog parity.", + "effort": "L", + "priority": "P1" + }, + { + "title": "Add archive/unarchive and an archived-lanes view", + "description": "Add archive/unarchive actions and a way to list includeArchived lanes (a /lanes archived toggle), since adeApi.ts:55 hardcodes includeArchived:false. Lets TUI users declutter without hard-deleting and restore lanes archived on desktop.", + "effort": "M", + "priority": "P1" + }, + { + "title": "Add per-lane drawer affordances (hotkey/context menu) for manage/delete/rename", + "description": "The only DrawerLaneAction is 'new-lane' (app.tsx:206); add single-key actions on a selected lane and/or a right-click context menu so management does not require slash commands, mirroring desktop LaneContextMenu.", + "effort": "M", + "priority": "P2" + }, + { + "title": "Interactive reparent picker with pre-apply rebase guard", + "description": "Replace typed /reparent with a right-pane parent picker (descendants excluded — reparentTargetsForLane already computes this, app.tsx:793) plus optional base override and a dirty/rebase-in-progress block, surfacing the 'runs git rebase' caution at the confirm step. Keep the typed form as a power-user fallback.", + "effort": "M", + "priority": "P2" + }, + { + "title": "Surface stack rebase suggestions / behind-parent state", + "description": "Consume rebaseSuggestion/autoRebaseStatus/parentStatus already present on the lane snapshot to show a 'behind parent' chip and sync affordance in the drawer — partial parity with LaneRebaseBanner without building the full rebase-run UI.", + "effort": "L", + "priority": "P2" + }, + { + "title": "Optional: Linear issue link at lane create", + "description": "Allow attaching a Linear issue (lane.linkLinearIssues) during/after create so the lane.linearIssue metadata the TUI already reads can be set, completing create-flow parity.", + "effort": "M", + "priority": "P2" + } + ], + "_key": "lanes", + "_kind": "parity" +} +``` + +
+ +
Grid / multi-chat view (parity) + +```json +{ + "dimension": "Grid / multi-chat view", + "summary": "The TUI multi-chat grid is functional: up to 6 tiles, add via addMode picker (Enter), remove via Ctrl+W or clicking the tile's ×, forward Tab cycles focus, click-to-focus and per-tile keyboard scroll all work, and there is a minimap. Verified gaps versus desktop: (1) Shift+Tab in a grid is genuinely mis-routed to cyclePermission instead of reverse tile focus (real P0 bug, app.tsx 8099/8344); (2) no lane accent color on tile chrome — ChatView only tints borders/header cyan/gray, never by lane.color, while desktop uses laneAccentColor; (3) single streaming dot only, no multi-state status glyph (awaiting/error/ended); (4) no spatial arrow navigation across the 2D grid (arrows scroll the focused tile); (5) fixed PATTERNS with floor-division leaves a small dead margin and there is no resize/reorder/preset/persistence. Two draft claims were overstated and corrected here: the remove-× hit rect actually covers the glyph (no desync), and the minimap row split matches the current fixed patterns (its real gap is being non-clickable, not geometry mismatch). Per-tile keyboard scroll routing already works via setChatScrollOffset -> scrollBySessionId; the remaining scroll gap is wheel-over-tile (wheel always scrolls the focused tile, not the tile under the cursor).", + "tuiStatus": [ + { + "feature": "Open / add / remove panes", + "status": "partial", + "details": "Six-tile cap via slice(0,6) and asTileCount in multiChatLayout.ts; add via addMode picker (confirmAddMode/addTileToGrid in app.tsx ~4061) which only accepts existing tileable agent chats; remove via Ctrl+W (app.tsx 8094) and the × hit rect (MultiChatGrid 101-111). Desktop is uncapped via packed/tree tiling and can tile non-chat and closed sessions.", + "tuiFiles": [ + "apps/ade-cli/src/tuiClient/app.tsx", + "apps/ade-cli/src/tuiClient/components/MultiChatGrid.tsx", + "apps/ade-cli/src/tuiClient/multiChatLayout.ts" + ] + }, + { + "feature": "Focus navigation", + "status": "partial", + "details": "Forward Tab cycles focusedIndex (app.tsx 8099, matches key.tab && !key.shift only). Shift+Tab is unhandled in the grid branch and falls through to the global handler at 8344 which calls cyclePermission(1). Click-to-focus works (onFocusTile/focusMultiViewTile). No spatial up/down/left/right tile navigation — arrows scroll the focused tile.", + "tuiFiles": [ + "apps/ade-cli/src/tuiClient/app.tsx" + ] + }, + { + "feature": "Per-pane status", + "status": "partial", + "details": "ChatView renders only a single streamingDot (' ●' when streaming) and an interrupted row; header color is cyan/t4/t2 by focus/ended only. No multi-state status glyph (streaming/awaiting/error/ended) per tile, versus desktop's spinning sessionStatusDot.", + "tuiFiles": [ + "apps/ade-cli/src/tuiClient/components/ChatView.tsx" + ] + }, + { + "feature": "Lane accent / tile chrome", + "status": "missing", + "details": "ChatView receives the lane prop but uses it only for BootHero. tileBorderColor and headerColor are cyan/borderFocused/border and cyan/t4/t2 — never lane.color. Desktop tints each tile by laneAccentColor with a SessionLaneHeaderLabel.", + "tuiFiles": [ + "apps/ade-cli/src/tuiClient/components/ChatView.tsx", + "apps/ade-cli/src/tuiClient/components/MultiChatGrid.tsx" + ] + }, + { + "feature": "Minimap", + "status": "partial", + "details": "gridMiniMapText renders a static ▢/▣ row split that does match the current fixed patterns (2/3 for 5, 3/3 for 6, 2/2 for 4). It is not clickable, does not highlight streaming tiles, and is a separate code path from computeTileRects so it will drift if patterns change.", + "tuiFiles": [ + "apps/ade-cli/src/tuiClient/components/GridMiniMap.tsx" + ] + }, + { + "feature": "Responsiveness / geometry", + "status": "partial", + "details": "computeTileRects uses Math.floor(width/cols) and Math.floor(height/rows), leaving up to cols-1 columns and rows-1 rows of unused terminal as dead margin. canRenderMultiChatGrid falls back to a single focused tile below 30x8 per cell. No remainder spreading.", + "tuiFiles": [ + "apps/ade-cli/src/tuiClient/multiChatLayout.ts", + "apps/ade-cli/src/tuiClient/components/MultiChatGrid.tsx" + ] + }, + { + "feature": "Resize / reorder / presets / persistence", + "status": "missing", + "details": "Fixed PATTERNS (1-6), append-only tile order held in in-memory MultiViewState, no grep hit for persisting/serializing multiView. Desktop has drag-resize, ArrangeMenu (Auto/Rows/Columns), ViewModeToggle, reorder, and persistence.", + "tuiFiles": [ + "apps/ade-cli/src/tuiClient/multiChatLayout.ts", + "apps/ade-cli/src/tuiClient/app.tsx" + ] + }, + { + "feature": "Per-tile scroll routing", + "status": "partial", + "details": "Keyboard scroll (arrows, PgUp/PgDn, Home/End, Ctrl+U/D) already routes to the focused tile: setChatScrollOffset (app.tsx 2468) writes to scrollBySessionId keyed by focusedSessionIdForMultiView. Gap: the mouse wheel (app.tsx 8039) also goes through setChatScrollOffset, so it scrolls the focused tile rather than the tile under the cursor.", + "tuiFiles": [ + "apps/ade-cli/src/tuiClient/app.tsx", + "apps/ade-cli/src/tuiClient/components/MultiChatGrid.tsx" + ] + } + ], + "recommendations": [ + { + "title": "Fix Shift+Tab in grid to focus the previous tile", + "description": "Add a `pane === 'chat' && multiViewRef.current && key.tab && key.shift` branch directly above the existing forward-Tab branch at app.tsx 8099 that decrements focusedIndex modulo tiles.length. Without it, Shift+Tab in a grid silently changes the agent permission via cyclePermission at 8344.", + "effort": "S", + "priority": "P0" + }, + { + "title": "Lane accent color on tile chrome", + "description": "Tint MultiChatTile border and header by lane.color in ChatView (currently only cyan/gray by focus). Mirror desktop laneAccentColor so co-located lanes are distinguishable at a glance. Keep the focused-tile double border on top of the accent.", + "effort": "M", + "priority": "P0" + }, + { + "title": "Multi-state per-tile status glyph", + "description": "Replace the single streaming dot in ChatView (line 1604) with a state glyph: spinner when streaming, amber when awaiting input, red on error, dim when ended. Feed from streamingBySessionId/interruptedBySessionId and session.status already threaded into MultiChatGrid.", + "effort": "M", + "priority": "P1" + }, + { + "title": "Spatial arrow tile navigation", + "description": "When focus is on the grid (no inline row / no scroll intent), let arrow keys pick the nearest tile by direction using computeTileRects centers, wired to focusMultiViewTile. Today arrows only scroll the focused tile, so there is no 2D movement.", + "effort": "M", + "priority": "P1" + }, + { + "title": "Remainder-aware geometry and clickable minimap", + "description": "Spread the floor() remainder across the last column/row in computeTileRects to remove the dead margin, then rebuild GridMiniMap from the same rects with clickable cells and a streaming highlight (it is currently a separate static heuristic).", + "effort": "M", + "priority": "P2" + }, + { + "title": "Presets toggle and persistence", + "description": "Add an Auto/Rows/Columns/single-tile cycle and serialize MultiViewState (tiles + focusedIndex) so the grid restores on launch. Desktop persists arrangement; the TUI keeps it only in memory.", + "effort": "M", + "priority": "P2" + }, + { + "title": "Wheel scrolls tile under cursor; tile non-chat/closed sessions", + "description": "Route the wheel (app.tsx 8039) to the tile whose hit rect contains mouse.x/mouse.y rather than always the focused tile. Separately, allow previewing closed/non-chat sessions like desktop instead of the 'only agent chats can be split' rejection in addTileToGrid (4063).", + "effort": "L", + "priority": "P2" + } + ], + "bugs": [ + { + "title": "Shift+Tab in a grid changes agent permission instead of focusing previous tile", + "severity": "medium", + "description": "app.tsx 8099 matches only `key.tab && !key.shift`, so Shift+Tab while a grid is open is not handled by the grid branch and falls through to the global handler at 8344 which calls cyclePermission(1). There is no reverse-focus path at all.", + "file": "apps/ade-cli/src/tuiClient/app.tsx" + }, + { + "title": "Floor-division dead margin in tile geometry", + "severity": "low", + "description": "computeTileRects uses Math.floor(width/cols) and Math.floor(height/rows) without distributing the remainder, leaving up to cols-1 unused columns and rows-1 unused rows as a blank strip on wide/odd terminals.", + "file": "apps/ade-cli/src/tuiClient/multiChatLayout.ts" + }, + { + "title": "Mouse wheel scrolls focused tile, not the tile under the cursor", + "severity": "low", + "description": "The wheel handler (app.tsx 8039) calls setChatScrollOffset, which at 2468-2477 always targets focusedSessionIdForMultiView. Hovering a non-focused tile and scrolling moves the focused tile instead, which is surprising for a grid.", + "file": "apps/ade-cli/src/tuiClient/app.tsx" + }, + { + "title": "Non-chat / closed sessions cannot be tiled", + "severity": "low", + "description": "addTileToGrid (app.tsx 4063) rejects any sessionId not present in sessionsRef with a transient notice, so closed or non-agent-chat sessions can never be split, unlike desktop which previews them.", + "file": "apps/ade-cli/src/tuiClient/app.tsx" + }, + { + "title": "Minimap is a separate code path from real geometry", + "severity": "low", + "description": "GridMiniMap.gridMiniMapText derives its row split independently of computeTileRects. It happens to match the current fixed PATTERNS, but any pattern change (or remainder packing) would silently desync the minimap. Not a present-day visual mismatch.", + "file": "apps/ade-cli/src/tuiClient/components/GridMiniMap.tsx" + } + ], + "polishOpportunities": [ + { + "title": "Reverse and spatial tile navigation", + "description": "Shift+Tab to the previous tile plus directional arrow movement across the 2D grid; today only forward Tab and click select a tile.", + "impact": "high" + }, + { + "title": "Per-lane color accent on tiles", + "description": "Tint each tile border and header by lane color for at-a-glance distinction, matching desktop laneAccentColor. ChatView currently only colors by focus state.", + "impact": "high" + }, + { + "title": "Multi-state animated status per tile", + "description": "Spinner for streaming, amber awaiting, red error, dim ended — instead of the single ● streaming dot.", + "impact": "high" + }, + { + "title": "Clickable minimap rebuilt from real rects", + "description": "Drive the minimap from computeTileRects, highlight the streaming tiles, and make cells clickable to focus.", + "impact": "medium" + }, + { + "title": "Remainder-aware geometry and add/remove transition", + "description": "Fill the terminal with no blank strip and flash the tile border on add/remove for feedback.", + "impact": "medium" + }, + { + "title": "Wheel-targets-tile-under-cursor", + "description": "Route the wheel to the tile beneath the pointer rather than the focused tile. Keyboard per-tile scroll already works.", + "impact": "medium" + } + ], + "desktopCapabilities": [ + { + "feature": "Tabs/grid toggle and Arrange presets", + "description": "ViewModeToggle and ArrangeMenu (Auto/Rows/Columns) via buildWorkSessionTilingTree, persisted.", + "keyFiles": [ + "apps/desktop/src/renderer/components/terminals/WorkViewArea.tsx", + "apps/desktop/src/renderer/components/terminals/workSessionTiling.ts" + ] + }, + { + "feature": "Resizable tree and packed tiling", + "description": "react-resizable-panels tree with gutters, plus a packed grid with pointer-drag resize, neighbor reflow, width-based auto column packing, and persistence.", + "keyFiles": [ + "apps/desktop/src/renderer/components/ui/PaneTilingLayout.tsx", + "apps/desktop/src/renderer/components/terminals/PackedSessionGrid.tsx", + "apps/desktop/src/renderer/components/terminals/packedSessionGridMath.ts" + ] + }, + { + "feature": "Lane chrome, status dots, reorder, previews", + "description": "Per-tile laneAccentColor, SessionLaneHeaderLabel, spinning sessionStatusDot, X close, drag reorder/split, and closed-session snapshot previews.", + "keyFiles": [ + "apps/desktop/src/renderer/components/terminals/WorkViewArea.tsx", + "apps/desktop/src/renderer/components/terminals/LaneChip.tsx", + "apps/desktop/src/renderer/components/terminals/SessionCard.tsx" + ] + } + ], + "_key": "grid", + "_kind": "parity" +} +``` + +
+ +
Runtimes / providers (all of them) (parity) + +```json +{ + "dimension": "Runtimes / providers (all of them)", + "summary": "Verified against apps/ade-cli/src/tuiClient/{app.tsx, planMode.ts, pendingInput.ts, components/FooterControls.tsx, components/ApprovalPrompt.tsx}. The TUI launches and drives all five real runtimes (claude/codex/cursor/droid/opencode) plus ollama/lmstudio (mapped to opencode) via a footer inline control row and a fuller Setup pane, with per-provider permission semantics shared with desktop cliLaunch.ts. copilot/shell are correctly non-selectable (PR-bot identity / terminal profile), rendered only as RightPane exec glyphs. Confirmed real divergences: (1) Codex 'custom' approval x sandbox combos round-trip-lose on cycle — resolveCodexPreset returns 'custom' but cyclePermission does findIndex('custom')=-1 -> Math.max(0,-1)=0, so the next press jumps to CODEX_PRESETS[1]='edit', and there are no independent approval/sandbox cells to author or preserve custom; (2) the footer left/right arrow order is the hardcoded [provider,model,reasoning,permission,subagents] filtered only for provider-locked and subagents-visible — 'reasoning' is NOT filtered when the model has no tiers (cycleReasoning early-returns and FooterControls only renders the cell when reasoningEffort is truthy), producing a dead/invisible focus stop; (3) 'fast' is absent from the InlineRowCell union and the arrow order, AND its mouse hit-test is only registered when codexFastMode is already ON (line 9435 `if (modelState.codexFastMode)`), so fast cannot be enabled by mouse at all — only the chat:fastMode keybinding toggles it; (4) plan_approval is force-classified highStakes in pendingInput.ts, routing it to ApprovalPrompt's typed 'approve'/'deny' path (chips hidden) instead of a dedicated plan card; (5) orchestration model_selection has no handler anywhere in the TUI. Corrections to the draft: a 3s transient mode-change banner already exists (modeChangeNotice at app.tsx:2613/10257) rendering `summary mode · description` with modeAccentColor border on user-initiated permission/reasoning changes — so the 'transient hint line' polish is largely already shipped; and cursorModeSnapshot IS partially consumed (read transiently to seed cursorModeId via cursorModeSnapshot?.currentModeId at app.tsx:4927) though it is not persisted on AdeCodeModelState (types.ts has only cursorModeId) and cycling still walks the static CURSOR_AVAILABLE_MODE_IDS, ignoring availableModeIds and never exposing per-mode config (reasoning/thinking) options. Provider branding/glyphs are well done and match Claude Code's aesthetic.", + "tuiStatus": [ + { + "feature": "Launch & drive claude/codex/cursor/droid/opencode (+ollama/lmstudio)", + "status": "full", + "details": "launchPromptInBackground passes the full normalized control set (modelId, reasoningEffort, permissionMode, interactionMode, claudePermissionMode, codexApprovalPolicy, codexSandbox, codexConfigSource, opencodePermissionMode, droidPermissionMode, cursorModeId, cursorConfigValues) to createChatSession; ollama/lmstudio map to opencode. Claude also gets a PTY terminal session for terminal-control mode.", + "tuiFiles": [ + "apps/ade-cli/src/tuiClient/app.tsx", + "apps/ade-cli/src/tuiClient/adeApi.ts" + ], + "gap": "None for the core launch path." + }, + { + "feature": "Claude permission modes (5-way)", + "status": "full", + "details": "CLAUDE_PERMISSION_OPTIONS default/auto/plan/acceptEdits/bypassPermissions; cyclePermission (app.tsx:7242) maps each to interactionMode/claudePermissionMode/permissionMode identically to desktop; permissionSummary surfaces plan/auto/accept edits/bypass/default.", + "tuiFiles": [ + "apps/ade-cli/src/tuiClient/app.tsx" + ], + "gap": "Footer permission cell shows only the mode word in a generic accent (theme.color.t3 base, planMode/violet when focused); no per-mode dot/tone the way desktop colors each mode. A transient mode-change banner does show the description for 3s on cycle (partial coverage)." + }, + { + "feature": "Codex approval/sandbox presets", + "status": "partial", + "details": "CODEX_PRESETS default/edit/plan/full-auto/config-toml; resolveCodexPreset and codexPresetPatch mirror desktop preset mapping; permissionSummary returns 'custom' for non-matching combos.", + "tuiFiles": [ + "apps/ade-cli/src/tuiClient/app.tsx", + "apps/ade-cli/src/tuiClient/planMode.ts" + ], + "gap": "No way to author or preserve a Codex 'custom' approval x sandbox combo: cyclePermission only walks the 5 presets and the footer never exposes approval-policy/sandbox as independent cells (desktop's codexCustomSummary path absent). Cycling a 'custom' session lands on 'edit' (index -1 -> 0 -> +1) and discards the combo." + }, + { + "feature": "Cursor modes", + "status": "partial", + "details": "cyclePermission cycles the static CURSOR_AVAILABLE_MODE_IDS = agent/ask/plan/full-auto, mapping each to a permissionMode. cursorModeSnapshot IS read transiently from the incoming session config (app.tsx:334) and used to seed cursorModeId via cursorModeSnapshot.currentModeId (app.tsx:4927). cursorConfigValues is stored on modelState and forwarded to createChatSession.", + "tuiFiles": [ + "apps/ade-cli/src/tuiClient/app.tsx", + "apps/ade-cli/src/tuiClient/types.ts", + "apps/desktop/src/shared/cursorModes.ts" + ], + "gap": "cursorModeSnapshot is not persisted on AdeCodeModelState (types.ts has only cursorModeId), so cycling ignores availableModeIds and any runtime-advertised mode beyond the static four is unreachable; per-mode config options (reasoning/thinking via cursorExtraOptions) are never rendered or editable — cursorConfigValues is write-through only. Diverges from desktop which builds the picker from the live snapshot." + }, + { + "feature": "Droid autonomy levels", + "status": "full", + "details": "DROID_PERMISSION_OPTIONS read-only/auto-low/auto-medium/auto-high cycle and map via droidPermissionToLegacy to legacy permissionMode; values match desktop and cliLaunch settings.json encoding.", + "tuiFiles": [ + "apps/ade-cli/src/tuiClient/app.tsx" + ], + "gap": "No per-option detail strings on the footer cell (the transient mode-change banner does show modeDescription on cycle); minor." + }, + { + "feature": "OpenCode permission modes", + "status": "full", + "details": "OPENCODE_PERMISSION_OPTIONS plan/edit/full-auto/config-toml cycle and set both opencodePermissionMode and permissionMode; matches desktop.", + "tuiFiles": [ + "apps/ade-cli/src/tuiClient/app.tsx" + ], + "gap": "None functionally." + }, + { + "feature": "Reasoning effort control", + "status": "partial", + "details": "cycleReasoning (app.tsx:7225) uses modelReasoningEfforts (model.reasoningEfforts -> descriptor.reasoningTiers -> EFFORTS fallback only for codex) and early-returns when no efforts exist. FooterControls renders the reasoning cell only when reasoningEffort is truthy.", + "tuiFiles": [ + "apps/ade-cli/src/tuiClient/app.tsx", + "apps/ade-cli/src/tuiClient/components/FooterControls.tsx" + ], + "gap": "The hardcoded EFFORTS fallback omits 'minimal' that some OpenAI tiers expose and is reached only for codex; for cursor/opencode reasoning is never surfaced even when the runtime supports it (no descriptor tiers). The reasoning cell stays in the arrow-cycle order even when no tiers exist or the cell isn't rendered — a dead/invisible focus stop." + }, + { + "feature": "Fast mode", + "status": "partial", + "details": "chat:fastMode keybinding (app.tsx:7550) is model-aware: toggles codexFastMode when serviceTiers include 'fast'/modelSupportsFastMode; for Claude with no fast tier submits /fast; otherwise a notice. FooterControls renders a flat warning-colored 'fast' word only when fastMode is on.", + "tuiFiles": [ + "apps/ade-cli/src/tuiClient/app.tsx", + "apps/ade-cli/src/tuiClient/components/FooterControls.tsx" + ], + "gap": "'fast' is not in the InlineRowCell union nor the arrow-key order, so keyboard-only users can't reach it via the inline row. Worse, the mouse hit-test is registered only when codexFastMode is already ON (app.tsx:9435 `if (modelState.codexFastMode)`), so fast cannot be ENABLED by mouse — only disabled — and the only enable path is the chat:fastMode keybinding." + }, + { + "feature": "Plan mode detection", + "status": "full", + "details": "planMode.ts isPlanMode handles claude (plan permission or interactionMode), codex (on-request + read-only), opencode (plan), droid (read-only), cursor (plan); footer tints the accent with theme.color.planMode.", + "tuiFiles": [ + "apps/ade-cli/src/tuiClient/planMode.ts", + "apps/ade-cli/src/tuiClient/components/FooterControls.tsx" + ], + "gap": "None." + }, + { + "feature": "Approval prompts (tool approve/deny, high-stakes, question)", + "status": "partial", + "details": "ApprovalPrompt renders APPROVAL/HIGH-STAKES/INPUT REQUESTED, a/d chips, option lists, typed approve/deny for high-stakes, and footer hints/hit-test. latestPendingApproval normalizes approval/permissions/plan_approval/question kinds and flags permissions+plan_approval+heuristic-matched text as highStakes.", + "tuiFiles": [ + "apps/ade-cli/src/tuiClient/components/ApprovalPrompt.tsx", + "apps/ade-cli/src/tuiClient/pendingInput.ts", + "apps/ade-cli/src/tuiClient/app.tsx" + ], + "gap": "plan_approval is forced into the high-stakes typed path (pendingInput.ts:53-57): showChips=false, user must type 'approve'/'deny'. Desktop shows ChatProposedPlanCard with formatted plan body and one-key Approve/Reject. The TUI shows only the generic description string, no formatted plan card." + }, + { + "feature": "Orchestration model-selection pending input", + "status": "missing", + "details": "No model_selection handling anywhere in the TUI (grep across apps/ade-cli/src finds no model_selection or ChatModelSelection); pendingInput.ts only emits 'approval'/'question'.", + "tuiFiles": [ + "apps/ade-cli/src/tuiClient/pendingInput.ts", + "apps/ade-cli/src/tuiClient/app.tsx" + ], + "gap": "When an orchestration session requests a model pick, desktop shows ChatModelSelectionPendingCard; the TUI would at best render a generic question (if delivered as approval_request) or not surface the picker at all." + }, + { + "feature": "copilot / shell as runtimes", + "status": "full", + "details": "Correctly NOT selectable: provider lists exclude both; RightPane ExecGlyph renders '$' for shell and a filled circle for copilot only as activity-row decoration.", + "tuiFiles": [ + "apps/ade-cli/src/tuiClient/components/RightPane.tsx", + "apps/ade-cli/src/tuiClient/theme.ts" + ], + "gap": "No standalone shell-terminal launcher (desktop cliLaunch supports a 'shell' profile); minor for a chat-centric TUI." + }, + { + "feature": "Per-provider branding (glyph/color/label)", + "status": "full", + "details": "theme.PROVIDER_THEME gives each provider a distinct glyph/color (claude sparkle, codex, cursor, droid, opencode, ollama, lmstudio) used by FooterControls.Cell, matching Claude Code's sparkle aesthetic.", + "tuiFiles": [ + "apps/ade-cli/src/tuiClient/theme.ts", + "apps/ade-cli/src/tuiClient/components/FooterControls.tsx" + ], + "gap": "None." + } + ], + "bugs": [ + { + "title": "Codex 'custom' approval/sandbox combos are silently lost when cycling permission", + "severity": "medium", + "file": "apps/ade-cli/src/tuiClient/app.tsx", + "description": "resolveCodexPreset can return 'custom', but cyclePermission (line 7237) does CODEX_PRESETS.findIndex(e=>e==='custom') === -1, then Math.max(0,-1)=0, so the next press lands on CODEX_PRESETS[1]='edit'. A session arriving in a custom approval x sandbox state (e.g. on-failure+workspace-write, never+read-only) cannot be re-selected or even nudged without discarding the combo, and there is no UI to author a custom combination (no independent approval-policy/sandbox cells like desktop's codexCustomSummary)." + }, + { + "title": "Fast mode is unreachable by mouse to enable, and absent from the keyboard inline-row order", + "severity": "medium", + "file": "apps/ade-cli/src/tuiClient/app.tsx", + "description": "The 'fast' footer label renders only when codexFastMode is true (FooterControls.tsx:155) and its mouse hit-test is registered only inside `if (modelState.codexFastMode)` (app.tsx:9435). So when fast is OFF there is neither a visible 'fast' affordance nor a clickable target — clicking can only turn fast OFF, never ON. Combined with 'fast' being absent from the InlineRowCell union and the arrow-key order [provider,model,reasoning,permission,subagents], the ONLY way to enable fast mode is the chat:fastMode keybinding. (Sharper than the draft, which said fast is mouse-clickable.)" + }, + { + "title": "Footer reasoning cell is a focusable arrow-key stop even when unsupported or not rendered", + "severity": "low", + "file": "apps/ade-cli/src/tuiClient/app.tsx", + "description": "The inline-row arrow order (app.tsx:8126 / 9496) is the fixed [provider,model,reasoning,permission,subagents] filtered only for provider-locked and subagents-visible. When the active model has no reasoning tiers (cursor/opencode/claude default), cycleReasoning early-returns (line 7227) and FooterControls renders the reasoning Cell only when reasoningEffort is truthy, so arrow-focusing 'reasoning' produces an invisible focus with no bracket highlight and no effect. The order should be derived from which cells are actually applicable/visible for the current provider." + }, + { + "title": "Cursor dynamic modes and per-mode config options are not driven by the runtime snapshot", + "severity": "medium", + "file": "apps/ade-cli/src/tuiClient/app.tsx", + "description": "cursorModeSnapshot is read transiently (app.tsx:334) and used only to seed cursorModeId via cursorModeSnapshot.currentModeId (app.tsx:4927); it is NOT persisted on AdeCodeModelState (types.ts:78 has only cursorModeId). cyclePermission (line 7274) walks the static CURSOR_AVAILABLE_MODE_IDS, so runtime-advertised modes beyond agent/ask/plan/full-auto (availableModeIds) are unreachable, and per-mode config (reasoning/thinking via cursorExtraOptions) is never rendered or editable — cursorConfigValues is write-through only. Diverges from desktop which builds the picker from the live snapshot. (Draft's claim that the state has no cursorModeSnapshot at all is imprecise: it is read transiently but not stored/cycled.)" + }, + { + "title": "plan_approval routed through high-stakes typed-approval instead of a plan card", + "severity": "low", + "file": "apps/ade-cli/src/tuiClient/pendingInput.ts", + "description": "latestPendingApproval flags request.kind==='plan_approval' as highStakes (lines 53-57), which makes ApprovalPrompt hide the a/d chips (showChips=false at ApprovalPrompt.tsx:42) and require typing 'approve'/'deny'. Desktop shows ChatProposedPlanCard with the plan body and one-click Approve/Reject. The TUI loses the formatted plan and the low-friction single-key approve; plan text appears only as the generic description string." + } + ], + "polishOpportunities": [ + { + "title": "Tone-coded permission/provider footer cells (the transient banner already covers description-on-cycle)", + "description": "A 3s mode-change banner (app.tsx:2613/10257) already shows `summary mode · description` with a modeAccentColor border on user-initiated permission/reasoning changes, so the 'transient hint line' is largely shipped. The remaining gap is the steady-state footer permission cell, which uses generic theme.color.t3 / planMode-violet rather than the per-mode tone desktop applies (Claude green/amber/blue/purple/red dots). Reuse modeAccentColor to tint the permission Cell itself and show modeDescription when the cell is focused-but-not-yet-cycled (the banner only fires after a change).", + "impact": "low" + }, + { + "title": "Codex approval x sandbox detail readout on the focused permission cell", + "description": "Even without full custom-mode authoring, when the codex permission cell is focused show the resolved approval policy and sandbox (e.g. 'on-request / workspace-write'), mirroring desktop's codexCustomSummary, so the single 'plan/edit/full-auto/custom' word isn't hiding the safety posture. Especially important for the 'custom' label, which currently conveys nothing.", + "impact": "medium" + }, + { + "title": "Render fast as a real focusable cell, dimmed when unsupported", + "description": "'fast' renders as flat warning text only when on and has no enable affordance. Add it to the focusable cell order when fastSupported, render it as a real [fast] Cell (on/off legible), register its mouse hit-test regardless of current state, and dim/hide it when the model has no fast tier — so keyboard and mouse users get the same affordance as reasoning/permission.", + "impact": "medium" + }, + { + "title": "Plan-approval card in the transcript", + "description": "Render plan_approval as a bordered plan card (planMode accent border, plan body, [a] approve / [d] reject chips) instead of the high-stakes typed prompt, matching desktop's ChatProposedPlanCard and giving plan mode a distinct moment.", + "impact": "medium" + }, + { + "title": "Animated provider-switch transition", + "description": "cycleProvider swaps the footer glyph+label instantly. A brief color cross-fade or one-frame glyph 'pop' on provider change would reinforce the strong per-provider brand colors in theme.PROVIDER_THEME.", + "impact": "low" + } + ], + "recommendations": [ + { + "title": "Derive the footer inline-cell order from applicable/visible cells and make fast reachable", + "description": "Filter 'reasoning' out of the arrow order (app.tsx:8126/9496) when modelReasoningEfforts is empty or the cell isn't rendered, and add 'fast' to the order + register its mouse hit-test unconditionally when fastSupported (not only when codexFastMode is on). Fixes the dead-focus-stop AND the mouse-can't-enable-fast / keyboard-can't-reach-fast bugs together. Highest leverage, smallest change.", + "effort": "S", + "priority": "P1" + }, + { + "title": "Expose Codex approval policy + sandbox so 'custom' round-trips", + "description": "At minimum, stop cyclePermission from silently discarding a 'custom' session state and show the resolved approval/sandbox in a focused-cell hint; ideally add independent approval-policy and sandbox cycling (or a custom sub-editor) so the TUI can author and preserve any desktop approval x sandbox combination.", + "effort": "M", + "priority": "P1" + }, + { + "title": "Drive Cursor mode and per-mode config from the runtime snapshot", + "description": "Persist cursorModeSnapshot on AdeCodeModelState (today it is only read transiently to seed cursorModeId), build the mode cycle from availableModeIds (falling back to CURSOR_AVAILABLE_MODE_IDS), and render/cycle cursorExtraOptions (reasoning/thinking toggles) so Cursor reaches desktop parity instead of a static 4-mode list and write-only cursorConfigValues.", + "effort": "L", + "priority": "P2" + }, + { + "title": "Render plan_approval as a dedicated plan card with one-key approve/reject", + "description": "Stop classifying plan_approval as high-stakes typed approval in pendingInput.ts; add a ChatProposedPlanCard-equivalent in the TUI transcript with [a]/[d] chips and formatted plan body, matching desktop and reducing friction for the most common plan-mode interaction.", + "effort": "M", + "priority": "P2" + }, + { + "title": "Handle orchestration model_selection pending inputs", + "description": "Add a TUI model-selection pending card (decode role/tag/suggested/availableModels) so orchestration runs that ask the user to pick a model are drivable from the TUI, matching desktop's ChatModelSelectionPendingCard.", + "effort": "M", + "priority": "P2" + }, + { + "title": "Add per-mode tone/color to the steady-state permission and provider footer cells", + "description": "Reuse modeAccentColor to tint the permission Cell and surface modeDescription on focus (the existing banner only fires after a change). For codex, show the resolved approval/sandbox readout on focus. Brings the inline row toward Claude-Code-grade legibility without new state.", + "effort": "S", + "priority": "P2" + } + ], + "_key": "runtimes", + "_kind": "parity" +} +``` + +
+ +
Model picker (parity) + +```json +{ + "dimension": "Model picker", + "summary": "Verified against the cited TUI files and their desktop counterparts. The draft's core structural gaps hold up: the provider rail is seeded only from providers present in the discovered model list (modelPickerLayout.ts L130-141), whereas desktop force-seeds ALL_PROVIDER_FAMILIES (ModelPickerContent.tsx L214-223); and search drops shortName/aliases (modelPickerLayout.ts L212-224) that the desktop scorer indexes (modelPickerSearch.ts getModelPickerSearchFields). Both confirmed REAL and high-value. Keyboard/mouse is genuinely complete and well-built (full nav, rail/tab/row/star click targets, escape-clears-query). Corrections: (1) the draft over-claims a \"cost badge\" gap — desktop has NO cost/price badges, only a Cursor availability badge and an inline reasoning chip, so that recommendation is rescoped; (2) the local badge IS already rendered in the TUI (ModelPickerPane L100,131-133), so it is not a gap; (3) refresh IS triggered on rail/provider switch (app.tsx L8818-8823, L9803-9808) but no \"Checking provider…\" / stale state is threaded into the pane (modelPickerInputs omits it, RightPane.tsx L1042-1048) — so the loading-state gap is about visibility, not wiring. Added one minor honest keyboard gap (no PageUp/Down/Home/End paging of the 12-row window).", + "tuiStatus": [ + { + "feature": "Provider rail", + "status": "partial", + "details": "Seeded only from providers present in the filtered model list (modelPickerLayout.ts L130-141). Desktop force-seeds ALL_PROVIDER_FAMILIES (ModelPickerContent.tsx L220-223) so empty providers still appear with discovery/empty hints. Confirmed REAL: unconfigured dynamic providers (cursor/droid/ollama/lmstudio) are unreachable in the rail until they yield a model.", + "tuiFiles": [ + "apps/ade-cli/src/tuiClient/components/ModelPicker/modelPickerLayout.ts", + "apps/ade-cli/src/tuiClient/components/ModelPicker/ModelPickerPane.tsx" + ] + }, + { + "feature": "Favorites/Recents/sub-tabs", + "status": "full", + "details": "Star toggle persists via modelPickerStore (MAX_RECENTS=10 confirmed, modelPickerStore.ts L5,L131). Favorites/Recents rail entries + provider sub-tabs all present and match desktop grouping (modelPickerLayout.ts L177-206).", + "tuiFiles": [ + "apps/ade-cli/src/tuiClient/components/ModelPicker/modelPickerLayout.ts", + "apps/ade-cli/src/services/modelPickerStore.ts" + ] + }, + { + "feature": "Search (shortId/aliases)", + "status": "partial", + "details": "Confirmed REAL: scoreModelPickerSearch is called with only name/family/providerDisplayName/isFavorite (+subProvider), omitting shortName and aliases (modelPickerLayout.ts L212-224). Desktop indexes shortName + aliases (modelPickerSearch.ts getModelPickerSearchFields L122-131), so queries by short id or alias score null and the model drops from results.", + "tuiFiles": [ + "apps/ade-cli/src/tuiClient/components/ModelPicker/modelPickerLayout.ts" + ] + }, + { + "feature": "Discovery loading state", + "status": "missing", + "details": "Catalog refresh IS triggered on rail/provider switch and click (app.tsx L8818-8823, L9803-9808) and the RPC tracks a stale flag (app.tsx L4315-4327), but no refreshing/stale signal is threaded into modelPickerInputs (RightPane.tsx L1042-1048) — so the pane never shows a desktop-style 'Checking provider…' state during async discovery. Gap is visibility, not wiring.", + "tuiFiles": [ + "apps/ade-cli/src/tuiClient/components/RightPane.tsx", + "apps/ade-cli/src/tuiClient/app.tsx" + ] + }, + { + "feature": "Reasoning / fast-tier row chips", + "status": "missing", + "details": "AgentChatModelInfo already carries reasoningEfforts/serviceTiers (app.tsx L506-507), but ModelPickerEntry/ModelPickerPane never surface them. Desktop ModelListRow renders an inline reasoning chip (ModelListRow.tsx L52-67) and desktop has a fast-tier affordance; TUI rows show only name + active/local chips.", + "tuiFiles": [ + "apps/ade-cli/src/tuiClient/components/ModelPicker/types.ts", + "apps/ade-cli/src/tuiClient/components/ModelPicker/ModelPickerPane.tsx" + ] + }, + { + "feature": "Auth dots / sign-in / auth-only toggle", + "status": "missing", + "details": "modelPickerInputs carries no provider auth status (RightPane.tsx L1042-1048). Desktop threads providerAuthStatus, dims unauthed rows, shows a sign-in/setup banner, and offers an auth-only filter toggle (ModelPickerContent.tsx L106,156-168,527-543,613-638). None of this exists in the TUI rail/rows.", + "tuiFiles": [ + "apps/ade-cli/src/tuiClient/components/RightPane.tsx", + "apps/ade-cli/src/tuiClient/components/ModelPicker/ModelPickerPane.tsx" + ] + }, + { + "feature": "Row context menu / Cursor badge / sticky header", + "status": "missing", + "details": "Desktop ModelListRow has a context menu (Copy model id / set surface default / view docs, ModelListRow.tsx L48-50,167-303) and a Cursor availability badge ('CLI only'/'chat only', L14,84,235-240); ModelPickerContent uses sticky/scroll-aware pinned tab + active-model headers (ModelPickerContent.tsx L653,678). TUI has none. NOTE: desktop has NO cost/price badge, so that draft claim is dropped; the local badge IS already rendered in the TUI (ModelPickerPane L100,131-133) and is not a gap.", + "tuiFiles": [ + "apps/ade-cli/src/tuiClient/components/ModelPicker/ModelPickerPane.tsx" + ] + }, + { + "feature": "Keyboard navigation", + "status": "full", + "details": "Confirmed solid: up/down with re-derived layout, tab/shift-tab rail cycling (which also fires provider refresh), [ ] provider-tab cycling, enter to commit (available-only), f to favorite, / to enter search, backspace to shorten/exit, escape clears query then closes (app.tsx L8763-8870, resolveModelPickerEscape L218-224). Only minor miss: no PageUp/PageDown/Home/End paging for the 12-row window.", + "tuiFiles": [ + "apps/ade-cli/src/tuiClient/app.tsx" + ] + }, + { + "feature": "Mouse click targets", + "status": "full", + "details": "Confirmed: distinct click targets for search bar, each rail entry (with provider-refresh side effect), each provider tab, each star (zIndex 6) and each row (zIndex 5), windowed to the visible 12 rows (app.tsx L9790-9851). Star/row z-ordering is correct so the star toggle does not commit a selection.", + "tuiFiles": [ + "apps/ade-cli/src/tuiClient/app.tsx" + ] + } + ], + "recommendations": [ + { + "title": "Seed full provider rail and restore shortName/alias search", + "description": "Union providersPresent with the desktop ALL_PROVIDER_FAMILIES set (mapped to AdeCodeProvider) so unconfigured cursor/droid/ollama/lmstudio always appear with an empty-state hint, mirroring ModelPickerContent.tsx L214-223. Independently, plumb shortName (from descriptor.shortId) and aliases into the scoreModelPickerSearch item at modelPickerLayout.ts L212-224 so short-id/alias queries match. Both are small, localized, high-value parity fixes.", + "effort": "S", + "priority": "P0" + }, + { + "title": "Surface discovery loading + inline reasoning/fast chips", + "description": "Thread the existing catalog stale/refresh signal (app.tsx L4315-4327) into modelPickerInputs and render a 'Checking provider…' state in ModelPickerPane while a provider refresh is in flight. Extend ModelPickerEntry with reasoning effort + serviceTiers (already on AgentChatModelInfo, app.tsx L506-507) and render an inline reasoning chip / fast-tier marker per row like desktop ModelListRow.", + "effort": "M", + "priority": "P1" + }, + { + "title": "Plumb provider auth into rail/rows with auth-only toggle and sign-in hint", + "description": "Pass providerAuthStatus into modelPickerInputs; dim unauthed rows, add auth dots on rail entries, an auth-only filter toggle, and a sign-in/setup hint for the active unauthed provider, matching ModelPickerContent.tsx L527-638. Highest-value of the P2 cluster.", + "effort": "M", + "priority": "P2" + }, + { + "title": "Add row affordances: copy-id/set-default action, Cursor badge, sticky headers", + "description": "Add a row-level action (e.g. a key to copy model id / set surface default, mirroring ModelListRow context menu L167-303), render the Cursor availability badge ('CLI only'/'chat only', ModelListRow.tsx L84), and pin the provider-tab/active-model header while scrolling. Drop the cost-badge idea from the draft — desktop has none. Lower priority polish.", + "effort": "L", + "priority": "P2" + }, + { + "title": "Add PageUp/PageDown/Home/End paging to the model list", + "description": "The list windows 12 rows but only supports single-step up/down (app.tsx L8763-8802). Add page and home/end jumps for long provider lists. Minor, but cheap ergonomics.", + "effort": "S", + "priority": "P2" + } + ], + "_key": "model-picker", + "_kind": "parity" +} +``` + +
+ +
Chat management (parity) + +```json +{ + "dimension": "Chat management", + "summary": "The TUI covers the core chat lifecycle that matters in a terminal: create (drawer \"+ new chat\" / /new chat with a model-setup pane), rename (/rename + form), tag for Claude (/tag), multi-session-per-lane with a real grid (MultiChatGrid + computeTileRects, 1-6 tiles), full steer lifecycle (steer/cancel/edit/send/interrupt), interrupt/cancel, a context-usage TokenBar + token/cost summary in the footer, and a high-quality message renderer (markdown, syntax-highlighted code, tables, work-log grouping, file-change badges, plan/compaction/queued-steer blocks, selection/copy). It speaks the same JSON-RPC chat domain (connection.action(\"chat\", ...)) and reuses desktop shared types/aggregation logic, so rendered-content fidelity is strong.\n\nThe biggest parity gaps are in chat *management*, not chat *content*. Verified against source: the TUI has NO delete-chat and NO archive/unarchive, and adeApi.ts exposes none of those wrappers despite the runtime fully supporting them (agentChatService.deleteSession/archiveSession/unarchiveSession, reachable via the chat domain — the sync layer maps these to chat.delete/chat.archive/chat.unarchive, and cli.ts:4934 has a scripted \"chat delete\"). There is no message edit / rewind-to-message (desktop's onRewindFiles flow). Browsing/search is shallow: /chats is a static unfilterable list, /switch only resolves lanes (its help text falsely promises chat switching), and Ctrl+R \"history search\" only snapshots the current chat's prompt drafts as a one-shot static list with no incremental re-filter, does NOT insert the chosen result back into the composer, and its scope-cycle control is a dead \"not available yet\" stub.\n\nOne draft claim is overstated and corrected here: the drawer is NOT fully blind to awaiting-input — formatSessionLabel (format.ts:800) appends \" ?\" for awaitingInput and \" ●\" for active status, which ChatBlock renders. What is genuinely missing is session.completion, session.tag, time/status bucketing, an archived section, and a distinct colored wait glyph. The displaySessions filtering bug (no !archivedAt filter) is real and confirmed.", + "tuiStatus": [ + { + "feature": "Create chat", + "status": "full", + "details": "Drawer '+ new chat' row and /new chat both route to openNewChatSetup which opens a new-chat-setup right pane, then createChatSession posts chat createSession with provider/model/permission options. Multiple chats per lane are supported and listed.", + "tuiFiles": [ + "apps/ade-cli/src/tuiClient/app.tsx", + "apps/ade-cli/src/tuiClient/adeApi.ts", + "apps/ade-cli/src/tuiClient/components/Drawer.tsx" + ] + }, + { + "feature": "Rename chat", + "status": "full", + "details": "/rename with no args opens a rename form prefilled with the current title; with args it calls renameChat (chat updateSession with manuallyNamed:true, adeApi.ts:485) and refreshes. Matches desktop semantics.", + "tuiFiles": [ + "apps/ade-cli/src/tuiClient/app.tsx", + "apps/ade-cli/src/tuiClient/adeApi.ts" + ] + }, + { + "feature": "Tag chat", + "status": "partial", + "details": "/tag works via tagChat (chat updateSession with tag, adeApi.ts:493), gated to Claude only, matching desktop's single-tag pointer. But tags are never surfaced anywhere: formatSessionLabel (format.ts:800) omits tag, and the drawer ChatBlock and /chats list show only glyph+title+age+active-dot, so a set tag is invisible. No tag-based filtering.", + "tuiFiles": [ + "apps/ade-cli/src/tuiClient/app.tsx", + "apps/ade-cli/src/tuiClient/components/Drawer.tsx", + "apps/ade-cli/src/tuiClient/format.ts" + ] + }, + { + "feature": "Delete chat", + "status": "missing", + "details": "No delete path exists in the TUI. Confirmed: adeApi.ts has no deleteSession wrapper (the chat domain methods present are listSessions/getChatEventHistory/getSlashCommands/getContextUsage/createSession/sendMessage/steer/cancelSteer/editSteer/dispatchSteer/approveToolUse/respondToInput/interrupt/updateSession only), and grep of app.tsx shows no chat-delete handler. The runtime supports it (agentChatService.deleteSession at agentChatService.ts:21792; cli.ts:4934 scripted 'chat delete' calls chat/deleteSession; syncRemoteCommandService.ts:2189 maps chat.delete to deleteSession), but the interactive TUI cannot reach it. Users can only abandon chats, never remove them.", + "tuiFiles": [ + "apps/ade-cli/src/tuiClient/adeApi.ts", + "apps/ade-cli/src/tuiClient/app.tsx" + ] + }, + { + "feature": "Archive / unarchive chat", + "status": "missing", + "details": "No archive or unarchive in the TUI. Confirmed: adeApi.ts lacks archiveSession/unarchiveSession wrappers; the drawer has no archive action and no archived section. Worse, displaySessions (app.tsx:2841-2849) merges and sorts sessions by recency but never filters session.archivedAt, while desktop explicitly filters !session.archivedAt — so any session archived elsewhere (desktop, sync peer, scripted CLI via agentChatService.archiveSession at :21861) renders mixed into the live drawer/grid with no way to hide or restore it.", + "tuiFiles": [ + "apps/ade-cli/src/tuiClient/adeApi.ts", + "apps/ade-cli/src/tuiClient/app.tsx", + "apps/ade-cli/src/tuiClient/components/Drawer.tsx" + ] + }, + { + "feature": "Message edit / rewind to message", + "status": "missing", + "details": "No rewind or message-edit feature. Grep for rewind/editMessage/editUserMessage/resend across apps/ade-cli/src/tuiClient returns nothing. Desktop's onRewindFiles + RewindFilesConfirmDialog (restore working tree to a prior user message) has no TUI equivalent. Prompt-history recall (Up arrow) re-populates the composer but does not rewind state.", + "tuiFiles": [ + "apps/ade-cli/src/tuiClient/app.tsx", + "apps/ade-cli/src/tuiClient/components/ChatView.tsx" + ] + }, + { + "feature": "History browse & search", + "status": "partial", + "details": "Confirmed. /chats (app.tsx:6219) lists the active lane's chats as a static ●/○ list (switch-chat action), ignores any arg, no search/filter. /switch (app.tsx:6232) only resolves LANES by id/name — typing a chat name falls to 'No lane matched'. 'History search' (openHistorySearch app.tsx:7399) snapshots the composer text once into a lowercase query, renders a fixed list capped at 20 rows into a plain list pane with NO incremental re-filter; historySearch:next just re-opens the same snapshot, the accept/execute branch only calls focusChat() so selecting a result does NOT insert it back into the composer, and historySearch:cycleScope (app.tsx:7617) is a dead stub: addNotice('History search scope cycling is not available yet.'). No cross-chat or transcript-content search, no time bucketing.", + "tuiFiles": [ + "apps/ade-cli/src/tuiClient/app.tsx", + "apps/ade-cli/src/tuiClient/components/RightPane.tsx" + ] + }, + { + "feature": "Multiple sessions per lane (multi-chat grid)", + "status": "full", + "details": "MultiChatGrid + multiChatLayout.computeTileRects (signature accepts 1-6) support 1-6 tiles with per-count patterns, focus/tab cycling, add-chat (^g), close tile (^w), and a GridMiniMap. Each tile is a ChatView with its own header, streaming dot, and removable ×. MultiChatGrid is the only TUI surface wired to the hit-test registry (useHitTestTarget). Exceeds the desktop's single-active-tab model.", + "tuiFiles": [ + "apps/ade-cli/src/tuiClient/components/MultiChatGrid.tsx", + "apps/ade-cli/src/tuiClient/multiChatLayout.ts", + "apps/ade-cli/src/tuiClient/components/ChatView.tsx" + ] + }, + { + "feature": "Steering / edit / cancel staged message", + "status": "full", + "details": "Full steer lifecycle: steerChatMessage with fallback to sendMessage when no active turn, /steer (list), /steer cancel, /steer edit , and Claude-only /steer send and /steer interrupt via dispatchSteer (adeApi.ts:410-446). Queued steers render as a 'staged message · sends after turn' block (aggregate.ts queued-steer + ChatView queuedSteerRows). Parity with desktop steer chips, minus an inline (non-slash) edit affordance.", + "tuiFiles": [ + "apps/ade-cli/src/tuiClient/app.tsx", + "apps/ade-cli/src/tuiClient/adeApi.ts", + "apps/ade-cli/src/tuiClient/aggregate.ts" + ] + }, + { + "feature": "Interrupt / cancel turn", + "status": "full", + "details": "app:interrupt action calls interruptChat (chat interrupt, adeApi.ts:481) when a turn is visible, sets interrupted state and renders an 'Interrupted · chat to continue' row (ChatView modelInterruptedRows). Falls back to an info notice when nothing is running.", + "tuiFiles": [ + "apps/ade-cli/src/tuiClient/app.tsx", + "apps/ade-cli/src/tuiClient/components/ChatView.tsx" + ] + }, + { + "feature": "Context-usage display", + "status": "partial", + "details": "Footer shows a live TokenBar (10-cell, color thresholds) + percent + token summary driven by latestTokenStats.percent. /context (app.tsx:5814) is Claude-gated (returns '/context is only available for Claude chats.' for non-Claude at :5819-5821), fetches getContextUsage, and dumps a plaintext per-category table (formatContextUsage, app.tsx:738) into a details pane. Versus desktop this is text-only with no visual category breakdown, and non-Claude providers get no detailed breakdown even though the footer bar works for them.", + "tuiFiles": [ + "apps/ade-cli/src/tuiClient/components/FooterControls.tsx", + "apps/ade-cli/src/tuiClient/app.tsx" + ] + }, + { + "feature": "Token / cost stats", + "status": "full", + "details": "latestTokenStats parses tokens, codex_token_usage, and done.usage into input/output/cache-read/cost; formatTokenSummary renders compact rollups in the footer and chat-info header. chatInfo.tokenStatsSummary mirrors it. Solid parity with desktop CodexTokenInline.", + "tuiFiles": [ + "apps/ade-cli/src/tuiClient/adeApi.ts", + "apps/ade-cli/src/tuiClient/app.tsx", + "apps/ade-cli/src/tuiClient/chatInfo.ts" + ] + }, + { + "feature": "Message rendering quality", + "status": "full", + "details": "aggregate.ts groups events into user-bubble / assistant-text / tool-calls-group / files-changed-group / runtime-activity / plan / compaction / queued-steer / approval / error / notice blocks with turn-aware merging, duration derivation, and subagent-child suppression. ChatView renders markdown (headings, bullets, numbered, quotes, fenced code with syntax highlight, tables, hr), file-type badges, status glyphs, spinners, and supports text selection/copy. Strong, Claude-Code-grade content fidelity.", + "tuiFiles": [ + "apps/ade-cli/src/tuiClient/aggregate.ts", + "apps/ade-cli/src/tuiClient/components/ChatView.tsx", + "apps/ade-cli/src/tuiClient/format.ts" + ] + } + ], + "bugs": [ + { + "title": "Archived chats are never filtered from the TUI session list", + "severity": "medium", + "description": "displaySessions (app.tsx:2841-2849) merges sessions + terminalSessions and sorts by recency but never filters session.archivedAt, unlike desktop AgentChatPane which does allRows.filter(session => !session.archivedAt). Because the TUI also offers no archive/unarchive UI, any session archived elsewhere (desktop, sync peer, scripted CLI) appears in the drawer ChatBlock and /chats list with no way to distinguish or hide it. Verified: agentChatService.archiveSession exists at agentChatService.ts:21861, so externally-archived sessions are a real possibility. File: apps/ade-cli/src/tuiClient/app.tsx:2841." + }, + { + "title": "/switch advertises chat switching but only matches lanes", + "severity": "low", + "description": "The /switch handler (app.tsx:6232-6258) only resolves lanes by entry.id/entry.name; there is no branch that resolves a chat by title/id, so typing a chat name falls into the else branch and shows 'No lane matched'. If commands.ts still describes /switch as 'Switch lane or chat' with argumentHint '[lane|chat]', the help text over-promises. Files: apps/ade-cli/src/tuiClient/app.tsx:6232, apps/ade-cli/src/tuiClient/commands.ts." + }, + { + "title": "History search cannot actually recall a prompt and has a dead scope control", + "severity": "low", + "description": "Two confirmed defects in one feature. (1) openHistorySearch (app.tsx:7399) snapshots composer text once into a lowercase query and renders a fixed <=20-row list pane; it does not re-filter as the user types, and the historySearch:accept/execute branch (app.tsx:7613-7615) only calls focusChat() — selecting a result does NOT insert it back into the composer, so the 'search' cannot recall a prompt. (2) historySearch:cycleScope (app.tsx:7617-7619) is bound but only calls addNotice('History search scope cycling is not available yet.'), presenting an affordance that does nothing. File: apps/ade-cli/src/tuiClient/app.tsx." + }, + { + "title": "Set chat tag is invisible in all TUI surfaces", + "severity": "low", + "description": "/tag persists a tag via chat updateSession (tagChat, adeApi.ts:493), but session.tag is rendered nowhere: formatSessionLabel (format.ts:800) builds only label + awaiting/active suffix, the drawer ChatBlock (Drawer.tsx:560-587) shows glyph+label+age+active-dot, and the /chats list shows ●/○ + title. The write succeeds but the user gets no persistent feedback and cannot filter by it. Files: apps/ade-cli/src/tuiClient/format.ts, apps/ade-cli/src/tuiClient/components/Drawer.tsx, apps/ade-cli/src/tuiClient/app.tsx." + } + ], + "polishOpportunities": [ + { + "title": "Status & time grouping in the drawer chat list", + "description": "Desktop SessionListPane buckets sessions into today/yesterday/older and running/awaiting/ended sections with collapsible headers and completion badges. The TUI ChatBlock (Drawer.tsx:520-595) is a flat recency list with only an age suffix, a provider glyph, and an active-dot. Grouping running vs idle vs ended (and dimming ended ones) would make multi-chat lanes far easier to scan.", + "impact": "medium" + }, + { + "title": "Completion badge and a distinct colored wait glyph on chat rows", + "description": "Correction to the draft: the drawer is NOT blind to awaiting-input — formatSessionLabel (format.ts:800) already appends ' ?' for session.awaitingInput and ' ●' for active, which ChatBlock renders. What is genuinely missing is session.completion (no completion chip anywhere) and a *distinct, colored* wait glyph: the ' ?' is a plain dim text suffix easily lost next to the active-dot. Surfacing a colored wait indicator plus a tiny completion chip would let users triage which chat needs them without opening each one.", + "impact": "medium" + }, + { + "title": "Visual context-usage breakdown for /context", + "description": "/context (app.tsx:5814) renders a monospace plaintext category table via formatContextUsage into a details pane. A small per-category bar (reusing the FooterControls TokenBar style with its color thresholds) inside the right pane would match desktop's visual treatment and make 'what is eating my context' obvious at a glance.", + "impact": "low" + }, + { + "title": "Inline steer-chip edit affordance", + "description": "Steering works via /steer edit , but desktop renders an editable pending-steer chip with direct edit/cancel buttons. A focusable queued-steer block in the transcript (press e to edit, x to cancel) would feel more direct than typing a slash command with the full replacement text.", + "impact": "medium" + }, + { + "title": "Mouse hover/click affordances on drawer chat rows", + "description": "Confirmed: only MultiChatGrid.tsx is wired to the hit-test registry (useHitTestTarget); the Drawer has no hit-test registration — the '↵/click' text in Drawer.tsx:285 is a static label, not a clickable target. Extending the existing hover/hit-test treatment to drawer chat rows (hover to highlight, click to switch, click-× to archive) would bring the multi-chat experience closer to the desktop's pointer-first feel.", + "impact": "medium" + }, + { + "title": "Confirm-on-destroy parity", + "description": "When delete/archive land, mirror desktop's confirm dialog (requestArchiveChat / archiveConfirm) using the existing TUI form/confirm pattern so a stray keypress can't nuke a chat.", + "impact": "low" + } + ], + "recommendations": [ + { + "title": "Add delete + archive/unarchive chat to the TUI", + "description": "Add deleteSession, archiveSession, unarchiveSession wrappers to adeApi.ts using the existing connection.action('chat', '', { sessionId }) pattern — note the method names are deleteSession/archiveSession/unarchiveSession (camelCase domain methods on agentChatService), NOT 'chat.delete' (that dotted form is only the sync RPC name). Wire a drawer chat action and slash commands (e.g. /chat delete, /chat archive), gate behind a confirm form, and add a separate 'Archived (N)' restore affordance. Closes the single biggest management gap.", + "effort": "L", + "priority": "P0" + }, + { + "title": "Fix archived-session filtering bug now", + "description": "Even before full archive UI lands, filter !session.archivedAt in displaySessions (app.tsx:2841-2849) so externally-archived chats stop polluting the drawer/grid, matching desktop AgentChatPane. Small, isolated, no new RPCs.", + "effort": "S", + "priority": "P0" + }, + { + "title": "Make chat browsing/search real", + "description": "Make /chats filterable (accept a query arg and filter laneSessions), extend the /switch handler (app.tsx:6232) to resolve chats by title/id when no lane matches (and fix or trim the help text), and fix Ctrl+R history search: make it incremental and, critically, make accept/execute insert the chosen prompt back into the composer instead of only calling focusChat(). Remove the dead historySearch:cycleScope notice or implement it.", + "effort": "M", + "priority": "P1" + }, + { + "title": "Surface session completion and tag in the drawer (awaiting already partly shown)", + "description": "In ChatBlock, render session.completion as a small chip, display session.tag, dim ended sessions, and upgrade the existing ' ?' awaiting suffix to a distinct colored wait glyph. Optionally bucket by status/time like desktop SessionListPane. Note: awaitingInput/active are already shown as text suffixes via formatSessionLabel, so this is enhancement, not from-scratch.", + "effort": "M", + "priority": "P1" + }, + { + "title": "Add message rewind / edit-resend", + "description": "Port the desktop rewind-files flow: let the user select a prior user message (message selector already exists) and rewind working-tree state to that point with a confirm preview, plus an edit-and-resend that truncates the transcript. Largest remaining content-management gap; depends on runtime exposing the rewind primitives to the CLI.", + "effort": "XL", + "priority": "P2" + }, + { + "title": "Upgrade /context to a visual breakdown and de-Claude-gate where possible", + "description": "Render per-category usage bars in the right pane (reuse the TokenBar style) and, where the runtime exposes usage for non-Claude providers, relax the Claude gate at app.tsx:5819 so Codex/OpenCode users get the breakdown the footer bar already implies exists for them.", + "effort": "M", + "priority": "P2" + } + ], + "_key": "chat-mgmt", + "_kind": "parity" +} +``` + +
+ +
Navigation & switching (parity) + +```json +{ + "dimension": "Navigation & switching", + "summary": "Verified against source. The desktop has three navigation layers: a left TabNav rail for ~12 top-level destinations (Work/Lanes/Files/Run/PRs/Review/Automations/CTO/Graph/History/VM + Settings pinned, confirmed in TabNav.tsx lines 26-39), a Cmd+K CommandPalette that fuzzy-searches ~30 grouped commands AND doubles as a full project browse/open/create/clone/remote-connect surface, and a TopBar with draggable multi-project tabs plus G-prefix chords and ]/[ lane cycling. The TUI is architecturally a single-project/single-lane-tree client: navigation is concentrated in one left Drawer (lanes section + nested chats section) plus an inline slash palette and an @-mention palette. Lane/chat switching itself is solid and fast (arrow keys, Enter, mouse click, drag-to-grid, last-chat-per-lane memory via lastChatByLaneRef, /switch command), and the Header gives a breadcrumb (ADE | project | lane | branch | chat). Confirmed real gaps: NO TUI global fuzzy command palette (SlashPalette only renders when prompt literally startsWith('/'), SlashPalette.tsx:119 and app.tsx:3217/9339; no app:openCommandPalette action in the keybindings enum), NO project switching/opening at all (grep for switchProject/openProject/recentProjects/browseDirectories in app.tsx is empty — one project per process), NO top-level go-to-view navigation (no G-chord, no setActiveView — Files/Graph/History/PRs are right-pane command outputs, not views), NO global lane-cycle shortcut (]/[ ARE bound in the TUI but only inside the model-picker context for provider-tab cycling at app.tsx:8840, NOT for lanes; the keybindings enum has no lane:* namespace), and switching has no center-pane transition/loading affordance (no laneTransition/switching-to state anywhere; only the Drawer shows 'Loading lanes…' at initial connect). The drawer is well built; the gaps are discoverability (no command-K), breadth (no project tabs / no view rail), and a few missing keyboard shortcuts that exist on desktop. Two corrections to the draft: the down-arrow 'off-by-one' is not a real bug (there is now an explicit atChatBottom clamp at app.tsx:9112-9114, and the undefined-index at 9116 is the intentional new-chat-row mapping), and the drawer hover-highlight polish is smaller than stated because the hover plumbing already exists end-to-end.", + "tuiStatus": [ + { + "feature": "Lane switching", + "status": "full", + "details": "Drawer 'lanes' section: Up/Down move selection and call selectActiveLaneId; Enter opens the lane's chat section and restores the last-used chat (lastChatByLaneRef, app.tsx:9148-9151) falling back to newestSession; mouse click switches; /switch [name] does fuzzy id/name match (app.tsx:6246). Lane selection persists (lastLaneId). Fast and responsive.", + "tuiFiles": [ + "apps/ade-cli/src/tuiClient/app.tsx", + "apps/ade-cli/src/tuiClient/components/Drawer.tsx" + ] + }, + { + "feature": "Chat switching", + "status": "full", + "details": "Drawer 'chats' section (entered via Enter on a lane): Up/Down move among the lane's sessions with explicit top/bottom clamps (app.tsx:9082, 9112-9120), Enter focuses the chat, '+ new chat' row at bottom; mouse click switches; drag a chat row into the center pane adds it to MultiChatGrid. last-chat-per-lane memory restores context on lane switch.", + "tuiFiles": [ + "apps/ade-cli/src/tuiClient/app.tsx", + "apps/ade-cli/src/tuiClient/components/Drawer.tsx", + "apps/ade-cli/src/tuiClient/components/MultiChatGrid.tsx" + ] + }, + { + "feature": "Global command palette (Cmd+K / Ctrl+K)", + "status": "missing", + "details": "Confirmed: no global fuzzy command launcher. SlashPalette returns null unless query.startsWith('/') (SlashPalette.tsx:119); the palette is gated on prompt.startsWith('/') at app.tsx:3217 and 9339. No Ctrl+K binding and no app:openCommandPalette in SUPPORTED_ACTION_VALUES (keybindings/index.ts:31-147). A user who doesn't know the slash grammar has no menu to browse navigation/actions. This is the largest discoverability gap vs desktop's CommandPalette.", + "tuiFiles": [ + "apps/ade-cli/src/tuiClient/components/SlashPalette.tsx", + "apps/ade-cli/src/tuiClient/app.tsx", + "apps/ade-cli/src/tuiClient/keybindings/index.ts" + ] + }, + { + "feature": "Slash command palette", + "status": "full", + "details": "Inline SlashPalette over the prompt: filtered by query and provider, windowed 5 visible rows with above/below counts (SlashPalette.tsx:123-139), placement glyphs, Up/Down move, Tab insert, Enter run, Esc close, selected-command summary line. Well-built and idiomatic. Only caveat vs desktop: reachable only by typing '/'.", + "tuiFiles": [ + "apps/ade-cli/src/tuiClient/components/SlashPalette.tsx", + "apps/ade-cli/src/tuiClient/commands.ts" + ] + }, + { + "feature": "@-mention reference palette", + "status": "full", + "details": "MentionPalette suggests lanes/chats/PRs/files/commits with kind column, name, detail, windowed rows, Tab insert, Esc close, selected summary with inserted-text preview. No desktop equivalent at this granularity; a TUI strength.", + "tuiFiles": [ + "apps/ade-cli/src/tuiClient/components/MentionPalette.tsx" + ] + }, + { + "feature": "Top-level view navigation (TabNav equivalent)", + "status": "missing", + "details": "Confirmed no equivalent to switching among Files/Graph/History/PRs/Review/Automations/CTO/VM views — grep for gotoView/setActiveView/activeView and any G-prefix chord in app.tsx is empty. Those desktop destinations surface in the TUI only as right-pane command outputs (/diff, /log, /pr, /linear, /status). No persistent navigable rail, no active-view indicator, no 'Go to X'. Partly by design (chat-centric TUI) but whole desktop surfaces have no first-class navigation home.", + "tuiFiles": [ + "apps/ade-cli/src/tuiClient/app.tsx" + ] + }, + { + "feature": "Project switching / multi-project tabs", + "status": "missing", + "details": "Confirmed no project switching: grep for switchProject/openProject/recentProjects/closeProject/browseDirectories in app.tsx returns nothing — the TUI binds to one project per process (project.projectRoot). Desktop's TopBar multi-project tabs, open/create/clone/connect-remote, recent projects, and drag-to-new-window have no TUI counterpart.", + "tuiFiles": [ + "apps/ade-cli/src/tuiClient/app.tsx" + ] + }, + { + "feature": "Lane cycling shortcut ([ / ])", + "status": "missing", + "details": "Desktop binds ] / [ to Select Next/Previous Lane globally (CommandPalette.tsx:590-622). Correction to draft: ] and [ ARE handled in the TUI, but only inside the model-picker key handler where they cycle provider tabs (app.tsx:8840-8847) — there is NO global lane-cycle. Lane navigation still requires opening the drawer (Ctrl+O) and arrowing. The keybindings enum has no lane:next/lane:previous (no lane:* namespace at all, keybindings/index.ts:31-147), so users cannot bind it. A new global ]/[ lane-cycle must be context-scoped so it does not clobber the model-picker provider-tab use.", + "tuiFiles": [ + "apps/ade-cli/src/tuiClient/keybindings/index.ts", + "apps/ade-cli/src/tuiClient/app.tsx" + ] + }, + { + "feature": "Pane focus cycling (Tab) & toggles", + "status": "full", + "details": "Tab cycles focus drawer to chat to details (cyclePaneFocus, app.tsx:2791); Ctrl+O toggles drawer, Ctrl+P toggles details, Ctrl+A toggles agents pane; keybinding contexts map pane to Tabs/Select/Chat/Help. Solid keyboard model on the Claude keybindings schema. Caveat: the reverse-cycle path is broken (see bugs).", + "tuiFiles": [ + "apps/ade-cli/src/tuiClient/app.tsx", + "apps/ade-cli/src/tuiClient/keybindings/index.ts" + ] + }, + { + "feature": "Breadcrumb / header context", + "status": "partial", + "details": "Header shows ADE | project | lane(icon+label) | branch | chat (Header.tsx:55-86). Good context. Gaps vs desktop: no indication of which PANE is focused (only the drawer border color conveys it), no count of open chats/lanes, and the chat label falls back through a long chain (terminal title to session title to goal to summary, app.tsx:10110) that can show a stale or generic value. Header is static (no active-view concept, since there are no views).", + "tuiFiles": [ + "apps/ade-cli/src/tuiClient/components/Header.tsx", + "apps/ade-cli/src/tuiClient/app.tsx" + ] + }, + { + "feature": "Switch feedback / transition affordance", + "status": "partial", + "details": "Confirmed: lane/chat switches are instant state changes with no visual transition, skeleton, or 'loading lane…' state in the center pane — grep for switching/laneTransition/projectTransition in app.tsx and MultiChatGrid is empty. Only the Drawer shows 'Loading lanes…' and only when lanes.length===0 at initial connect (Drawer.tsx:217-219). Desktop CommandPalette/TopBar have motion (fadeScale), spinners, and projectTransition state. On a slow runtime a TUI lane switch can feel like nothing happened until events repopulate.", + "tuiFiles": [ + "apps/ade-cli/src/tuiClient/app.tsx", + "apps/ade-cli/src/tuiClient/components/Drawer.tsx" + ] + } + ], + "bugs": [ + { + "title": "/switch fuzzy match does not restore the lane's last-used chat (inconsistent with drawer Enter)", + "severity": "low", + "description": "Confirmed. In app.tsx:6251 the /switch branch selects newestSession(displaySessions.filter(... laneId)) and sets it active. The drawer's Enter path (app.tsx:9148-9151) instead restores laneSessions.find(s => s.sessionId === lastChatByLaneRef.current.get(lane.id)) before falling back to newestSession. So switching the same lane via /switch vs the drawer can land on different chats, losing last-active-chat context only on the /switch path. Fix: mirror the drawer's lastChatByLaneRef lookup in the /switch branch.", + "file": "apps/ade-cli/src/tuiClient/app.tsx" + }, + { + "title": "tabs:previous / footer:previous do not cycle pane focus backward (aliased to forward)", + "severity": "low", + "description": "Confirmed. In runKeybindingAction (app.tsx:7674-7681) both 'tabs:next'/'footer:next' AND 'tabs:previous'/'footer:previous' call cyclePaneFocus() with no argument; cyclePaneFocus (app.tsx:2791-2806) only walks the order array forward (currentIndex+1). A user who rebinds a key to tabs:previous gets forward cycling, silently violating the binding's intent. Fix: give cyclePaneFocus a direction param and route the *:previous actions backward.", + "file": "apps/ade-cli/src/tuiClient/app.tsx" + } + ], + "polishOpportunities": [ + { + "title": "Add a Ctrl+K global command / lane / chat jump palette", + "description": "A centered overlay that fuzzy-searches built-in commands AND lanes AND chats in one box (like desktop CommandPalette + Claude Code's Ctrl+K), triggered by a keybinding instead of a typed '/', and able to jump directly to a lane/chat without opening the drawer. Reuse the existing SlashPalette/MentionPalette rendering primitives (windowed rows, rail glyph, summary line). Requires adding the trigger to SUPPORTED_ACTION_VALUES. Single biggest discoverability win.", + "impact": "high" + }, + { + "title": "Global [ / ] lane cycling (context-scoped) with a brief header flash", + "description": "Bind global ] / [ to cycle the active lane without opening the drawer, matching desktop. Must be scoped to the chat/global context so it does not clobber the existing model-picker provider-tab cycling at app.tsx:8840. Momentarily emphasize the lane name in the Header (e.g. violet bold ~400ms) so the switch is felt, and add lane:next/lane:previous (a new lane:* namespace) to SUPPORTED_ACTION_VALUES so it is user-bindable.", + "impact": "high" + }, + { + "title": "Switch transition affordance in the center pane", + "description": "On lane/chat switch, render a one-line 'switching to · …' placeholder (or subtle spinner) in the chat pane until the first event arrives, instead of an unchanged-looking screen. No such state exists today (confirmed). Mirrors desktop projectTransition feedback and makes fast switches feel intentional.", + "impact": "medium" + }, + { + "title": "Focused-pane indicator in the Header", + "description": "Add a small right-aligned segment in Header (drawer / chat / details, active one violet, others dimmed) so keyboard users always know where Tab will move and which pane arrows control, complementing the existing border-color cue.", + "impact": "medium" + }, + { + "title": "Drawer row hover highlighting (plumbing already exists)", + "description": "The hover pipeline is already fully wired: hoverTest fires on mouse move (app.tsx:7862-7869), hoveredHitId flows to HitTestProvider (app.tsx:10105), and useHitTestTarget(target) returns an is-hovered boolean (hitTestRegistry.ts:102-115). The Drawer registers hit targets for click/drag but never calls useHitTestTarget, so rows show no hover state. Have drawer lane/chat rows consume useHitTestTarget to subtly highlight under the mouse and reflect the hovered item in the summary footer. Smaller than it looks because the infra exists.", + "impact": "medium" + }, + { + "title": "Pre-select and scroll-to active lane on drawer open; jump-to-most-recent-chat", + "description": "When the drawer opens, pre-select the active lane and scroll it into view; offer a quick 'jump to most-recently-active chat across lanes' using lastActivityAt so power users with many lanes don't arrow through the full tree.", + "impact": "low" + }, + { + "title": "Show lane / open-chat counts and attention summary in Header", + "description": "Surface counts (e.g. 'lanes 6 · chats 3 · 1 needs input') so the user gets the at-a-glance attention signal the desktop TabNav status dots provide, since the TUI has no persistent rail. Header.tsx currently shows none.", + "impact": "low" + } + ], + "recommendations": [ + { + "title": "Add a Ctrl+K global command + lane/chat jump palette", + "description": "Introduce a keybinding-triggered overlay that fuzzy-searches built-in commands, lanes, and chats and can jump directly to any of them. Reuse SlashPalette/MentionPalette rendering. Add the trigger action to SUPPORTED_ACTION_VALUES (keybindings/index.ts) and a default Global binding. Closes the largest parity+discoverability gap with desktop's CommandPalette.", + "effort": "L", + "priority": "P0" + }, + { + "title": "Add global [ / ] lane cycling and make it user-bindable", + "description": "Implement next/previous active-lane cycling without opening the drawer, bound to ] and [ in the chat/global context (scoped so it does not collide with the model-picker provider-tab use at app.tsx:8840). Add a lane:next/lane:previous action (new lane:* namespace) to the keybindings enum. Pair with a brief Header emphasis on switch.", + "effort": "S", + "priority": "P1" + }, + { + "title": "Make /switch restore last-used chat (match drawer Enter)", + "description": "In the /switch fuzzy-match branch (app.tsx:6251), look up lastChatByLaneRef.current.get(lane.id) and prefer that session before falling back to newestSession, so /switch and drawer-Enter land on the same chat.", + "effort": "S", + "priority": "P1" + }, + { + "title": "Add a center-pane switch transition affordance", + "description": "Render a transient 'switching to /…' line or spinner in the chat pane on lane/chat switch until the first event repopulates. No such state exists today; mirrors desktop projectTransition feedback.", + "effort": "S", + "priority": "P1" + }, + { + "title": "Fix tabs:previous / footer:previous to cycle pane focus backward", + "description": "Give cyclePaneFocus (app.tsx:2791) a direction argument (or add a reverse helper) and route tabs:previous/footer:previous (app.tsx:7678) through it so rebound reverse-cycle keys behave correctly. Currently both directions alias forward.", + "effort": "S", + "priority": "P2" + }, + { + "title": "Add a focused-pane indicator to the Header", + "description": "Show which pane (drawer/chat/details) currently has focus in the Header so Tab cycling and arrow targeting are unambiguous for keyboard users.", + "effort": "S", + "priority": "P2" + }, + { + "title": "Wire drawer rows to the existing hover pipeline", + "description": "Have drawer lane/chat rows call useHitTestTarget (hitTestRegistry.ts:102) to highlight on mouse hover and reflect the hovered item in the summary footer. The hover plumbing already exists end-to-end; only the drawer's row rendering needs to consume it, so this is an S not an M.", + "effort": "S", + "priority": "P2" + } + ], + "_key": "nav", + "_kind": "parity" +} +``` + +
+ +
Pull requests (parity) + +```json +{ + "dimension": "Pull requests", + "summary": "The desktop ships a full GitHub PR management surface (PRsPage -> GitHubTab list of all repo PRs with filters/search/CI dots/review indicators/labels/avatars; a 4-sub-tab PrDetailPane with overview/convergence/files/checks, a real diff viewer, review threads with resolve, timeline rails; plus merge/land with method + bypass, submitReview approve/request-changes/comment, addComment, updateTitle, setLabels, requestReviewers, close/reopen, rerunChecks, cleanupBranch, AI draft-description, an issue resolver, and a dedicated AI ReviewPage). The TUI exposes only five read-mostly slash commands (/pr, /pr open, /pr checks, /pr review, /pr comments) that all dump flat plaintext into the generic DetailsPane, plus a 3-line PR snippet in the lane-details RightPane and a [#N ·passed/total] pill in the Drawer (shown for OPEN PRs only). The TUI can show summary/checks/reviews/comments text for the active lane's FIRST PR only and can create a draft PR or open the PR in a real browser; it cannot merge, submit a review, comment, view a diff/files/commits, list all repo PRs, filter/search, edit labels/reviewers/title, close/reopen/rerun checks, or run the AI reviewer at all. VERIFIED in app.tsx: no land/submitReview/rerunChecks/setLabels/requestReviewers/updateTitle/close/reopen/getFiles/getCommits/getFileDiff calls and no /review or /prs command exist anywhere. The runtime pr action namespace already exposes most of these over JSON-RPC (per preload.ts), so the write capabilities are reachable from conn.action(\\\"pr\\\", ...) but are simply not wired up. PR rendering in the TUI is heuristic markdown-ish text reflow via formatPrSummary/formatPrChecks/formatPrReview/formatPrComments, not structured, so it is visually flat and lossy compared to the desktop's card/timeline/diff UI.", + "tuiStatus": [ + { + "feature": "List PRs per lane / per repo", + "status": "partial", + "details": "app.tsx /pr* handlers fetch the active lane's PRs via conn.action('pr','listAll',{laneId}) and use prs[0] (line 6064-6065). Multiple PRs per lane and all-repo PRs are invisible. A separate effect uses pr.listPrsByLane (server-side) to build a Record for the Drawer pill ([#N ·passed/total], Drawer.tsx line 501-516), which renders only when state==='open'. There is no all-PR list, no filter (open/closed/merged), no search, no sort, no labels, no author/avatar, no review-status indicator. The GitHubTab list surface has no TUI analog.", + "gap": "No browsable PR list; only the active lane's first PR is reachable. No filter/search/sort/labels/author/review-status. Desktop GitHubTab (whole repo) is entirely absent.", + "tuiFiles": [ + "apps/ade-cli/src/tuiClient/app.tsx", + "apps/ade-cli/src/tuiClient/components/Drawer.tsx", + "apps/ade-cli/src/tuiClient/adeApi.ts" + ] + }, + { + "feature": "View PR details / status", + "status": "partial", + "details": "/pr renders formatPrSummary(activePr) as flat text (#number · state, title, id/lane/branch/merge/github/ade rows; rightPaneFormatters.ts line 135-173) into kind:'details'. The lane-details RightPane shows a 3-line snippet: state Chip, formatPrActivity text, and 'passed/total' (RightPane.tsx line 449-478). Never calls a getDetail action. No timeline.", + "gap": "Plaintext-only summary of one PR; no structured detail pane, no sub-tabs, no timeline. formatPrSummary already surfaces mergeable/branch when present but there is no reviewers/labels and no full-record fetch.", + "tuiFiles": [ + "apps/ade-cli/src/tuiClient/app.tsx", + "apps/ade-cli/src/tuiClient/rightPaneFormatters.ts", + "apps/ade-cli/src/tuiClient/components/RightPane.tsx" + ] + }, + { + "feature": "View PR checks", + "status": "partial", + "details": "/pr checks calls pr getChecks and renders formatPrChecks: a one-line summary (N passing · M failing · K pending) plus up to 16 rows of 'STATUS name · time' as flat text in the generic details pane (rightPaneFormatters.ts line 175-200). formatPrChecks correctly uses statusWord() (which treats error/timed_out/cancelled/action_required as FAIL). The lane-details pane also shows checksPassed/checksTotal computed separately in app.tsx. No detailsUrl links, no grouping, no live polling/refresh, no rerun.", + "gap": "Static plaintext list capped at 16 with no '… N more'; no links to check logs, no rerunChecks, no live refresh (desktop polls every 60s).", + "tuiFiles": [ + "apps/ade-cli/src/tuiClient/app.tsx", + "apps/ade-cli/src/tuiClient/rightPaneFormatters.ts" + ] + }, + { + "feature": "View PR diffs / files / commits", + "status": "missing", + "details": "VERIFIED: app.tsx never calls pr getFiles, getCommits, or getFileDiff (grep returns zero matches). The RightPane 'diff' content kind exists but is wired to git/lane diffs, not PR diffs. There is no way to see a PR's changed files, commits, or per-file diff.", + "gap": "No PR diff/files/commits viewing at all; AdeDiffViewer has no TUI equivalent for PRs.", + "tuiFiles": [ + "apps/ade-cli/src/tuiClient/app.tsx", + "apps/ade-cli/src/tuiClient/components/RightPane.tsx" + ] + }, + { + "feature": "View review comments / threads", + "status": "partial", + "details": "/pr review runs getReviews+getReviewThreads+getComments and renders formatPrReview (reviews/threads/comments each capped at 8 as flat bullets; rightPaneFormatters.ts line 202-236). /pr comments uses the pr_get_review_comments tool -> formatPrComments (threads/comments capped at 10; line 238-265). Thread resolve state is shown as the word 'resolved'/'open' but threads cannot be navigated, expanded, or resolved. No timeline, no diff context for inline comments.", + "gap": "Read-only flat text, hard caps (8/10), no thread navigation/resolve, no inline diff context, no avatars/timestamps structure.", + "tuiFiles": [ + "apps/ade-cli/src/tuiClient/app.tsx", + "apps/ade-cli/src/tuiClient/rightPaneFormatters.ts" + ] + }, + { + "feature": "Create PR", + "status": "partial", + "details": "/pr open creates a draft via pr createFromLane (title + body, draft:true) either inline (/pr open , app.tsx line 6104-6109) or through a 2-field form (title/body) in the RightPane (line 6093-6101). The inline path hardcodes body:'' so any prose after the title is discarded. The lane-details PR action and the o/[↵] keybinds also trigger /pr open. No AI draft-description, no base/target selection, no labels/reviewers, no stack/queue/integration workflows.", + "gap": "Minimal title/body draft only; missing AI draft, base/target, labels, reviewers, and the 4 workflow types of CreatePrModal. Inline path discards body.", + "tuiFiles": [ + "apps/ade-cli/src/tuiClient/app.tsx", + "apps/ade-cli/src/tuiClient/components/RightPane.tsx" + ] + }, + { + "feature": "Update PR (title / labels / reviewers / close / reopen / rerun)", + "status": "missing", + "details": "VERIFIED: no TUI command or handler calls pr updateTitle, setLabels, requestReviewers, close, reopen, or rerunChecks (grep returns zero matches in app.tsx), despite all being available on the runtime pr action namespace (preload.ts callProjectRuntimeActionOr('pr', ...)).", + "gap": "Zero PR mutation beyond create; updateTitle/setLabels/requestReviewers/close/reopen/rerunChecks are all unreachable from the TUI.", + "tuiFiles": [ + "apps/ade-cli/src/tuiClient/app.tsx" + ] + }, + { + "feature": "Merge / land PR", + "status": "missing", + "details": "VERIFIED: no /pr merge or /merge command and no call to pr land/landStack/landQueueNext anywhere in app.tsx (grep returns only unrelated 'close' event handlers and the /pull mode parser, which is git pull, not PR merge). The runtime exposes land via JSON-RPC but the TUI never calls it.", + "gap": "Cannot merge a PR from the TUI at all; merge method selection and rule-bypass are absent.", + "tuiFiles": [ + "apps/ade-cli/src/tuiClient/app.tsx" + ] + }, + { + "feature": "Submit review (approve / request changes)", + "status": "missing", + "details": "VERIFIED: no call to pr submitReview and no review-submit UI. The TUI can read reviews but cannot approve, request changes, or post a review.", + "gap": "No review submission; PrReviewSubmitModal has no TUI analog.", + "tuiFiles": [ + "apps/ade-cli/src/tuiClient/app.tsx" + ] + }, + { + "feature": "AI code review runs", + "status": "missing", + "details": "VERIFIED: no /review command and no use of the review bridge (listRuns/startRun/getRunDetail/findings/suppressions) in app.tsx. The entire desktop ReviewPage AI-review system is absent from the TUI.", + "gap": "No AI review run, findings, severity summary, suppressions, feedback, or learnings in the TUI.", + "tuiFiles": [ + "apps/ade-cli/src/tuiClient/app.tsx" + ] + }, + { + "feature": "Queue / rebase / integration workflows", + "status": "missing", + "details": "No TUI surface for QueueTab/RebaseTab/IntegrationTab. Rebase needs, conflict badges, queue landing, and integration branches have no TUI representation.", + "gap": "All three desktop workflow surfaces are missing.", + "tuiFiles": [ + "apps/ade-cli/src/tuiClient/app.tsx" + ] + }, + { + "feature": "Open PR in browser / desktop deep-link", + "status": "full", + "details": "VERIFIED: the lane-details PR action ([↵] open in browser, RightPane.tsx line 455) opens the PR url in a REAL browser via window.ade.app.openExternal, falling back to `open`/`xdg-open` and finally /pr open (app.tsx line 8932-8958). /pr open on an existing PR also calls navigateDesktop with a pr target. Ctrl+Y copies the ade:// deeplink for the focused PR (app.tsx line 8380; buildDeeplink in rightPaneFormatters). This is at or above the desktop's open-in-browser affordance.", + "gap": "None of note.", + "tuiFiles": [ + "apps/ade-cli/src/tuiClient/app.tsx", + "apps/ade-cli/src/tuiClient/rightPaneFormatters.ts", + "apps/ade-cli/src/tuiClient/components/RightPane.tsx" + ] + } + ], + "bugs": [ + { + "title": "Inline /pr open <title> hardcodes body:'' , silently dropping any prose after the title", + "severity": "low", + "description": "app.tsx line 6104-6109: the inline path conn.action('pr','createFromLane',{laneId,title:args,body:'',draft:true}) always sends body:''. The 2-field form (title/body) is only reached when args is empty (line 6092). A user typing '/pr open Fix the thing because X' has the whole string treated as the title with no body. apps/ade-cli/src/tuiClient/app.tsx ~line 6104." + }, + { + "title": "PR commands operate only on prs[0], so lanes with multiple PRs are ambiguous", + "severity": "medium", + "description": "Every /pr* handler does const activePr = prs[0] after pr listAll {laneId} (app.tsx line 6064-6065), and the lane-details hydration does the same (line 3871). If a lane has more than one PR (e.g. a reopened/closed pair or stacked PRs), the TUI silently picks the first returned and there is no way to target the others; checks/review/comments then reflect an arbitrary PR. apps/ade-cli/src/tuiClient/app.tsx lines 3871, 6064-6066." + }, + { + "title": "Lane-details checksFailed/checksPending miscount non-failure/non-pending conclusions; pill is unaffected", + "severity": "medium", + "description": "In the lane-details PR hydration (app.tsx line 3904-3906): checksPassed requires status==='completed' && conclusion==='success'; checksFailed = filter(conclusion==='failure'); checksPending = filter(status!=='completed'). This undercounts failures (statusWord() in rightPaneFormatters.ts treats error/timed_out/cancelled/action_required as FAIL too) so a PR with errored/timed-out checks shows formatPrActivity 'checks passing' (RightPane.tsx line 142-156). It also means passed+failed+pending can be < total when checks complete as neutral/skipped/stale, so the lane-details 'X/Y passing' line looks incomplete. NOTE (correction to draft): this does NOT affect the Drawer pill — the pill is fed by the server-side pr.listPrsByLane action (app.tsx line 5261-5271, adeApi.ts line 244), which returns its own checksPassed/checksTotal and never uses this client-side checksFailed. apps/ade-cli/src/tuiClient/app.tsx ~line 3904-3906." + }, + { + "title": "formatPrChecks/formatPrReview/formatPrComments hard-truncate lists with no 'N more' indicator", + "severity": "low", + "description": "formatPrChecks slices to 16 (rightPaneFormatters.ts line 193); formatPrReview slices reviews/threads/comments to 8 each (lines 212, 221, 230); formatPrComments slices to 10 each (lines 250, 259) — all with no '… N more' affordance, unlike formatLinearIssueComments which does append '… and N more' (line 296). On PRs with many checks/threads the user cannot tell data was dropped. apps/ade-cli/src/tuiClient/rightPaneFormatters.ts." + }, + { + "title": "Drawer PR pill is hidden for closed/merged PRs", + "severity": "low", + "description": "Drawer.tsx line 400: prPillText = pr?.state === 'open' ? formatPrPillText(pr) : null. A lane whose PR is closed or merged shows no pill at all in the lane list, so the only at-a-glance PR signal disappears exactly when a merged/closed outcome would be useful to surface. apps/ade-cli/src/tuiClient/components/Drawer.tsx ~line 400." + } + ], + "polishOpportunities": [ + { + "title": "Structured PR card instead of reflowed plaintext", + "description": "Today /pr, /pr review, /pr checks all dump into the generic DetailsPane which heuristically guesses headers/key-values/bullets from text. A dedicated pr-detail RightPane content kind with real fields (state chip, mergeable, base->head, reviewers, labels, checks summary bar) would look intentional and Claude-Code-grade rather than markdown-ish reflow. The lane-details PR snippet (Chip + formatPrActivity) is the seed for this.", + "impact": "high" + }, + { + "title": "A real PR list pane with filter chips and search", + "description": "Add a /prs (plural) list pane showing all repo PRs with open/closed/merged filter chips, a search field, CI dot + review glyph per row, and ↵ to open detail — closing the biggest structural gap vs GitHubTab while feeling native to the TUI. No /prs command exists today.", + "impact": "high" + }, + { + "title": "Merge affordance with method picker", + "description": "Add a merge action (e.g. m in the PR pane or /pr merge --squash|--rebase|--merge) that opens a tiny ←→ method selector, then calls pr land. The destructive-action confirm pattern already exists in LaneDeleteFormPane (RightPane.tsx line 881) and can be reused for an 'enter merges #123 via squash' confirm row.", + "impact": "high" + }, + { + "title": "Inline check status with live spinner and color-coded glyphs", + "description": "PR checks are static text fetched once. A live-refreshing checks view reusing the existing SpinTickProvider/spinTick (app.tsx line 128, 10104) with ◐ running, ✓ pass, ✗ fail glyphs and a compact progress bar (e.g. ▰▰▰▱▱ 3/5) would mirror the desktop's polling and feel alive.", + "impact": "high" + }, + { + "title": "Navigable review threads with resolve/next-unresolved", + "description": "formatPrReview lists threads as flat bullets capped at 8. Make threads a selectable list (↑↓ move, ↵ expand, r to resolve via pr resolveReviewThread, n/N jump to next/prev unresolved) echoing the desktop timeline rails' prev/next-unresolved navigation.", + "impact": "medium" + }, + { + "title": "Mouse hit-targets for PR rows and actions", + "description": "The TUI already has a hitTestRegistry and mouse line-math (app.tsx line 2328, 7863-7873). Extend it so PR check rows, review threads, and a merge button are clickable, and the PR url is click-to-open — matching the desktop's pointer-first feel. The PR url open path already exists (app.tsx line 8932).", + "impact": "medium" + }, + { + "title": "Color-graded check/review status in the Drawer pill, shown for all states", + "description": "The Drawer PrPill (Drawer.tsx line 505-517) shows [#N ·passed/total] with a two-color split and only renders for open PRs. Add a tiny review glyph (✓ approved / ✱ changes-requested / · none) plus a failing/pending tint, and stop hiding the pill for merged/closed PRs, so the lane list communicates PR health at a glance like the desktop's ciDot + reviewIndicator.", + "impact": "medium" + }, + { + "title": "AI review trigger from the TUI", + "description": "Surface a /review command that starts a review run (review startRun) and streams findings into a pane with severity-colored rows; even a read-only findings list would bring a flagship desktop capability to the terminal.", + "impact": "medium" + } + ], + "recommendations": [ + { + "title": "Wire PR merge/land into the TUI", + "description": "Add a merge command/action that calls conn.action('pr','land',{prId,method,bypassRules}) with a small squash/rebase/merge method picker and a confirm row (reuse the LaneDeleteFormPane confirm pattern). The runtime already supports it (preload land -> pr action). This closes the single biggest functional gap.", + "effort": "M", + "priority": "P0" + }, + { + "title": "Add submitReview (approve/request-changes/comment) and addComment", + "description": "Provide /pr approve, /pr request-changes [body], and /pr comment <body> backed by pr submitReview / pr addComment so reviewers can act from the terminal. Today the TUI is entirely read-only on reviews.", + "effort": "M", + "priority": "P0" + }, + { + "title": "Replace plaintext PR panes with a structured pr-detail content kind", + "description": "Introduce a RightPaneContent kind for PR detail (state chip, mergeable, base->head, reviewers, labels, checks summary, review threads) instead of routing through the heuristic DetailsPane. Fetch the full record from the pr action namespace. Fixes the flat/lossy rendering and the section-guessing heuristics.", + "effort": "L", + "priority": "P0" + }, + { + "title": "Fix lane-details checks counting to match statusWord() FAIL set", + "description": "Update the lane-details hydration in app.tsx (~line 3904-3906) so checksFailed counts error/timed_out/cancelled/action_required (not just 'failure') and the passed/failed/pending split accounts for neutral/skipped completions, so the lane-details snippet and 'X/Y passing' line stop under-reporting failures and looking incomplete. (Drawer pill is server-fed and out of scope here.)", + "effort": "S", + "priority": "P1" + }, + { + "title": "Support multiple PRs per lane / target selection", + "description": "Stop hard-picking prs[0] (app.tsx line 6065, 3871); when a lane has multiple PRs, present a selectable list or accept a PR number argument so checks/review/comments target the intended PR.", + "effort": "M", + "priority": "P1" + }, + { + "title": "Add a browsable all-repo PR list pane (/prs)", + "description": "Build a list surface fed by pr listAll {} (or a repo snapshot action) with open/closed/merged filters, search, CI + review glyphs per row, and ↵-to-detail, giving rough parity with GitHubTab.", + "effort": "L", + "priority": "P1" + }, + { + "title": "PR diff/files/commits viewing", + "description": "Call pr getFiles/getCommits/getFileDiff and render changed files + per-file unified diff in a pane (reuse the existing diff content kind / highlightCache). Currently there is no PR diff viewing at all.", + "effort": "L", + "priority": "P1" + }, + { + "title": "Fix inline /pr open to carry a body", + "description": "The inline /pr open <title> path hardcodes body:'' (app.tsx line 6107). Either route inline /pr open through the title/body form always, or parse a title/body delimiter, so prose typed inline is not silently dropped.", + "effort": "S", + "priority": "P1" + }, + { + "title": "Add '… N more' truncation indicators to PR formatters", + "description": "formatPrChecks (16), formatPrReview (8) and formatPrComments (10) hard-slice with no overflow hint. Append '… and N more' like formatLinearIssueComments already does, so users know data was dropped.", + "effort": "S", + "priority": "P2" + }, + { + "title": "Live-refresh PR checks with spinner + progress glyphs", + "description": "Poll getChecks (like the desktop's 60s loop) while a PR pane is open and render ◐/✓/✗ with a compact progress bar using the existing spinTick.", + "effort": "M", + "priority": "P2" + }, + { + "title": "Add updateTitle/setLabels/requestReviewers/close/reopen/rerunChecks commands", + "description": "Expose the remaining pr mutations (already on the runtime namespace) as /pr commands so the TUI reaches edit/close/rerun parity with PrDetailPane.", + "effort": "M", + "priority": "P2" + }, + { + "title": "Surface AI code review (/review)", + "description": "Add a /review command using the review bridge (startRun/listRuns/getRunDetail) to start runs and list severity-colored findings, bringing a flagship desktop surface to the terminal. Genuinely large scope; keep last.", + "effort": "XL", + "priority": "P2" + } + ], + "_key": "prs", + "_kind": "parity" +} +``` + +</details> + +<details><summary><b>Terminals & sessions</b> (parity)</summary> + +```json +{ + "dimension": "Terminals & sessions", + "summary": "VERIFIED against the cited TUI files. The desktop treats terminals as a first-class interactive surface: a real xterm.js emulator (TerminalView.tsx) with WebGL→DOM rendering, live PTY streaming, bidirectional input, FitAddon resize, scrollback, clickable links, mouse-tracking forwarding, ⌘C→SIGINT, selection/copy, hydration + disposed-session replay, plus a full management layer (SessionListPane filter/multi-select/bulk-close/delete, SessionContextMenu rename/pin/deeplink, TerminalsPage grid/tab tiling) across shell, run-shell, claude, codex, cursor-cli, droid, opencode runtimes. The TUI ships a deliberately narrow slice: listTerminalSessions (adeApi.ts 85-104) hard-filters to claude / claude-orchestrated tool types (RESUMABLE_TERMINAL_TOOL_TYPES) plus provider==='claude', dropping all CHAT_BACKED types; the surviving Claude PTY renders as a single TerminalPane (components/TerminalPane.tsx) embedded in the chat surface in an either/or branch with MultiChatGrid and ChatView (app.tsx 10144-10176), so only one terminal ever shows. TerminalPane reconstructs the screen by feeding a preview snapshot/transcript + a capped 500-chunk live PTY buffer into a @xterm/headless emulator and painting Ink <Text> rows — a read-mostly preview refreshed by a 500ms ade.terminal.preview poll (app.tsx 3423-3445), with write-only attach via Ctrl+T gated to running Claude sessions only (app.tsx 2873-2879, 7835-7844). Shell/Codex/Cursor/Droid/OpenCode terminals, scrollback nav, in-pane search, selection/copy, clickable links, terminal grid/tiling, and the entire session-management toolbar are absent. Functional for \"watch and steer one Claude session,\" but far from desktop parity or Claude-Code-grade feel. All high-impact draft claims confirmed; three minor refinements applied (paste forwarding, mouse-tracking scope, status-string separators).", + "tuiStatus": [ + { + "feature": "Discover/list terminals", + "status": "partial", + "details": "VERIFIED: listTerminalSessions (adeApi.ts 85-104) filters to only claude/claude-orchestrated (RESUMABLE_TERMINAL_TOOL_TYPES) or resumeMetadata.provider==='claude', and explicitly drops CHAT_BACKED_TERMINAL_TOOL_TYPES (opencode-chat, cursor, droid-chat). shell, run-shell, codex, cursor-cli, droid, opencode PTYs are never surfaced. There is no dedicated terminal list — terminals are merged into the chat session list; no terminal-specific filter/search surface.", + "gap": "No way to see or open the majority of desktop terminal types; no dedicated terminal list, filter, or search.", + "tuiFiles": [ + "apps/ade-cli/src/tuiClient/adeApi.ts", + "apps/ade-cli/src/tuiClient/app.tsx" + ] + }, + { + "feature": "Open / attach a terminal", + "status": "partial", + "details": "VERIFIED: opening is implicit (selecting a Claude terminal renders TerminalPane, app.tsx 10166-10175). Attach is Ctrl+T (isTerminalControlToggle), gated to running Claude sessions via claudeTerminalControlAvailable (app.tsx 2873-2879: status==='running' && provider==='claude') and re-checked in useInput (7835-7844). Detach is Ctrl+] (\\x1d) or Ctrl+T again (7831-7833). Only affordance is the footer '^t Claude' pill (9559-9569).", + "gap": "Attach only works for running Claude sessions; no attach for any other runtime; only a footer pill as affordance, no list-driven open.", + "tuiFiles": [ + "apps/ade-cli/src/tuiClient/app.tsx", + "apps/ade-cli/src/tuiClient/components/TerminalPane.tsx" + ] + }, + { + "feature": "Live output", + "status": "partial", + "details": "VERIFIED: TerminalPane feeds preview transcript/snapshot + live PTY chunks into a @xterm/headless emulator and paints Ink rows (TerminalPane.tsx 295-422). Live data arrives via a 500ms ade.terminal.preview poll (app.tsx 3423-3445) and a subscribeRuntimeEvents pty_data stream capped at the last 500 chunks (app.tsx 5174-5177, slice(-500)). It is a periodically-refreshed snapshot, not a smooth stream.", + "gap": "500ms poll cadence makes output feel steppy vs desktop rAF-coalesced writes; the 500-chunk cap silently drops older live output on busy sessions and can desync the incremental chunk cursor (see bug).", + "tuiFiles": [ + "apps/ade-cli/src/tuiClient/components/TerminalPane.tsx", + "apps/ade-cli/src/tuiClient/app.tsx" + ] + }, + { + "feature": "Input to terminal", + "status": "partial", + "details": "VERIFIED: when attached, each process.stdin 'data' event is forwarded to writeTerminal as its own JSON-RPC call (app.tsx 4192-4215) with no local echo and no batching; detach is split out via splitTerminalControlInput. Unattached, the composer can submit a prompt to a running Claude session. A pasted block does arrive and forward as a single chunk (it is one stdin data event), so paste is not dropped — but there is no local echo, so the pane only updates on the next poll/pty_data.", + "gap": "No local echo and one RPC per stdin data event make attached typing lag and feel disconnected; only Claude PTYs accept input; no ⌘C→SIGINT inside the pane (signals are separate handlers).", + "tuiFiles": [ + "apps/ade-cli/src/tuiClient/app.tsx" + ] + }, + { + "feature": "Resize", + "status": "full", + "details": "VERIFIED: on size/layout change app.tsx 3403-3421 calls resizeTerminal(cols, rows) then re-previews; cols/rows derived from terminalPaneWidth/chatRowBudget via clampTerminalPaneCols / claudeTerminalRowsForPane with an attached vs preview distinction. TerminalPane resizes its headless emulator to match (TerminalPane.tsx 394-401). Functionally correct.", + "gap": "Resize fires an extra preview on every dimension change with no debounce and races the 500ms poll's setTerminalPreview (see bug); behavior is otherwise correct.", + "tuiFiles": [ + "apps/ade-cli/src/tuiClient/app.tsx", + "apps/ade-cli/src/tuiClient/components/TerminalPane.tsx" + ] + }, + { + "feature": "Multiple terminals", + "status": "missing", + "details": "VERIFIED: TerminalPane renders in an either/or branch with MultiChatGrid and ChatView (app.tsx 10144-10176), so exactly one terminal (the active Claude session) ever renders. MultiChatGrid tiles chats only, not terminals.", + "gap": "No multi-terminal grid/tab view; desktop PackedSessionGrid/WorkViewArea tiling has no TUI counterpart for terminals.", + "tuiFiles": [ + "apps/ade-cli/src/tuiClient/app.tsx" + ] + }, + { + "feature": "Preview (closed/disposed sessions)", + "status": "partial", + "details": "VERIFIED: for closed sessions transcriptPreviewRows calls compactClosedTerminalTranscript, which unconditionally applies a large bank of Claude-Code-specific regexes (spinner glyphs, box-drawing, footer/session chrome, numeric/spinner residue dropping when >=4) to ANY transcript (TerminalPane.tsx 97-194, 318-324), then shows last N lines with 'closed, resumable · Enter resumes' status. Snapshot rows are used while running.", + "gap": "Preview is lossy and Claude-specific: chrome-stripping mangles non-Claude output; no scrollback into history, no styled replay; disposed non-Claude sessions have no preview. Desktop replay mode has no equivalent.", + "tuiFiles": [ + "apps/ade-cli/src/tuiClient/components/TerminalPane.tsx" + ] + }, + { + "feature": "Scrollback / search / copy / links in terminal", + "status": "missing", + "details": "VERIFIED: TerminalPane only ever renders lines.slice(0, visibleHeight) (TerminalPane.tsx 414-459). The headless emulator has a 2000-line scrollback buffer (line 379) but it is never navigated. No scrollback nav, no in-terminal search, no selection/copy, no clickable-link detection. (Note: app.tsx has a useTerminalMouseTracking, but it is an env-gated, ADE-global SGR-mouse toggle (ADE_TUI_MOUSE, lines 1996-2011, 2232), not wired to pane scroll/clicks.)", + "gap": "None of desktop's scroll/search/copy/link affordances exist for terminals in the TUI.", + "tuiFiles": [ + "apps/ade-cli/src/tuiClient/components/TerminalPane.tsx" + ] + }, + { + "feature": "Session management (rename/pin/delete/bulk/info)", + "status": "missing", + "details": "VERIFIED: terminal sessions surface only as chat-list rows. signalTerminal (SIGTERM/SIGKILL, app.tsx 5020-5022 via signalTerminalWithCliSync) and resumeTerminalSession (5494) exist but are wired to generic stop/resume, not a management UI. No terminal-scoped context menu, rename, pin, copy-id/deep-link, multi-select, or bulk stop/delete.", + "gap": "Entire desktop session-management toolbar (SessionListPane / SessionContextMenu) is absent for terminals.", + "tuiFiles": [ + "apps/ade-cli/src/tuiClient/app.tsx" + ] + } + ], + "desktopCapabilities": [ + { + "feature": "Interactive xterm.js terminal renderer (WebGL + DOM fallback)", + "description": "TerminalView.tsx renders a real xterm.js Terminal with FitAddon, WebGL-first renderer with automatic DOM fallback on context loss, per-session runtime caching keyed by (projectRoot, sessionId, ptyId), frame-write coalescing on rAF with a setTimeout fallback for backgrounded tiles, and fit-recovery for invalid dims. Full keyboard input is delivered straight to the PTY.", + "keyFiles": [ + "apps/desktop/src/renderer/components/terminals/TerminalView.tsx" + ] + }, + { + "feature": "Live PTY streaming + hydration + disposed-session replay", + "description": "Subscribes to ade pty data/exit events and writes chunks live; on mount hydrates from ade.terminal.preview snapshot (SGR-bracketed ANSI) or transcript tail; for disposed sessions enters replay mode (readTranscriptTail raw, up to 8MB, stripFullScreenRedrawSequences, 100k scrollback) so a closed session renders as a scrollable transcript rather than the last alt-screen frame.", + "keyFiles": [ + "apps/desktop/src/renderer/components/terminals/TerminalView.tsx", + "docs/features/terminals-and-sessions/ui-surfaces.md" + ] + }, + { + "feature": "Multi-terminal management: list, filter, multi-select, bulk ops, context menu", + "description": "SessionListPane provides per-lane filtering, search box, running/awaiting/ended grouping, shift/cmd range+toggle multi-select, bulk Stop running runtimes and bulk Delete ended sessions with concurrency. SessionContextMenu + SessionInfoPopover offer rename, pin, copy session id / deep link, go-to-lane, stop runtime, delete. TerminalsPage wires all of it.", + "keyFiles": [ + "apps/desktop/src/renderer/components/terminals/TerminalsPage.tsx", + "apps/desktop/src/renderer/components/terminals/SessionListPane.tsx", + "apps/desktop/src/renderer/components/terminals/SessionContextMenu.tsx" + ] + }, + { + "feature": "Multiple terminal types and grid/tab tiling", + "description": "WorkViewArea + PackedSessionGrid + workSessionTiling render many terminals simultaneously in tab or packed-grid layouts, each its own live xterm runtime. Supported toolTypes include shell, run-shell, claude, claude-orchestrated, codex, cursor-cli, droid, opencode. Continue/Resume closed CLI sessions via pty.sendToSession / pty.resumeSession.", + "keyFiles": [ + "apps/desktop/src/renderer/components/terminals/WorkViewArea.tsx", + "apps/desktop/src/renderer/components/terminals/PackedSessionGrid.tsx", + "apps/desktop/src/renderer/components/terminals/useWorkSessions.ts" + ] + }, + { + "feature": "Terminal UX niceties: links, mouse-tracking bridge, copy, SIGINT, prefs", + "description": "Clickable URL link provider opens in the ADE browser; DECSET 1000/1002/1003 mouse-tracking detection installs a Shift-mouse bridge forwarding SGR mouse events so in-TUI mouse UIs work; ⌘C with no selection sends SIGINT on macOS; selection/copy via xterm; live-reactive font family / size / line height / scrollback from terminalPreferences with texture-atlas reset.", + "keyFiles": [ + "apps/desktop/src/renderer/components/terminals/TerminalView.tsx" + ] + } + ], + "bugs": [ + { + "title": "Live PTY chunk buffer caps at 500 and can desync the incremental write cursor", + "severity": "medium", + "description": "VERIFIED. app.tsx 5174-5177 keeps only the last 500 chunks per session (slice(-500)). TerminalPane tracks consumed chunks via chunkIndexRef and writes liveChunks[start..] on each change (TerminalPane.tsx 403-412). The only resync guard is `if (liveChunks.length < start) chunkIndexRef.current = 0` (line 407) — it fires only when the array got SHORTER than the cursor. But trimming keeps length pinned at 500: once the buffer is saturated, each new chunk shifts the window (drops index 0, appends at end) while length stays 500 and start stays ~500, so the guard never trips and the for-loop writes nothing new even though fresh chunks arrived → live output silently stalls/drops on a saturated buffer. On re-mount chunkIndexRef resets to 0 and the whole 500-window is re-written into a fresh emulator. Chunks that scrolled past the 500-window before being consumed are lost. Net effect on a chatty Claude run: missing and/or duplicated terminal content.", + "file": "apps/ade-cli/src/tuiClient/app.tsx" + }, + { + "title": "Resize effect issues an un-debounced resize+preview on every dimension change and races the poll", + "severity": "low", + "description": "VERIFIED. app.tsx 3403-3421 runs resizeTerminal then previewTerminal+setTerminalPreview on every change of activeTerminalSession/chatRowBudget/claudeTerminalControlActive/terminalPaneWidth, with no debounce. A separate effect (3423-3445) polls previewTerminal+setTerminalPreview every 500ms. Window/pane resizes therefore spam resize+preview RPCs to the daemon, and two independent writers race to setTerminalPreview.", + "file": "apps/ade-cli/src/tuiClient/app.tsx" + }, + { + "title": "Closed-transcript chrome-stripping is Claude-specific and applied to every closed transcript", + "severity": "medium", + "description": "VERIFIED. transcriptPreviewRows → compactClosedTerminalTranscript + isNoisyClosedTerminalLine (TerminalPane.tsx 97-194, 318-324) unconditionally apply Claude-Code-specific regexes (spinner glyphs, box-drawing TERMINAL_BOX_CHROME_RE, footer/session chrome, prompt chrome, logo, and dropping numeric/spinner residue when >=4 such lines) to ANY closed-terminal transcript. For a non-Claude command whose output legitimately contains box characters, prompt-like lines, or short numeric lines, real content is discarded. Currently latent because the list is Claude-only, but it directly blocks safely extending the list to shell/codex terminals.", + "file": "apps/ade-cli/src/tuiClient/components/TerminalPane.tsx" + }, + { + "title": "Attached-mode input has no local echo and is one RPC per stdin event", + "severity": "low", + "description": "VERIFIED (refined). app.tsx 4192-4215 forwards each process.stdin 'data' event to writeTerminal as its own JSON-RPC call with no batching and no optimistic echo; the pane only reflects it on the next 500ms poll / pty_data event. Typing in attach mode visibly lags vs desktop's local xterm echo. Note: a paste arrives as a single stdin chunk and is forwarded intact, so paste is not dropped — the issue is latency/echo, not paste handling.", + "file": "apps/ade-cli/src/tuiClient/app.tsx" + } + ], + "polishOpportunities": [ + { + "title": "Smooth, low-latency live output instead of 500ms polled snapshots", + "description": "Drive TerminalPane primarily from the pty_data stream with a small interval-coalesced flush (~30-60ms, like desktop's frame-write scheduler) and use the preview poll only as a slow reconcile fallback. Today the 500ms cadence makes Claude's spinner and streaming text visibly stutter; tighter coalesced writes would feel live like Claude Code.", + "impact": "high" + }, + { + "title": "In-pane scrollback + copy mode for terminals", + "description": "The headless emulator already keeps a 2000-line scrollback (TerminalPane.tsx 379) but only lines.slice(0, visibleHeight) is ever painted. Add keyboard scrollback (PgUp/PgDn or a tmux-style copy-mode) over that buffer and a yank of visible/selected rows. Right now you can only see the top visibleHeight rows, which feels cramped and lossy vs the desktop scrollable terminal.", + "impact": "high" + }, + { + "title": "Animated attach/detach affordance and clearer control banner", + "description": "The attached banner is a single spin-frame + 'CLAUDE CONTROL · Ctrl+T returns to ADE · Ctrl+] escape' line with a round border whose color toggles on two spinner frames (terminalControlBorderColor, TerminalPane.tsx 334-336, 424-425). A transient toast 'Attached — keystrokes go to Claude' on attach and a matching detach flash would make the mode switch feel intentional and discoverable.", + "impact": "medium" + }, + { + "title": "Status-aware color/iconography for terminal state", + "description": "The status string is plain text ('live preview' / 'closed, resumable · Enter resumes' / 'closed', TerminalPane.tsx 424-430). Use theme accents + a small glyph (running dot, paused, exited-with-code) and show the exit code on close (it is already on the session as exitCode) so terminal state reads at a glance, matching the chat header polish.", + "impact": "low" + }, + { + "title": "Resume affordance visibility for closed sessions", + "description": "Closed resumable sessions only hint 'Enter resumes' in the status line. A dedicated footer action / inline button (consistent with the '^t Claude' pill) plus a brief 'Resuming…' spinner during resumeTerminalSession would make recovery feel first-class rather than buried in a status string.", + "impact": "low" + } + ], + "recommendations": [ + { + "title": "Stream-driven live output with coalesced flush; fix the 500-chunk desync", + "description": "Refactor terminalLiveChunks to an append-only ref/cursor consumed incrementally by TerminalPane (or a daemon cursor-based stream), flush via a short coalescing timer, and stop the slice(-500) saturation that stalls the write cursor (app.tsx 5174-5177 + TerminalPane.tsx 403-412). Keep the preview poll only as a periodic reconcile. Fixes the missing/duplicated-output bug and the steppy feel in one pass.", + "effort": "M", + "priority": "P0" + }, + { + "title": "Add terminal scrollback navigation and copy in TerminalPane", + "description": "Expose the existing 2000-line headless scrollback (TerminalPane.tsx 379) via keyboard scroll and add a copy/yank of visible/selected rows. Closes the biggest interaction gap vs the desktop scrollable terminal; today only the top visibleHeight rows are ever shown.", + "effort": "M", + "priority": "P1" + }, + { + "title": "Generalize terminal support beyond Claude (or scope the gap explicitly)", + "description": "Either widen listTerminalSessions (adeApi.ts 85-104) and TerminalPane to surface shell, codex, cursor-cli, droid, opencode PTYs AND gate compactClosedTerminalTranscript's chrome stripping behind toolType==='claude' so it stops mangling non-Claude output, or explicitly document that TUI terminals are intentionally Claude-only. The Claude-only filter plus Claude-only chrome heuristics currently compound to exclude most desktop terminal types.", + "effort": "L", + "priority": "P1" + }, + { + "title": "Debounce resize and unify the two preview writers", + "description": "Debounce the resize+preview effect (app.tsx 3403-3421) and make a single source of truth for setTerminalPreview so window/pane resize doesn't spam RPCs or race the 500ms poll (3423-3445).", + "effort": "S", + "priority": "P1" + }, + { + "title": "Local echo / batched input for attached mode", + "description": "Batch rapid stdin into fewer writeTerminal calls and/or optimistically echo printable keystrokes into the headless emulator before the round-trip (app.tsx 4192-4215) so attach-mode typing feels responsive instead of waiting on the next poll.", + "effort": "M", + "priority": "P2" + }, + { + "title": "Terminal session management actions", + "description": "Add a minimal terminal-scoped action set (rename, copy id, stop/SIGTERM, delete, resume) reachable from the session row or a small menu, mirroring desktop SessionContextMenu. signalTerminal and resumeTerminalSession already exist (app.tsx 5020-5022, 5494) but are only wired to generic stop/resume, not a management surface.", + "effort": "L", + "priority": "P2" + }, + { + "title": "Polish attach/detach + state visuals", + "description": "Add a transient attach/detach toast, status glyphs + exit-code display (exitCode already on the session), and a visible Resume affordance for closed sessions to reach Claude-Code-grade feel.", + "effort": "S", + "priority": "P2" + } + ], + "_key": "terminals", + "_kind": "parity" +} +``` + +</details> + +<details><summary><b>Files & editor / diffs</b> (parity)</summary> + +```json +{ + "dimension": "Files & editor / diffs", + "summary": "Verified against the cited TUI sources; the draft's central claims hold. The desktop Files tab is a full IDE-grade surface (virtualized git-aware tree, Monaco edit host with atomic saves and 3 modes, rich @pierre/diffs viewer with split/unified/wrap/line-numbers, quick-open, ripgrep search, inline image previews, context menu). The TUI exposes almost none of it. Its entire Files/diff story is one read-only `/diff` right pane plus the chat \"files changed\" summary group. CONFIRMED: (1) `/diff` (RightPane.tsx:1115-1133) renders each file's path + `+a -d` and only the first 8 lines of file.body in a single `theme.color.t3 dimColor` Text — no +/- colorization, no hunk-header styling, no syntax highlighting, no scrolling (RightPane has zero overflow/scroll machinery), no expand/collapse; capped at 20 files (summarizeDiffChanges, format.ts:830). (2) The chat-side `file_change` aggregation (aggregate.ts FileChangeEntry, line 25) consumes event.diff via diffStats() (line 175) and DISCARDS the diff body — the actual hunks an agent wrote are never inspectable anywhere in the TUI. The event type genuinely carries `diff: string` (chat.ts:374), so the data is available and thrown away. (3) The chat files-changed list is itself capped at GROUP_VISIBLE_CAP=3 most-recent entries with a \"+N more\" tail (ChatView.tsx:600,672), so even the +/- magnitude list is truncated. (4) Syntax-highlighting infra (highlightCache.ts, exactly 13 languages registered) is wired ONLY to assistant markdown fenced code blocks (format.ts:327), never to diffs. (5) Image plumbing (imageTargets.ts) parses dimensions but openLatestImage (app.tsx:6746) only shells out to open/xdg-open/rundll32 and ignores the parsed dimensions; no in-terminal rendering. CORRECTION to draft: a `/open` command DOES exist (commands.ts:22) but it opens the ADE context in the desktop app — there is still no fuzzy file quick-open and no project text search. There is no file tree, no file open/preview/edit, and no editing/save path. This remains the largest parity gap of any domain audited: the TUI is a truncated diff-summary viewer, not a files/editor surface.", + "tuiStatus": [ + { + "feature": "File tree / explorer", + "status": "missing", + "details": "No file tree, directory browsing, file open, or per-file navigation. The only file listing is the lane-details CHANGES section (RightPane.tsx LaneFileRow) showing a few changed paths with M/A/D/? glyphs — a git-status summary, not a browsable tree. No equivalent to FilesExplorer's virtualized tree, icons, or directory change dots.", + "gap": "No file browser surface exists. Users cannot open or view an arbitrary file's contents from the TUI.", + "tuiFiles": [ + "apps/ade-cli/src/tuiClient/components/RightPane.tsx" + ] + }, + { + "feature": "Per-file diff viewing (hunks)", + "status": "partial", + "details": "`/diff` calls conn.actionList('diff','getChanges') and renders RightPane kind 'diff'. RightPane.tsx (1115-1133) shows each file's path + `+a -d` and `file.body.split(/\\r?\\n/).slice(0,8).join('\\n')` in a SINGLE `theme.color.t3 dimColor` Text — no per-line green/red, no @@ hunk styling, no syntax highlighting. Capped at 20 files (summarizeDiffChanges, format.ts:830) and 8 body lines/file. RightPane has NO scroll/overflow mechanism, no expand/collapse, no split/unified, no wrap, no line numbers, no copy-path. Purely a static truncated dump.", + "gap": "Diff rendering is a truncated monochrome blob vs desktop's @pierre/diffs viewer. No add/del coloring, no hunk navigation, no scroll, only first 8 lines/file visible with no '…N more' indicator.", + "tuiFiles": [ + "apps/ade-cli/src/tuiClient/components/RightPane.tsx", + "apps/ade-cli/src/tuiClient/app.tsx", + "apps/ade-cli/src/tuiClient/format.ts" + ] + }, + { + "feature": "Agent edit diffs in chat (file_change events)", + "status": "missing", + "details": "aggregate.ts FileChangeEntry (line 25) stores only {itemId,path,kind,status,additions,deletions,deleted}; event.diff (chat.ts:374) is consumed by diffStats() (line 175) then dropped. filesChangedGroupRows (ChatView.tsx:731-794) therefore renders only path + ext badge + `+N −M`, and even that list is capped at GROUP_VISIBLE_CAP=3 most-recent entries via visibleEntries (ChatView.tsx:600,672) with a '+N more' tail. No way to expand a file_change to see the changed lines. Desktop surfaces these via ChatFileChangesPanel -> AdeDiffViewer.", + "gap": "The hunk text an agent wrote/edited is discarded at aggregation; the TUI can never show what changed, only the magnitude — and shows that for at most 3 files at a time.", + "tuiFiles": [ + "apps/ade-cli/src/tuiClient/aggregate.ts", + "apps/ade-cli/src/tuiClient/components/ChatView.tsx" + ] + }, + { + "feature": "Syntax highlighting in diffs", + "status": "missing", + "details": "highlightCache.ts provides hljs tokenization for 13 languages and ChatView.tsx tokensToRuns maps categories to theme colors — but ONLY for assistant markdown fenced code blocks (format.ts:327). The `/diff` pane and file_change rows apply no highlighting.", + "gap": "Highlighting engine exists and is good, but is not wired into any diff/file-content surface.", + "tuiFiles": [ + "apps/ade-cli/src/tuiClient/highlightCache.ts", + "apps/ade-cli/src/tuiClient/components/ChatView.tsx", + "apps/ade-cli/src/tuiClient/components/RightPane.tsx" + ] + }, + { + "feature": "Syntax highlighting in code blocks (chat)", + "status": "full", + "details": "Assistant markdown fenced code blocks are tokenized by highlightCode (format.ts:327) and rendered with per-category colors. LRU cache with byte budget and a line-count guard (highlightCode.length === rawLines.length, format.ts:328) that falls back to plain text on mismatch. Genuine parity for inline code.", + "gap": "None for chat code blocks. Exactly 13 languages registered (highlightCache.ts:16-28: ts/js/python/rust/go/swift/bash/json/yaml/markdown/xml/css/sql) — no java, kotlin, c/cpp, ruby, php, etc.; unlisted languages fall back to plain.", + "tuiFiles": [ + "apps/ade-cli/src/tuiClient/highlightCache.ts", + "apps/ade-cli/src/tuiClient/format.ts", + "apps/ade-cli/src/tuiClient/components/ChatView.tsx" + ] + }, + { + "feature": "Image preview", + "status": "partial", + "details": "imageTargets.ts has robust image plumbing — extension detection, header-based dimension parsing for PNG/GIF/WEBP/JPEG (readImageDimensionsFromBuffer), and clipboard image capture for paste-attachment. But the only viewing path is openLatestImage (app.tsx:6746) which resolves latestOpenableImageTarget to a path string and shells out to OS open/xdg-open/rundll32. NO in-terminal rendering (no iTerm/kitty/sixel inline protocol, no ASCII/ansi preview), and the parsed dimensions are never surfaced to the user.", + "gap": "Images can only be opened in an external viewer, breaking flow; no inline thumbnail or dimensions shown despite the data being available. Desktop shows inline image previews up to 1MB.", + "tuiFiles": [ + "apps/ade-cli/src/tuiClient/imageTargets.ts", + "apps/ade-cli/src/tuiClient/app.tsx" + ] + }, + { + "feature": "Quick open / cross-file search", + "status": "missing", + "details": "No fuzzy file quick-open and no project text-search command. Note: a `/open` command exists (commands.ts:22) but it opens the ADE context in the desktop app, not a file; file/diff-related right commands are only /diff, /log, /status, /pr. The runtime exposes files.quickOpen/searchText for desktop but the TUI never calls them.", + "gap": "No fuzzy file open and no project text search from the TUI (the existing /open is a desktop-handoff, not a file picker).", + "tuiFiles": [ + "apps/ade-cli/src/tuiClient/commands.ts", + "apps/ade-cli/src/tuiClient/adeApi.ts" + ] + }, + { + "feature": "File editing / atomic save", + "status": "missing", + "details": "No Monaco-equivalent editor and no write path; @ mentions can attach files to a prompt but the TUI cannot open, edit, or save a file. No conflict-resolution surface either.", + "gap": "Entirely absent; the TUI is read-only with respect to files.", + "tuiFiles": [ + "apps/ade-cli/src/tuiClient/app.tsx" + ] + } + ], + "bugs": [ + { + "title": "file_change diff body is discarded at aggregation, making agent edits unviewable", + "severity": "high", + "description": "aggregate.ts FileChangeEntry (line 25) keeps only path/kind/status/additions/deletions and drops event.diff after diffStats() consumes it (line 175). The event type genuinely carries `diff: string` (apps/desktop/src/shared/types/chat.ts:374), so the data exists and is thrown away. filesChangedGroupRows (ChatView.tsx:731) consequently has no diff text to show — the TUI can NEVER display the lines an agent changed, only the +/− magnitude. This is the central functional gap for this domain.", + "file": "apps/ade-cli/src/tuiClient/aggregate.ts" + }, + { + "title": "/diff pane truncates silently and unrecoverably (no scroll exists)", + "severity": "high", + "description": "summarizeDiffChanges (format.ts:830) hard-slices to 20 files, and RightPane.tsx (line 1127) slices each file.body to the first 8 lines with no '…N more' indicator. RightPane has NO scroll/overflow machinery at all (no maxHeight/overflow/scrollOffsetRows in the component), so a 200-line change shows only 8 leading context lines and the rest is permanently invisible.", + "file": "apps/ade-cli/src/tuiClient/components/RightPane.tsx" + }, + { + "title": "Diff lines are rendered with no +/- colorization", + "severity": "medium", + "description": "RightPane.tsx (1125-1129) renders the entire diff body in one Text with color theme.color.t3 dimColor. Added/removed/context lines are visually identical — the user cannot distinguish additions from deletions, the whole point of a diff. diffStats (aggregate.ts:129-136) already classifies +/- per line, so the data is trivially available.", + "file": "apps/ade-cli/src/tuiClient/components/RightPane.tsx" + }, + { + "title": "Chat files-changed list capped at 3 entries, hiding most changed files", + "severity": "medium", + "description": "filesChangedGroupRows uses visibleEntries with GROUP_VISIBLE_CAP=3 (ChatView.tsx:600,672), so a turn that touches 10 files shows only the 3 most recent paths and collapses the rest into '+7 more' with no way to expand. Combined with the discarded diff body, an agent's multi-file edit is barely summarizable in chat, let alone inspectable.", + "file": "apps/ade-cli/src/tuiClient/components/ChatView.tsx" + }, + { + "title": "Image dimensions parsed but never surfaced; viewing requires external app", + "severity": "low", + "description": "imageTargets.ts implements full header-based dimension parsing (readImageDimensions / readImageDimensionsFromBuffer) but openLatestImage (app.tsx:6746) resolves only a path and shells out to `open`/`xdg-open`/`rundll32`, ignoring dimensions. The parsed width/height are dead data, and there is no in-terminal preview even where the terminal supports inline images.", + "file": "apps/ade-cli/src/tuiClient/app.tsx" + } + ], + "polishOpportunities": [ + { + "title": "Colorize diff hunks like git/Claude Code", + "description": "Render `+` lines green, `-` lines red, `@@` hunk headers cyan/violet, context dim. Reuse aggregate.ts diffStats line-classification regex (lines 134-135). Highest-leverage visual win — turns the /diff pane from a gray blob into a readable diff.", + "impact": "high" + }, + { + "title": "Expandable file_change rows in chat with inline hunks", + "description": "Add `diff?` to FileChangeEntry (stop discarding event.diff), raise/replace GROUP_VISIBLE_CAP for files-changed, and let ↵ on a row expand a colorized, syntax-highlighted hunk view inline (highlightCache.ts already built). Mirrors Claude Code collapsible edit blocks and desktop ChatFileChangesPanel.", + "impact": "high" + }, + { + "title": "Scrollable, full diff pane with per-file collapse", + "description": "RightPane currently has no scroll at all; add scroll-offset state (the chat already has sliceRows / maxScrollOffsetForRows / scrollOffsetRows machinery in ChatView.tsx:1030-1059 to copy) plus per-file expand/collapse and a file/line-count header, so large diffs are navigable instead of capped at 8 lines/20 files.", + "impact": "high" + }, + { + "title": "Syntax-highlight diff content", + "description": "Pipe each file's body through highlightCode (resolving language from extension) and overlay the +/- gutter coloring, matching desktop @pierre/diffs and Claude Code highlighted diffs. No new dependency.", + "impact": "medium" + }, + { + "title": "Inline image rendering via terminal image protocols", + "description": "Detect iTerm2/kitty/sixel terminals and render image attachments inline (graceful ANSI/ASCII or dimensions-only fallback) instead of always shelling out to the OS viewer. The dimension parser already exists in imageTargets.ts.", + "impact": "medium" + }, + { + "title": "Mouse hover/click affordances on diff and file rows", + "description": "hitTestRegistry.ts already exists and is wired into app.tsx and MultiChatGrid; register diff/file rows so a click expands a file (and opens it once an open path exists) with a hover highlight, making the pane feel alive like the desktop tree.", + "impact": "low" + } + ], + "recommendations": [ + { + "title": "Preserve diff text and add expandable, colorized hunks in chat", + "description": "Add `diff?: string` to FileChangeEntry in aggregate.ts (stop discarding event.diff at line 175), raise the GROUP_VISIBLE_CAP=3 file cap for files-changed, and make rows expandable in ChatView.tsx to show colorized (+green/−red/@@ cyan) and optionally syntax-highlighted hunks. Closes the single biggest gap: agent edits are currently never inspectable.", + "effort": "L", + "priority": "P0" + }, + { + "title": "Colorize and make the /diff right pane scrollable + per-file expandable", + "description": "In the RightPane.tsx diff branch, render +/-/@@ lines with distinct colors, introduce a scroll offset (RightPane has none today; reuse ChatView's sliceRows/maxScrollOffsetForRows), show '… N more lines/files' affordances, and let ↵ expand a file. Replace the silent 8-line/20-file caps with windowed scrolling.", + "effort": "M", + "priority": "P0" + }, + { + "title": "Wire syntax highlighting into diff surfaces", + "description": "Resolve language by file extension and run diff content through the existing highlightCode (highlightCache.ts), layering +/- gutter color on top. No new dependency. (Note: only 13 languages are registered, so unlisted langs fall back to plain — acceptable for v1.)", + "effort": "S", + "priority": "P1" + }, + { + "title": "Add in-terminal image preview with dimensions fallback", + "description": "Surface readImageDimensions output in the open notice immediately, and render inline via iTerm2/kitty/sixel when supported (ASCII/dimensions fallback otherwise) instead of only spawning the OS viewer in openLatestImage (app.tsx:6746).", + "effort": "M", + "priority": "P1" + }, + { + "title": "Add fuzzy quick-open and text-search commands", + "description": "Expose files.quickOpen and files.searchText (already in the runtime, used by desktop) via new commands feeding a right-pane results list with ↵-to-open. Pick a non-conflicting name — `/open` is already taken (it hands off to the desktop app); use e.g. `/find` and `/goto`.", + "effort": "L", + "priority": "P2" + }, + { + "title": "Build a minimal read-only file tree / preview surface", + "description": "A right-pane lane-scoped file tree (runtime files.listTree) with git-status glyphs and ↵-to-preview a read-only, syntax-highlighted file. Stops short of editing but gives the TUI real file-browsing parity with the desktop explorer.", + "effort": "XL", + "priority": "P2" + } + ], + "_key": "files", + "_kind": "parity" +} +``` + +</details> + +<details><summary><b>Settings & config</b> (parity)</summary> + +```json +{ + "dimension": "Settings & config", + "summary": "short summary to isolate array size limit", + "tuiStatus": [ + { + "feature": "AI provider API key management (store/replace/delete/verify, cloud + Cursor)", + "status": "missing", + "details": "No storeApiKey/deleteApiKey/verifyApiKey under tuiClient; adeApi.ts wires only read-only ai/listApiKeys (adeApi.ts:330). loginUnavailableHint for cursor says 'Open Settings > AI Providers' (app.tsx:1660).", + "gap": "Cannot add/replace/delete/verify keys from the TUI; Cursor has no in-TUI auth path.", + "tuiFiles": [ + "apps/ade-cli/src/tuiClient/adeApi.ts", + "apps/ade-cli/src/tuiClient/app.tsx" + ] + }, + { + "feature": "Provider/runtime readiness status view", + "status": "glitchy", + "details": "BUG (medium): providerReadinessRows (app.tsx:3320) is dead code — only refs are the declaration + helpers (1264-1324) + type import (175); never rendered. storedApiKeyProviders (app.tsx:2260) and the getOpenCodeRuntimeDiagnostics call feed discarded state. /status shows only project/workspace/lane/chat/ADE:ready (app.tsx:5801).", + "gap": "Readiness computed but invisible; no status surface.", + "tuiFiles": [ + "apps/ade-cli/src/tuiClient/app.tsx" + ] + }, + { + "feature": "CLI provider login", + "status": "partial", + "details": "BUG (medium): loginCommandsForProvider (app.tsx:1651-1656) covers only claude/codex/opencode; Cursor/Droid return [], /login rejected (6348-6350), loginUnavailableHint (1658-1666) fires. No ai/storeApiKey, so Cursor can't be authed from `ade code` without env var. Droid hint offers standalone `droid` /login (still leaves the TUI).", + "gap": "No in-TUI auth for Cursor or Droid; 3 of 5 runtimes.", + "tuiFiles": [ + "apps/ade-cli/src/tuiClient/app.tsx" + ] + }, + { + "feature": "Local providers (LM Studio / Ollama) endpoint + autoDetect + preferred-model", + "status": "missing", + "details": "TUI never calls ai/updateConfig and has no local-provider UI. Locals observed only via getStatus, which feeds the unrendered providerReadinessRows.", + "gap": "No way to configure or view local endpoints.", + "tuiFiles": [ + "apps/ade-cli/src/tuiClient/adeApi.ts" + ] + }, + { + "feature": "AI feature toggles + session-intelligence + per-feature model overrides", + "status": "missing", + "details": "No ai/updateConfig in tuiClient, so AiFeaturesSection's sessionIntelligence/featureModelOverrides writes are unreachable.", + "gap": "All AI feature config is desktop-only.", + "tuiFiles": [ + "apps/ade-cli/src/tuiClient/adeApi.ts" + ] + }, + { + "feature": "Project config view/edit + config trust confirmation", + "status": "missing", + "details": "BUG (medium): zero projectConfig.* calls in tuiClient, so confirmTrust is unreachable. Per configuration-schema.md, getExecutableConfig() throws when requiresSharedTrust until confirmTrust runs. /status shows no trust state.", + "gap": "Trust-required projects cannot be approved from `ade code`; config-driven commands can hard-block.", + "tuiFiles": [ + "apps/ade-cli/src/tuiClient/app.tsx" + ] + }, + { + "feature": "Appearance / preferences (theme, fonts, density, sounds, terminal typography)", + "status": "missing", + "details": "theme.ts is a fixed export (line 150) plus PROVIDER_THEME brand colors (line 82). No theme/font/density/preference read or written.", + "gap": "Fixed dark palette only.", + "tuiFiles": [ + "apps/ade-cli/src/tuiClient/theme.ts" + ] + }, + { + "feature": "Integrations config (GitHub / Linear / Mobile Push / ADE CLI)", + "status": "partial", + "details": "commands.ts wires operational /linear * (55-66) and /pr * (50-54), not settings config. No enablement, prPollingIntervalSeconds, or mobile-push pairing.", + "gap": "Integration config is desktop-only; Mobile Push pairing absent.", + "tuiFiles": [ + "apps/ade-cli/src/tuiClient/commands.ts", + "apps/ade-cli/src/tuiClient/app.tsx" + ] + }, + { + "feature": "Claude-compat config diagnostics (/keybindings, /statusline, /doctor, /system)", + "status": "full", + "details": "commands.ts registers these (71-76); app.tsx handles them at 5739-5781; /keybindings open creates/opens the Claude keybindings file (app.tsx:5773). Genuinely TUI-native.", + "gap": "None notable.", + "tuiFiles": [ + "apps/ade-cli/src/tuiClient/app.tsx", + "apps/ade-cli/src/tuiClient/commands.ts" + ] + }, + { + "feature": "Escape hatch: /ade domain.action json", + "status": "partial", + "details": "/ade (app.tsx:6288-6324) calls run_ade_action with arbitrary domain/action/JSON; could hand-type ai.storeApiKey. No discoverability, validation, or secret-masking — keys typed in plaintext.", + "gap": "No safe settings affordance; secrets exposed in plaintext.", + "tuiFiles": [ + "apps/ade-cli/src/tuiClient/app.tsx" + ] + } + ], + "recommendations": [ + { + "title": "Render provider readiness (kill the dead useMemo) in a Providers/status pane", + "description": "POLISH high: consume providerReadinessRows (app.tsx:3320) in RightPane — add a 'providers' kind or extend /status with per-provider status glyph, auth, model counts, OpenCode count, stored-key badge, selectable rows. Low-risk; removes the largest gap.", + "effort": "M", + "priority": "P0" + }, + { + "title": "Add in-TUI API key store/verify/delete (masked entry) for all providers", + "description": "POLISH high: add adeApi wrappers for ai/storeApiKey, deleteApiKey, verifyApiKey + an inline masked-entry flow with Verifying -> Connected/Invalid, covering cloud providers + Cursor. Closes the plaintext-secret hazard of /ade.", + "effort": "L", + "priority": "P0" + }, + { + "title": "Add config-trust confirmation from the TUI (trust row in /status or /doctor)", + "description": "POLISH medium: add projectConfig.get + projectConfig.confirmTrust wrappers and a trust row (current/required + short hashes) with an inline confirm and warning-tint when requiresSharedTrust.", + "effort": "M", + "priority": "P1" + }, + { + "title": "Expose AI feature toggles + per-feature model overrides via ai/updateConfig", + "description": "Add an ai/updateConfig wrapper and a feature-toggle pane (auto-titles, summaries, PR descriptions, commit messages) with model selection, matching AiFeaturesSection.", + "effort": "L", + "priority": "P1" + }, + { + "title": "Reframe the setup pane as a real settings surface; inline deep-link + refresh spinner", + "description": "POLISH medium: open-settings row (app.tsx:1214) only fires a transient notice (7337-7344); refresh-status (7331-7335) has no spinner. Reframe the setup pane and inline the deep-link result.", + "effort": "M", + "priority": "P1" + }, + { + "title": "Support local provider (LM Studio/Ollama) endpoint config", + "description": "Add an editable local-providers pane writing ai/updateConfig({ localProviders }) with endpoint/autoDetect/preferred-model fields.", + "effort": "M", + "priority": "P2" + }, + { + "title": "Cursor/Droid auth coverage + optional TUI theme preference", + "description": "Once key storage lands, route Cursor/Droid through the in-TUI key flow and update loginUnavailableHint (app.tsx:1658). Lowest priority: a light/dark/accent preference, since theme.ts (line 150) is a fixed palette.", + "effort": "S", + "priority": "P2" + } + ], + "_key": "settings", + "_kind": "parity" +} +``` + +</details> + +<details><summary><b>Automations / scheduled agents</b> (parity)</summary> + +```json +{ + "dimension": "Automations / scheduled agents", + "summary": "VERIFIED: The ADE desktop ships a full automations/scheduled-agents product (rule list + split-pane editor under apps/desktop/src/renderer/components/automations/) covering cron-schedule and event triggers, agent-session vs built-in execution, an action-chain editor, model/permission/guardrail/output config, templates, natural-language draft authoring (validate/simulate/save), per-rule run history + run detail, manual run-now with lane picker, enable/disable toggles, and live event-driven refresh. The runtime exposes all of this via the allowlisted `automations` ADE-action domain, and the headless `ade automations ...` CLI wraps every verb, including a dedicated formatAutomationRunDetail formatter (cli.ts:11512) reachable through the `automation-run-detail` formatter (cli.ts:7997, 12804). The TUI (Ink client at apps/ade-cli/src/tuiClient/) has confirmed ZERO automations surface: a full grep of tuiClient returns only two hits, both the passive session-metadata passthrough of automationId/automationRunId (app.tsx:342-343). There is no automations entry in the slash-command registry (commands.ts:14-77), no `kind: \\\"automations\\\"` in the RightPaneContent union (types.ts:142-221), no adeApi.ts wrapper (adeApi.ts has dedicated wrappers for lanes/chats/terminals/PRs/models/plugins/output-styles but nothing for automations), and no automations event subscription (the only subscribeRuntimeEvents call is category-scoped to `pty`, app.tsx:5168). The single reachable path is the generic `/ade automations.<action> <json>` escape hatch (app.tsx:6288-6324), which works because `automations` is an allowlisted run_ade_action domain but requires the user to hand-author JSON-RPC arguments and dumps unformatted output via renderObject (app.tsx:6320-6323). SHARPENED CONTRAST: Linear — a directly comparable runtime-action domain — received a first-class TUI treatment (a full `/linear *` slash-command family, commands.ts:55-66, plus a dedicated 197-line linearCommands.ts), which makes the total absence of any automations surface look like an oversight rather than a deliberate scope cut. This is the largest single-domain parity gap: an entire desktop product is invisible in the TUI.", + "tuiStatus": [ + { + "feature": "View list of automation rules (name, trigger, execution, status, next/last run)", + "status": "missing", + "details": "Desktop RulesTab renders a rich rule list (status chip, trigger label, execution label, mode summary, next/last run timestamps, enable toggle, hover actions). VERIFIED the TUI has no rule-list view: RightPaneContent union (types.ts:142-221) has no automations kind, and grep of tuiClient finds no rule-rendering code. The only way to see rules is `/ade automations.list`, which dumps raw JSON via renderObject (app.tsx:6320-6323).", + "gap": "No automations list view or pane in the TUI. Users cannot browse rules without hand-typing a raw JSON-RPC escape command.", + "tuiFiles": [ + "apps/ade-cli/src/tuiClient/app.tsx", + "apps/ade-cli/src/tuiClient/adeApi.ts", + "apps/ade-cli/src/tuiClient/types.ts", + "apps/ade-cli/src/tuiClient/components/RightPane.tsx" + ] + }, + { + "feature": "Create / edit an automation rule (trigger, schedule cron, execution surface, actions, model, guardrails, outputs)", + "status": "missing", + "details": "Desktop RuleEditorPanel.tsx is a full editor (trigger families incl. Schedule with SCHEDULE_PRESETS, agent-session vs built-in execution, action-chain editor, model/permission config, tool palette, guardrails, output disposition). VERIFIED the TUI has none of this — no editor kind in RightPaneContent, no form command for automations (the `form` kind's command union, types.ts:173, lists only new-lane/rename/pr-open/feedback/lane-delete/new-lane-from-unstaged). The generic `/ade automations.saveRule {json}` path requires hand-authoring a full AutomationRuleDraft as inline JSON, which is infeasible interactively.", + "gap": "No rule authoring/editing UI. Cron schedule presets, trigger filters, and the action-chain editor are entirely absent.", + "tuiFiles": [ + "apps/ade-cli/src/tuiClient/app.tsx", + "apps/ade-cli/src/tuiClient/types.ts" + ] + }, + { + "feature": "Run an automation now (manual trigger, with lane selection)", + "status": "missing", + "details": "Desktop RulesTab calls triggerManually and shows a lane-picker modal when execution.laneMode is 'require-on-trigger'. VERIFIED the TUI exposes no run-now affordance; only the raw `/ade automations.triggerManually {\"id\":...}` escape works, with no lane picker and no result formatting.", + "gap": "No interactive 'run now' for an automation, and no lane-selection prompt for rules that require one.", + "tuiFiles": [ + "apps/ade-cli/src/tuiClient/app.tsx" + ] + }, + { + "feature": "Enable / disable a rule", + "status": "missing", + "details": "Desktop RuleListRow has an on/off toggle calling automations.toggle. VERIFIED the TUI has no toggle control; reachable only via raw `/ade automations.toggleRule {\"id\":...,\"enabled\":true}`.", + "gap": "No quick enable/disable control in the TUI.", + "tuiFiles": [ + "apps/ade-cli/src/tuiClient/app.tsx" + ] + }, + { + "feature": "View run history and per-run detail (actions, status, errors, output)", + "status": "missing", + "details": "Desktop RuleHistoryPanel + RunDetailPanel show per-rule run lists and detailed breakdowns with live refresh. The headless CLI already has formatAutomationRunDetail (cli.ts:11512, dispatched via the `automation-run-detail` formatter at cli.ts:12804). VERIFIED the TUI has neither a history view nor any run-detail rendering; raw `/ade automations.listRuns`/`getRunDetail` dump unformatted JSON. The CLI formatter is portable and should be reused.", + "gap": "No run-history view, no run-detail rendering, despite the headless CLI already shipping a reusable formatter.", + "tuiFiles": [ + "apps/ade-cli/src/tuiClient/app.tsx", + "apps/ade-cli/src/tuiClient/components/RightPane.tsx" + ] + }, + { + "feature": "Templates (seed a new rule from a library)", + "status": "missing", + "details": "Desktop TemplatesTab / AutomationsTemplatesPage offer a template gallery that seeds a draft. VERIFIED the TUI has no templates surface and no draft-seeding flow.", + "gap": "No template picker in the TUI.", + "tuiFiles": [ + "apps/ade-cli/src/tuiClient/app.tsx" + ] + }, + { + "feature": "Natural-language rule authoring + validate/simulate", + "status": "missing", + "details": "Desktop calls automations.validateDraft / simulate backed by automationPlannerService; the headless CLI exposes `automations create --text` and `--from-file`/`--stdin` (cli.ts:1445, 7882). VERIFIED the TUI offers no NL authoring, no validate, no simulate. This is the most TUI-friendly entry point (a single text prompt) yet is entirely absent.", + "gap": "No natural-language create/validate/simulate flow, despite the planner and CLI path already existing.", + "tuiFiles": [ + "apps/ade-cli/src/tuiClient/app.tsx" + ] + }, + { + "feature": "Live updates when automations fire / runs change state", + "status": "missing", + "details": "Desktop subscribes to automation events to live-refresh rule list and run history. VERIFIED the TUI's only subscribeRuntimeEvents call is category-scoped to `pty` (app.tsx:5168); it never subscribes to automation/runtime automation events. The only automation-aware code is the passive session-metadata passthrough (app.tsx:342-343).", + "gap": "No live automation event subscription in the TUI, so even if a rule fires there is no in-TUI feedback.", + "tuiFiles": [ + "apps/ade-cli/src/tuiClient/app.tsx", + "apps/ade-cli/src/tuiClient/connection.ts" + ] + }, + { + "feature": "Trigger filters (GitHub/Linear: labels, authors, branch, repo, team, project)", + "status": "missing", + "details": "Desktop GitHubTriggerFilters / LinearTriggerFilters render per-trigger filter editors. VERIFIED the TUI has no trigger-filter UI of any kind.", + "gap": "No way to author or view trigger filters from the TUI.", + "tuiFiles": [ + "apps/ade-cli/src/tuiClient/app.tsx" + ] + }, + { + "feature": "Generic JSON-RPC escape hatch for automations", + "status": "partial", + "details": "VERIFIED: `/ade automations.<action> <json>` reaches the automation service because `automations` is an allowlisted run_ade_action domain. It splits domain.action, forwards parseAdeActionPayload(rest), and renders the raw result via renderObject at depth 24 (app.tsx:6306-6323). This provides programmatic access but no list/editor/history/formatting/lane-picker, and requires the user to hand-author JSON-RPC arguments.", + "gap": "Only a raw, unformatted, expert-only path exists; it is not a substitute for any of the desktop's structured automation views.", + "tuiFiles": [ + "apps/ade-cli/src/tuiClient/app.tsx" + ] + } + ], + "bugs": [ + { + "title": "`/ade automations.*` escape hatch silently forwards mis-shaped payloads with no schema hint or validation", + "severity": "low", + "file": "apps/ade-cli/src/tuiClient/app.tsx", + "description": "VERIFIED at app.tsx:6306-6323: the `/ade` handler splits domain.action and forwards `...parseAdeActionPayload(parsed.rest)` straight into run_ade_action with no schema hint, no client-side validation, and no usage example for the specific action. For automations verbs needing structured args (saveRule expects a full AutomationRuleDraft; triggerManually expects {id, laneId?}), a malformed object is forwarded as-is and the runtime throws a generic JsonRpcError invalidParams with no guidance on the expected shape. This is a real-but-low-severity papercut on the escape hatch itself; the larger consequence (create/update/run-with-lane being unreachable in practice) is a parity gap, not a defect, and is already captured under tuiStatus." + }, + { + "title": "Automation-originated sessions surface in the TUI session list with no automation context label or filter", + "severity": "low", + "file": "apps/ade-cli/src/tuiClient/app.tsx", + "description": "VERIFIED: app.tsx:342-343 passes through automationId/automationRunId on session objects, and laneTree.ts (the session/lane tree builder) does no filtering or labeling on those fields (only de-dupes by id, laneTree.ts:56). So automation-launched agent-session chats appear in the TUI session/lane tree indistinguishably from user-started chats — no badge, no filter, no executor indicator. Desktop scopes these as automation-owned chats. A user could be confused by chats they did not start. Low severity because it is cosmetic/clarity rather than incorrect behavior." + } + ], + "polishOpportunities": [ + { + "title": "Status-colored, animated run rows", + "description": "Mirror Claude Code's tool-call rows: running automations get a subtle spinner/pulse, completed-clean a green check, failed a red dot with the error inline. The TUI already has a spin tick (spinTick.tsx) and theme tones to reuse. A glanceable, colorized rule/run list reads far better than the raw renderObject JSON that is the only output today.", + "impact": "high" + }, + { + "title": "Natural-language create as a first-class slash command", + "description": "`/automation create <free text>` that streams the planner's draft back with a confirm-to-save step would be the most delightful and TUI-native entry point — one line of English instead of a multi-field JSON object. The planner (automationPlannerService) and the `automations create --text`/`--from-file`/`--stdin` CLI path (cli.ts:1445, 7882) already exist, so this is wiring rather than new backend work.", + "impact": "high" + }, + { + "title": "Cron schedule preview with human-readable next-fire times", + "description": "Desktop shows next/last run timestamps and SCHEDULE_PRESETS. A TUI automations view should render the cron expression alongside a computed human-readable cadence ('Weekdays at 9 AM') and a 'next run in 3h 12m' detail — the kind of small live touch Claude Code uses to make the terminal feel alive. Depends on the runtime returning next-fire times via the list/get actions (verify they are present in the payload before promising the countdown).", + "impact": "medium" + }, + { + "title": "Mouse-clickable rule list + run-now / toggle hotkeys", + "description": "The TUI already supports mouse hit-testing in other panes (hitTestRegistry.ts). An automations pane should let the user click a rule to expand its detail, click a toggle to enable/disable, and press a single key (e.g. 'r') to run now — matching desktop's hover-action affordances without leaving the keyboard-first flow.", + "impact": "medium" + } + ], + "recommendations": [ + { + "title": "Add a read-only automations view (list + run history) to the TUI", + "description": "Introduce an automations RightPaneContent kind plus an adeApi.ts wrapper over the allowlisted automations actions (list, listRuns, getRunDetail, getHistory). Render a colorized rule list (trigger, schedule cadence, enabled, last/next run, last-run status) and a run-history/detail view. Port the existing headless CLI formatAutomationRunDetail logic (cli.ts:11512) so run detail looks consistent across surfaces. This closes the single biggest visibility gap: today an entire desktop product is invisible in the TUI, while the comparable Linear domain already has a full slash-command family.", + "effort": "L", + "priority": "P0" + }, + { + "title": "Add automation lifecycle slash commands (toggle, run now, delete) with a lane picker, mirroring the existing /linear family", + "description": "Add a `/automation` (or `/automations`) command family to commands.ts — `toggle <id>`, `run <id>` (prompting for a lane when execution.laneMode is 'require-on-trigger', mirroring desktop RulesTab), and `delete <id>` — modeled on the existing /linear command family (commands.ts:55-66) and its linearCommands.ts helper. Back them with adeApi wrappers over toggleRule/triggerManually/deleteRule, and subscribe to runtime automation events so the list and any run views live-refresh (today subscribeRuntimeEvents is only used for the `pty` category at app.tsx:5168).", + "effort": "M", + "priority": "P1" + }, + { + "title": "Natural-language create/validate/simulate flow", + "description": "Add `/automation create <free text>` that calls the planner (validateDraft/simulate/saveDraft, already exposed through the runtime and the `automations create --text` CLI path at cli.ts:7882) and walks the user through a confirm-and-save step in the RightPane. This is the most TUI-appropriate authoring path and avoids needing a full structured editor for v1.", + "effort": "M", + "priority": "P1" + }, + { + "title": "Label automation-originated sessions in the TUI session/lane tree", + "description": "Use the already-passed automationId/automationRunId (app.tsx:342-343) to badge automation-launched chats (e.g. an 'auto' tag) in laneTree.ts and optionally filter them out of the default human-chat list, matching desktop's automation-scoped chat treatment. Prevents confusion from chats the user did not start.", + "effort": "S", + "priority": "P2" + }, + { + "title": "Full structured rule editor in the TUI (triggers, actions, guardrails)", + "description": "Longer-term parity: a form-driven editor mirroring desktop RuleEditorPanel (trigger family + cron presets, agent-session vs built-in, action chain, model/permission/guardrail/output fields). This is the largest remaining piece; defer behind the read-only view, lifecycle commands, and NL-create, which together cover most real-world TUI usage. Note the existing `form` RightPaneContent kind (types.ts:171-189) is single-screen and would need extension or a new multi-step kind.", + "effort": "XL", + "priority": "P2" + } + ], + "_key": "automations", + "_kind": "parity" +} +``` + +</details> + +<details><summary><b>Orchestration / work board / CTO</b> (parity)</summary> + +```json +{ + "dimension": "Orchestration / work board / CTO", + "summary": "Verified against the cited TUI files. The desktop ships three surfaces in this domain: (1) a full CTO operator console (apps/desktop/src/renderer/components/cto/CtoPage.tsx); (2) a live orchestration plan panel (apps/desktop/src/renderer/components/orchestration/OrchestrationPanel.tsx); and (3) a subagent/teammate/background roster via apps/desktop/src/shared/chatSubagents.ts. The TUI implements ONLY the third surface: a read-only roster in the chat-info pane (RightPane.tsx ChatInfoRoster, fed by the thin re-export shim subagentPane.ts over the SAME shared chatSubagents.ts the desktop uses), plus inspect-and-view-transcript. CONFIRMED: CTO/worker management and the orchestration phase/task plan panel are entirely absent from the TUI — there is no /cto, /team, /workers, or /hire command (commands.ts), and orchestrationRunId/Role/Tag/StepId/BundlePath are pure pass-through fields in the session normalizer (app.tsx:358-363) never read into any UI. CONFIRMED: Linear orchestration exists only as fire-and-forget slash commands (linearCommands.ts builds a single tool request per verb -> result dumped into a generic details pane); the resolve verbs (run resolve / sync resolve approve|reject|retry|resume|complete) exist but there is no board, timeline, or live subscription. The daemon already exposes the data (get_cto_state, spawn_agent, orchestrator event polling, Linear-sync tool specs) and there is even a generic `/ade <domain.action>` escape hatch — so the gap is purely client-side UI. IMPORTANT CORRECTION/NUANCE: the chat-info pane DOES already render a PLAN block (RightPane.tsx ChatInfoPlanBlock) and `/info` exists, but it surfaces the agent's TodoWrite/exit-plan-mode plan via latestPlan(events) — NOT the orchestration plan.md / phase-task DAG — so the orchestration-plan gap stands, and a future phase strip should reuse that block's rendering pattern rather than duplicate it. The one surface the TUI ships is genuinely solid and Claude-Code-adjacent, but it is the smallest of the three.", + "tuiStatus": [ + { + "feature": "Subagent / teammate / background-agent roster", + "status": "full", + "details": "VERIFIED. RightPane.tsx ChatInfoRoster renders a CHATS roster: main row + foreground subagents + teammates + background sections, status glyphs/colors (theme.agentStatusGlyph driven by status), durations, a live/done/failed/bg tally, a 5-row scrolling window (ROSTER_CAPACITY=5) with up/down overflow hints, and a selected-row detail line. Backed by the SAME shared buildSubagentPaneRows / subagentSnapshotsFromEvents the desktop uses. At parity with the desktop roster and more compact.", + "tuiFiles": [ + "apps/ade-cli/src/tuiClient/components/RightPane.tsx", + "apps/ade-cli/src/tuiClient/subagentPane.ts", + "apps/ade-cli/src/tuiClient/app.tsx" + ], + "gap": "Read-only — no per-agent actions (stop/respawn). Glyph is purely status-driven; teammates and foreground subagents are visually identical aside from the section header, and background rows get only a cyan color tint (not a distinct glyph)." + }, + { + "feature": "Subagent transcript drill-in (inspect an agent's work)", + "status": "full", + "details": "VERIFIED. Selecting a roster row sets inspectedSubagentId (app.tsx ~3227-3229); displayEvents swaps to buildSubagentTranscriptEvents and the chat pane shows the agent's filtered transcript with a synthetic header and 'return ↵' affordance. Mirrors desktop chatSubagents transcript building. An effect (app.tsx:2964-2968) clears inspectedSubagentId when the agent leaves the snapshot set, so a reaped agent does not leave a dangling transcript view.", + "tuiFiles": [ + "apps/ade-cli/src/tuiClient/app.tsx", + "apps/ade-cli/src/tuiClient/components/RightPane.tsx" + ], + "gap": "No jump from a subagent row to its real underlying chat session (desktop OrchestrationPanel onOpenSession opens the worker chat); the TUI only shows a reconstructed transcript." + }, + { + "feature": "Orchestration plan panel (phases, tasks, status pills, plan.md)", + "status": "missing", + "details": "VERIFIED. Desktop OrchestrationPanel renders run header, phase accordion, task cards with status pills, owner/elapsed, validation checklist, plan narrative, and a plan-approval bar. In the TUI, orchestrationRunId/Role/Tag/StepId/BundlePath are only pass-through fields in the session normalizer (app.tsx:358-363) — never read into any UI. There is no PhaseAccordion, TaskCard, RunHeader, or plan.md rendering in tuiClient. NOTE: the chat-info PLAN block (ChatInfoPlanBlock, fed by latestPlan(events)) renders the agent's TodoWrite/exit-plan-mode plan, which is a different surface and does not cover orchestration phases/tasks.", + "tuiFiles": [ + "apps/ade-cli/src/tuiClient/app.tsx", + "apps/ade-cli/src/tuiClient/components/RightPane.tsx" + ], + "gap": "An orchestrator/lead chat shows no run plan, no phases, no task board, no per-task status, and no plan-approval gate. The operator is blind to the run structure the desktop surfaces live." + }, + { + "feature": "Orchestration lead task actions (cancel/revert, respawn, mark-done, open worker chat)", + "status": "missing", + "details": "VERIFIED. No OrchestrationTaskAction equivalent, command, or keybinding exists in the TUI (commands.ts has no orchestration verbs). Desktop TaskCard exposes a lead-only menu wired to open-worker-chat | cancel(revert) | respawn | mark-done-manually.", + "tuiFiles": [ + "apps/ade-cli/src/tuiClient/app.tsx", + "apps/ade-cli/src/tuiClient/commands.ts" + ], + "gap": "TUI users cannot drive a run (reassign/cancel/respawn tasks, approve a plan) — they can only chat with the lead and hope it self-manages." + }, + { + "feature": "CTO console (Team / workers: hire, edit, detail, budgets, heartbeats)", + "status": "missing", + "details": "VERIFIED. commands.ts has no /cto, /team, /workers, or /hire command, and no component renders AgentIdentity/budget/heartbeat. The daemon DOES expose get_cto_state and the CTO operator tool surface, and the generic `/ade <domain.action>` escape hatch could technically reach those actions, but there is no purpose-built UI.", + "tuiFiles": [ + "apps/ade-cli/src/tuiClient/commands.ts", + "apps/ade-cli/src/tuiClient/adeApi.ts" + ], + "gap": "Entire CTO/Team surface absent. No worker roster, no budget/heartbeat visibility, no wake/pause/hire from the TUI (only the raw, undiscoverable /ade passthrough)." + }, + { + "feature": "Work board / work queue (Linear sync dashboard, run timeline)", + "status": "partial", + "details": "VERIFIED. linearCommands.ts maps /linear workflows|run|route|sync|ingress (plus list/pull/comment/assign/status) to a single tool request per invocation; results are dumped into a generic right-pane details view. The resolve verbs exist (run resolve & sync resolve: approve|reject|retry|resume|complete; sync queue; sync detail). Desktop LinearSyncPanel renders a live dashboard, run timeline, queue with inline resolve actions, and a live monitor. The TUI has the verbs but no board/timeline UI and no live event subscription.", + "tuiFiles": [ + "apps/ade-cli/src/tuiClient/linearCommands.ts", + "apps/ade-cli/src/tuiClient/commands.ts" + ], + "gap": "No persistent work-queue board, no run-status timeline, no inline approve/reject affordances — each query is a one-shot command whose output is static text with no live updates." + }, + { + "feature": "Multi-agent work grid (parallel agents at a glance)", + "status": "partial", + "details": "VERIFIED present. MultiChatGrid.tsx tiles up to 6 chat sessions with focus/remove and per-tile streaming state — a genuine multi-agent surface. But it tiles arbitrary user chats, not an orchestration run's worker fleet; tiles are not bound to orchestration task/agent identity, status pills, or phase context.", + "tuiFiles": [ + "apps/ade-cli/src/tuiClient/components/MultiChatGrid.tsx", + "apps/ade-cli/src/tuiClient/multiChatLayout.ts" + ], + "gap": "No orchestration-aware grid: cannot view a run's workers as a fleet with task/status/owner overlays." + } + ], + "bugs": [ + { + "title": "Background subagents are never reaped, so the footer spinner ticks forever on an idle chat that has a stuck background agent", + "severity": "low", + "description": "VERIFIED. spinTickActive (app.tsx:3243-3247) ORs in `liveAgentCount > 0`, where liveAgentCount counts subagentSnapshots with status 'running' (app.tsx:2890-2893). subagentSnapshotsFromEvents (chatSubagents.ts:283-298) only flips a still-'running' subagent to 'stopped' when its parent turn is terminal AND `snapshot.background !== true` — background agents are explicitly excluded from that reaping. So a background agent (or any subagent) whose terminal subagent_result never arrives keeps liveAgentCount > 0 indefinitely, leaving the footer spin animation ticking on an otherwise-idle chat. Scope correction vs. the draft: liveAgentCount is derived from the active session's `events`, so this is per-chat (the chat that owns the stuck agent), not a truly global spinner. This is the 'stuck spinner / phantom activity' class called out in the user's Claude-chat-UX feedback. Fix: either reap stale running subagents on parent-turn-terminal regardless of the background flag, or gate the liveAgentCount contribution on the active chat actually streaming.", + "file": "apps/ade-cli/src/tuiClient/app.tsx" + }, + { + "title": "Roster mouse hit-test reserves a detail line for selected rows whose only summary is generic, shifting click->row mapping for rows below", + "severity": "low", + "description": "VERIFIED and confirmed to be on the live mouse path. subagentPaneSelectableLineOffsets (chatSubagents.ts:346-351) adds an extra line for the selected snapshot when `row.snapshot.lastToolName || row.snapshot.summary` is truthy. But RightPane.tsx ChatInfoRoster (line 713) draws the detail line only when rosterRowDetail(...) is non-null, and rosterRowDetail (line 535-539) filters via isGenericSubagentSummary (line 526-533), which returns true for '', 'stopped', 'agent closed', and 'parent turn ended before ade received...'. So a selected row whose only summary is e.g. 'stopped' (very common for a reaped/closed agent) reserves a click line the renderer never draws, shifting click->row mapping for every row below it by one line. app.tsx (lines 7975, 8055, 9857) calls subagentIndexForPaneLine with rightSelectionIndex as the selectedIndex param, so a real mouse click can land on the wrong agent. Fix: align the offset-table extra-line rule with rosterRowDetail's generic-summary filter (reuse isGenericSubagentSummary).", + "file": "apps/desktop/src/shared/chatSubagents.ts" + }, + { + "title": "Roster selection clamp can transiently point at a stale index when the snapshot set shrinks", + "severity": "low", + "description": "PLAUSIBLE but lowest-confidence / cosmetic. rightSelectionIndex is clamped against the snapshot/row count and ChatInfoRoster re-derives its 5-row window from the selected index; when the roster shrinks (an agent reaped) between a manifest event and the clamp settling, the transient selected index can briefly highlight no row / the wrong row. ChatInfoRoster does locally clamp `selected` to totalSelectable-1 (line 647) and the inspectedSubagentId effect (app.tsx:2964-2968) self-heals the transcript view, which bounds the blast radius. Not a crash; a one-frame visual glitch at most.", + "file": "apps/ade-cli/src/tuiClient/app.tsx" + } + ], + "polishOpportunities": [ + { + "title": "Animate subagent status transitions and live duration ticks", + "description": "The roster shows a static formatElapsed(durationMs) and a status glyph; rows appear/disappear instantly. Claude-Code feel: live-tick the duration for running agents (spinTick infra already exists), flash the glyph on status change (running->done brief green flash, ->failed red flash), and slide new rows in.", + "impact": "medium" + }, + { + "title": "Distinct visual identity per agent kind", + "description": "VERIFIED gap: the roster glyph is purely status-driven (subagentAgentKind(status) -> agentStatusGlyph); background rows differ only by a cyan color tint and teammates/foreground subagents are otherwise identical. Give teammates a person glyph, background a moon/clock glyph, and subagents a spark glyph so the three sections sort at a glance without reading the header — matching the desktop iconography.", + "impact": "medium" + }, + { + "title": "Phase/plan strip for orchestrator chats", + "description": "When activeSession has orchestrationRunId, the chat-info pane could show a compact PHASE strip (Planning > Developing > Validating > Wrap-up, current highlighted) and a task tally ('4 done · 2 in progress · 1 failed'), reusing desktop STATUS_PILL/PHASE_LABEL semantics. Highest-leverage delight item: turns the orchestrator chat from blind to situationally-aware. Implementation note: the chat-info pane already has a ChatInfoPlanBlock rendering pattern (for the agent todo plan) to model this after — but it must read orchestration manifest data, not latestPlan(events).", + "impact": "high" + }, + { + "title": "Hover + click affordances on roster rows (mouse parity)", + "description": "Rows are keyboard-navigable (↑↓ focus · ↵ swap · esc -> main) and there is line->index mouse mapping, but the roster does NOT register with the hitTestRegistry hover path (that infra is used elsewhere, e.g. lane-details/MultiChatGrid). Add a hover tint via hitTestRegistry and a subtle '↵ view' affordance on the hovered row so mouse users discover drill-in. (Fixing the line-offset bug above is a prerequisite for trustworthy click targeting.)", + "impact": "medium" + }, + { + "title": "Plan-approval inline gate in the TUI", + "description": "Desktop surfaces a PlanReadyBar with an Implement button when planApprovalPending is set. The TUI's approval infra (latestPendingApproval) exists but is not specialized for orchestration plan approval. A dedicated, visually distinct plan-approval banner with Approve/Revise would make lead-driving runs feel intentional rather than buried in generic approval prompts.", + "impact": "high" + }, + { + "title": "Color-coded run header for orchestrator/lead sessions", + "description": "VERIFIED: the TUI ChatInfoHeader (RightPane.tsx:551-575) shows model glyph + lane + active/idle + context/token summary only — no role/run badge. For orchestrationRole lead/worker/validator sessions, badge the header with the role and run title so the operator always knows they are inside a run.", + "impact": "medium" + } + ], + "recommendations": [ + { + "title": "Render an orchestration plan/phase summary in the chat-info pane for runs", + "description": "When activeSession.orchestrationRunId is set, fetch the manifest (the daemon already buffers orchestrator events — category 'orchestrator'/'dag_mutation') and render a compact phase strip + task tally + per-task status using the desktop STATUS_PILL/PHASE_LABEL semantics from orchestrationTokens.ts. Model the layout on the existing ChatInfoPlanBlock pattern (do NOT reuse latestPlan, which is the agent todo plan). Closes the single biggest blindness.", + "effort": "L", + "priority": "P0" + }, + { + "title": "Fix the phantom-spinner and roster line-math defects", + "description": "Two small, self-contained correctness fixes in the one surface the TUI already ships: (1) reap stale running subagents on parent-turn-terminal regardless of the background flag (or gate spinTickActive's liveAgentCount contribution on the active chat actually streaming) so the footer spinner stops on idle chats; (2) align subagentPaneSelectableLineOffsets' extra-detail-line rule with RightPane.tsx rosterRowDetail/isGenericSubagentSummary so mouse click->row mapping stays accurate. Promoted ahead of new-surface work because they degrade the surface that already exists.", + "effort": "S", + "priority": "P0" + }, + { + "title": "Add lead task actions and plan approval to the TUI", + "description": "Wire OrchestrationTaskAction (open-worker-chat, cancel/revert, respawn, mark-done-manually) and the plan-approval gate to keyboard commands / a selectable task list once the plan summary exists. Reuse the existing approval plumbing (latestPendingApproval) for the plan-ready gate and the deeplink/open-session path for open-worker-chat. Without this the TUI lead can observe but not drive a run.", + "effort": "L", + "priority": "P1" + }, + { + "title": "Turn /linear sync + run into a live work-queue board pane", + "description": "linearCommands.ts already maps to getLinearSyncDashboard / listLinearSyncQueue / getLinearWorkflowRunDetail and the resolve verbs. Replace the one-shot 'dump result into details pane' behavior with a dedicated, auto-refreshing board pane showing runs, statuses, and inline approve/reject/retry affordances. Delivers the desktop LinearSyncPanel's value in the terminal.", + "effort": "L", + "priority": "P1" + }, + { + "title": "Add a minimal /cto worker roster + wake/pause read surface", + "description": "Surface the worker team via a new pane: list workers (name/role/status/budget chip/heartbeat) from get_cto_state and allow wake/pause. Full hire/edit can stay desktop-only initially. Note the raw `/ade <domain.action>` passthrough technically reaches these daemon actions today but is undiscoverable — a real roster pane is the parity fix.", + "effort": "XL", + "priority": "P2" + }, + { + "title": "Animate roster status transitions and differentiate agent-kind glyphs", + "description": "Add status-change flashes, live duration ticks for running agents, slide-in for new rows, hover highlight via hitTestRegistry (the roster does not yet register with it), and distinct glyphs per kind (subagent/teammate/background, today only color-tinted). Pure polish on the existing roster.", + "effort": "M", + "priority": "P2" + } + ], + "_key": "orchestration", + "_kind": "parity" +} +``` + +</details> + +<details><summary><b>Review & conflict resolution</b> (parity)</summary> + +```json +{ + "dimension": "Review & conflict resolution", + "summary": "Verified against the cited TUI files. The desktop has two substantial feature areas here: (1) a conflict-prediction/simulation/AI-resolution stack surfaced in Graph (RiskMatrix + ConflictPanel), Lanes (rebase banners), and PRs (RebaseTab, PrAiResolverPanel, ConflictFilePreview) backed by conflictService.ts and the ade.conflicts.* / ade.git.* IPC surface; and (2) an AI code-review feature (ReviewPage + ReviewFindingCard) with review runs, severity-classified findings, adjudication, and inline-comment publication via ade.review.*. The TUI has essentially none of this. Confirmed facts: the substring 'conflict' appears 0 times in the 10,327-line app.tsx (case-insensitive grep is empty); `/diff` (app.tsx:5985) calls diff.getChanges then renders a static file list (path + '+N -N', plus at most the first 8 lines of each file's diff body, capped at 20 files) with NO diff/syntax coloring, NO scrolling, NO mouse/keyboard interaction, NO per-file expand, and NO staged/unstaged split; `/pr review` and `/pr comments` (app.tsx:6122-6132) dump GitHub reviews/threads/comments into a read-only details pane; `/pull --rebase|--merge` (app.tsx:6429) fires git.pull and reports 'Pull complete' regardless of conflict outcome; `/reparent` (app.tsx:6042) runs lane.reparent (git rebase) with no conflict handling. None of conflicts.simulateMerge/getBatchAssessment/getRiskMatrix, conflicts.*Proposal, conflicts.scanRebaseNeeds/rebaseLane, git.getConflictState/rebaseContinue/rebaseAbort/mergeContinue/mergeAbort, the external-resolver actions, or any ade.review.* action is wired. They are reachable only via the generic `/ade <domain.action> [json]` escape hatch (app.tsx:6315, run_ade_action -> renderObject), which dumps raw JSON into a details pane with zero purpose-built or interactive UI. Net: read-only diff inspection plus PR-comment viewing; the TUI cannot review AI findings or resolve merge conflicts in any meaningful sense. One correction to the draft: the codebase DOES have a highlightCache.ts / highlightCode() that is used (format.ts:327) — but only for chat markdown fenced code blocks, never for the diff pane, so the diff truly has no coloring.", + "desktopCapabilities": [ + { + "feature": "One-shot merge simulation with conflict markers", + "description": "Graph edge-click and lane detail trigger conflicts.simulateMerge, returning outcome (clean/conflict/error), mergedFiles, per-file conflictMarkers, and diffStat. ConflictPanel shows a live 'Running merge simulation…' spinner then outcome + conflict/files-changed counts.", + "keyFiles": [ + "apps/desktop/src/renderer/components/graph/graphDialogs/ConflictPanel.tsx", + "apps/desktop/src/main/services/conflicts/conflictService.ts", + "docs/features/conflicts/simulation.md" + ] + }, + { + "feature": "AI conflict-resolution proposal: prepare -> request -> apply/undo", + "description": "ConflictPanel lets the user pick which lane to apply to, click Prepare AI (builds bounded context preview with token/char stats, warnings, collapsible exports + per-file marker/diff previews), Send to AI (conflicts.requestProposal), then review a Proposal (status, confidence %, explanation), choose apply mode (unstaged/staged/commit with commit message), Apply via git apply --3way, and Undo via git apply -R.", + "keyFiles": [ + "apps/desktop/src/renderer/components/graph/graphDialogs/ConflictPanel.tsx", + "docs/features/conflicts/simulation.md" + ] + }, + { + "feature": "Project-wide animated pairwise risk matrix", + "description": "RiskMatrix renders an N×N grid of lane pairs colored by riskLevel (none/low/medium/high) with overlapCount/hasConflict, animated cell-enter transitions, hover brightness, and tooltips listing overlapping files. Consumes conflicts.getBatchAssessment.", + "keyFiles": [ + "apps/desktop/src/renderer/components/graph/shared/RiskMatrix.tsx", + "apps/desktop/src/renderer/components/graph/shared/RiskTooltip.tsx" + ] + }, + { + "feature": "Conflict file preview (ours/theirs/diff hunk/raw markers)", + "description": "ConflictFilePreview renders a per-file card with language detection + icon, OURS (green) vs THEIRS (red) side-by-side excerpts, a colorized diff hunk (+green/-red/@@violet), and a RAW toggle to show literal conflict markers; falls back to 'both lanes modified this file' when detail is unavailable.", + "keyFiles": [ + "apps/desktop/src/renderer/components/prs/ConflictFilePreview.tsx" + ] + }, + { + "feature": "Rebase need detection + AI-assisted rebase / continue / abort", + "description": "RebaseTab surfaces behind-by counts, manual-attention statuses, 'Rebase with AI' launching an in-tab resolver, plain rebase, and abort (lanes.rebaseAbort). Lane/PR rebase banners prompt action. Backed by git.getConflictState/rebaseContinue/rebaseAbort/mergeContinue/mergeAbort and conflicts.scanRebaseNeeds/rebaseLane.", + "keyFiles": [ + "apps/desktop/src/renderer/components/prs/tabs/RebaseTab.tsx", + "apps/desktop/src/renderer/components/lanes/LaneRebaseBanner.tsx", + "apps/desktop/src/renderer/components/prs/PrRebaseBanner.tsx", + "docs/features/conflicts/README.md" + ] + }, + { + "feature": "External CLI resolver (Codex/Claude) with live terminal session", + "description": "PrAiResolverPanel embeds a full AgentChatPane resolver session (model/reasoning/permission picker, optional resolver instructions, running/completed/failed status chips) wired to prs.aiResolutionStart/onAiResolutionEvent; conflictService runs the CLI in the lane/integration worktree and commits via an explicit commitExternalResolverRun step.", + "keyFiles": [ + "apps/desktop/src/renderer/components/prs/shared/PrAiResolverPanel.tsx", + "apps/desktop/src/renderer/components/prs/shared/PrResolverLaunchControls.tsx", + "docs/features/conflicts/simulation.md" + ] + }, + { + "feature": "AI code-review runs with severity-classified findings, adjudication, and publication", + "description": "ReviewPage + ReviewFindingCard start review runs (startRun), stream events (onReviewEvent), classify findings by severity (critical/high/medium/low/info) with summary chips, support filtering, copy-to-clipboard, open-in-files/editor, feedback, suppressions, quality report, and publish findings as inline PR comments (local_only vs auto_publish).", + "keyFiles": [ + "apps/desktop/src/renderer/components/review/ReviewPage.tsx", + "apps/desktop/src/renderer/components/review/ReviewFindingCard.tsx", + "apps/desktop/src/renderer/components/review/reviewApi.ts", + "apps/desktop/src/renderer/components/review/reviewTypes.ts" + ] + } + ], + "tuiStatus": [ + { + "feature": "View lane diff", + "status": "partial", + "details": "`/diff` (app.tsx:5985) calls diff.getChanges and sets a 'diff'-kind right pane rendered by summarizeDiffChanges (format.ts:816). It shows path + '+N -N' counts plus at most the first 8 lines of each file's diff body (RightPane.tsx:1127 slice(0,8)), capped at 20 files (format.ts:830 slice(0,20)). The body is drawn with a single plain dim color (theme.color.t3) — NO per-line +/- diff coloring and NO syntax highlighting (highlightCode exists in highlightCache.ts and is wired in format.ts:327, but only for chat markdown fenced code blocks, never the diff pane). There is no diff-kind keyboard handler and no scroll-offset state, so no scrolling, no per-file expand/collapse, no hunk navigation, no mouse interaction, and no staged/unstaged split despite diff.getChanges returning {staged,unstaged} (typed that way at app.tsx:3842). It is a glance, not a reviewable diff.", + "gap": "No usable code review of a diff: cannot scroll, expand beyond 8 lines, color additions/deletions, or distinguish staged vs unstaged.", + "tuiFiles": [ + "apps/ade-cli/src/tuiClient/app.tsx", + "apps/ade-cli/src/tuiClient/format.ts", + "apps/ade-cli/src/tuiClient/components/RightPane.tsx" + ] + }, + { + "feature": "View PR reviews/comments", + "status": "partial", + "details": "`/pr review` (app.tsx:6127) aggregates pr.getReviews/getReviewThreads/getComments and dumps them through formatPrReview into a static details pane; `/pr comments` (app.tsx:6122) does the same via formatPrComments. Read-only text, no threading interaction, no reply, no resolve, no request-changes.", + "gap": "No way to act on review threads (reply, resolve, request changes) from the TUI; purely informational text.", + "tuiFiles": [ + "apps/ade-cli/src/tuiClient/app.tsx", + "apps/ade-cli/src/tuiClient/format.ts" + ] + }, + { + "feature": "Merge conflict simulation / prediction", + "status": "missing", + "details": "conflicts.simulateMerge, getBatchAssessment, getRiskMatrix, listOverlaps are never called by the TUI (reachable only via the raw `/ade conflicts.simulateMerge [json]` escape hatch at app.tsx:6315, which dumps JSON via renderObject). No risk matrix, no overlap chips, no pre-flight merge check.", + "gap": "Cannot predict or simulate conflicts before merging from the TUI.", + "tuiFiles": [ + "apps/ade-cli/src/tuiClient/app.tsx", + "apps/ade-cli/src/tuiClient/adeApi.ts", + "apps/ade-cli/src/tuiClient/connection.ts" + ] + }, + { + "feature": "Conflict marker rendering (ours/theirs/hunk)", + "status": "missing", + "details": "The substring 'conflict' appears 0 times in app.tsx (verified by case-insensitive grep). There is no equivalent of ConflictFilePreview: no ours/theirs side-by-side, no colorized hunk, no raw conflict-marker view.", + "gap": "A user resolving a conflict cannot even see the conflict regions in the TUI.", + "tuiFiles": [ + "apps/ade-cli/src/tuiClient/app.tsx", + "apps/ade-cli/src/tuiClient/components/RightPane.tsx" + ] + }, + { + "feature": "AI conflict-resolution proposal (prepare/request/apply/undo)", + "status": "missing", + "details": "None of conflicts.prepareProposal/requestProposal/applyProposal/undoProposal/listProposals are wired. No apply-mode selection, no confidence/explanation display, no undo.", + "gap": "No AI-assisted conflict resolution available in the TUI at all.", + "tuiFiles": [ + "apps/ade-cli/src/tuiClient/app.tsx" + ] + }, + { + "feature": "External CLI resolver session", + "status": "missing", + "details": "conflicts.runExternalResolver / prepareResolverSession / commitExternalResolverRun and prs.aiResolution* are not wired. The TUI has TerminalPane and AgentChatPane equivalents but never launches a resolver session.", + "gap": "No way to spawn or commit an external resolver run from the TUI.", + "tuiFiles": [ + "apps/ade-cli/src/tuiClient/app.tsx", + "apps/ade-cli/src/tuiClient/components/TerminalPane.tsx" + ] + }, + { + "feature": "Live rebase/merge conflict state (continue/abort)", + "status": "missing", + "details": "git.getConflictState/rebaseContinue/rebaseAbort/mergeContinue/mergeAbort are never called. `/pull --rebase|--merge` (app.tsx:6429) calls git.pull and reports `Pull complete: <stringified result>` regardless of outcome; a pull/rebase that leaves the worktree conflicted is neither detected nor surfaced. The Drawer only reads lane.status.rebaseInProgress (Drawer.tsx:58, 112-113) to paint a 'failed' color and a static 'rebase in progress' hint, with no actionable follow-up.", + "gap": "After a conflicting pull/rebase the user is stranded: no conflict surface, no continue/abort, only a status color.", + "tuiFiles": [ + "apps/ade-cli/src/tuiClient/app.tsx", + "apps/ade-cli/src/tuiClient/components/Drawer.tsx" + ] + }, + { + "feature": "Rebase-need detection / AI rebase", + "status": "missing", + "details": "conflicts.scanRebaseNeeds/getRebaseNeed/rebaseLane and lanes.rebaseAbort are not wired. `/reparent` (app.tsx:6042) runs lane.reparent (git rebase) but does not scan for or surface rebase needs across lanes, and offers no AI rebase.", + "gap": "No proactive 'this lane is behind / needs rebase' surface and no AI rebase.", + "tuiFiles": [ + "apps/ade-cli/src/tuiClient/app.tsx" + ] + }, + { + "feature": "AI code-review runs + findings", + "status": "missing", + "details": "The entire ade.review.* surface (startRun, listRuns, getRunDetail, recordFeedback, suppressions, publish) is absent. `/pr review` shows GitHub review comments, not ADE's own AI review findings. No severity classification, no finding cards, no adjudication, no publish-as-inline-comments.", + "gap": "The TUI cannot run or view ADE's AI code review or its findings.", + "tuiFiles": [ + "apps/ade-cli/src/tuiClient/app.tsx", + "apps/ade-cli/src/tuiClient/commands.ts" + ] + } + ], + "bugs": [ + { + "title": "/pull silently swallows conflict outcomes", + "severity": "high", + "description": "In app.tsx:6429-6430 the /pull handler does `const result = await conn.action(\"git\", \"pull\", {laneId, ...(mode ? {mode} : {})}); addNotice(`Pull complete: ${renderObject(result,4)...}`, \"success\")`. It always reports a green 'Pull complete' and stringifies the result, with no inspection of merge/rebase conflict state. A `/pull --rebase` or `/pull --merge` that produces conflicts leaves the worktree mid-conflict while showing success, with no continue/abort affordance and no conflict surface. Desktop instead routes conflicting pulls into the rebase/conflict surfaces via git.getConflictState. Verified: no getConflictState call exists anywhere in app.tsx.", + "file": "apps/ade-cli/src/tuiClient/app.tsx" + }, + { + "title": "/diff drops staged/unstaged distinction, has no diff coloring, and truncates silently", + "severity": "medium", + "description": "diff.getChanges returns {staged, unstaged} (typed that way at app.tsx:3842), but the /diff path (app.tsx:5990-5991) passes the raw result to summarizeDiffChanges (format.ts:816), which only reads record.files/record.changes and slices to 20 files (format.ts:830) with no truncation indicator. RightPane.tsx:1115-1133 renders each body with `.slice(0, 8)` lines (RightPane.tsx:1127) in a single plain dim color (theme.color.t3) — no +/- additions/deletions coloring at all. Staged vs unstaged is lost, large diffs are silently cut, body is uncolored, and the user has no signal that content was omitted.", + "file": "apps/ade-cli/src/tuiClient/format.ts" + }, + { + "title": "/reparent runs git rebase with no conflict handling", + "severity": "medium", + "description": "`/reparent` (app.tsx:6042-6047) calls lane.reparent (which runs git rebase per its own usage text) but, like /pull, has no path for a conflicted rebase result — no getConflictState check, no continue/abort. A failed reparent rebase leaves the lane in rebaseInProgress state that the Drawer only paints as 'failed' (Drawer.tsx:58) with a static 'rebase in progress' hint (Drawer.tsx:112-113) and no recovery action.", + "file": "apps/ade-cli/src/tuiClient/app.tsx" + } + ], + "polishOpportunities": [ + { + "title": "Colorized, scrollable diff with ours/theirs marker styling", + "description": "Render the diff pane like ConflictFilePreview: green additions, red deletions, violet @@ hunk headers, and for conflict regions show OURS (green left-border) vs THEIRS (red left-border) blocks with a RAW-markers toggle. Add j/k or arrow scrolling (the diff-kind pane currently has no scroll-offset state at all) and per-file expand/collapse instead of the fixed 8-line truncation, so the diff is actually reviewable in-terminal like Claude Code's diff view.", + "impact": "high" + }, + { + "title": "Conflict-aware /pull and /reparent feedback", + "description": "Detect conflict outcomes and replace the generic 'Pull complete' notice with an amber, actionable banner ('Rebase paused: 3 files conflicted — /rebase continue or /rebase abort'), turning today's silent green success into a guided recovery.", + "impact": "high" + }, + { + "title": "Inline conflict count + risk chips on lane rows", + "description": "The Drawer already computes lane status colors; add overlap/conflict chips (peerConflictCount, overlappingFileCount from conflicts.getLaneStatus) and a compact risk glyph so users see integration risk where lanes live, mirroring desktop lane badges — without a full RiskMatrix.", + "impact": "medium" + }, + { + "title": "Animated merge-simulation spinner and outcome reveal", + "description": "When a (future) /merge-check runs, show a brief spinner ('Running merge simulation…') then reveal the outcome line (clean=green check, conflict=amber warning with count) the way desktop ConflictPanel does, giving the TUI the same sense of liveness.", + "impact": "medium" + }, + { + "title": "Severity-colored review finding list", + "description": "If AI review findings are surfaced, render them as a navigable list with severity-tinted left borders (critical/high/medium/low/info) and a summary chip row, matching ReviewFindingCard, with keyboard navigation to jump between findings.", + "impact": "medium" + } + ], + "recommendations": [ + { + "title": "Surface live conflict state and continue/abort after pull/rebase/reparent", + "description": "Wire git.getConflictState after /pull --rebase|--merge (app.tsx:6429) and /reparent (app.tsx:6042); when conflicted, render a dedicated conflict pane (conflicted file list with ours/theirs/hunk like ConflictFilePreview) and add /rebase continue, /rebase abort, /merge continue, /merge abort commands mapping to git.rebaseContinue/rebaseAbort/mergeContinue/mergeAbort. Closes the most dangerous gap where /pull falsely reports success.", + "effort": "M", + "priority": "P0" + }, + { + "title": "Make /diff a real review surface", + "description": "Add per-line +/- diff coloring (the diff body is currently a single plain dim color), per-file expand beyond 8 lines, a truncation indicator (today the 20-file/8-line caps are silent), scrolling (add a diff-kind scroll-offset state and key handler — none exists), and a staged/unstaged split (diff.getChanges already returns both, typed at app.tsx:3842). This is the foundation for any in-TUI conflict review.", + "effort": "M", + "priority": "P0" + }, + { + "title": "Add a /merge-check (simulate) command", + "description": "Wire conflicts.simulateMerge (and optionally getLaneStatus/listOverlaps) to a /merge-check [lane] command that shows outcome, conflicting files, and overlap list — pre-flight risk without the full graph. Render overlap/conflict chips on Drawer lane rows from getLaneStatus.", + "effort": "M", + "priority": "P1" + }, + { + "title": "Bring AI conflict resolution into the TUI", + "description": "Implement prepare -> request -> apply/undo against conflicts.prepareProposal/requestProposal/applyProposal/undoProposal: show preview stats, proposal explanation + confidence, an apply-mode selector (unstaged/staged/commit), and an undo command. Reuse existing form/right-pane primitives.", + "effort": "L", + "priority": "P1" + }, + { + "title": "Wire the AI code-review surface", + "description": "Add /review and /review runs commands against ade.review.* (startRun/listRuns/getRunDetail/onEvent) with a severity-colored, navigable findings list (mirroring ReviewFindingCard), feedback, and open-in-files. Currently entirely absent from the TUI.", + "effort": "L", + "priority": "P2" + }, + { + "title": "Offer an external-resolver session launcher", + "description": "Add a command to launch conflicts.runExternalResolver / prs.aiResolution* into the existing TerminalPane/AgentChatPane, with an explicit commit step (commitExternalResolverRun), matching PrAiResolverPanel. Largest effort due to session lifecycle and worktree/cwd policy.", + "effort": "XL", + "priority": "P2" + } + ], + "_key": "review-conflicts", + "_kind": "parity" +} +``` + +</details> + +<details><summary><b>Remote runtime / sync / multi-device</b> (parity)</summary> + +```json +{ + "dimension": "Remote runtime / sync / multi-device", + "summary": "Verified against the cited TUI files: the report is substantially accurate. The TUI (`ade code`) has effectively zero parity with the desktop for this domain. Desktop is a full remote/sync controller (SSH targets, multi-route fallback, Tailscale/Bonjour discovery, route ranking, runtime bootstrap/upload, compatibility warnings, live connection snapshot, persisted last-remote binding, Sync Devices / phone-pairing / host-election surface). The TUI only knows two runtime modes, confirmed in tuiClient/types.ts line 25: `RuntimeMode = \\\"attached\\\" | \\\"embedded\\\"`. A grep of all of tuiClient/ for tailscale|bonjour|pairing|ssh|remoteRuntime|connectToBrain|brain_status|sync.setPin|RemoteRuntimeTarget returns nothing — there is no remote/SSH target, no target selection, no discovery, and no sync/device/pairing surface. The connection is established once in app.tsx's mount effect (lines 5025-5057) and the only re-attach logic is the embedded→attached probe (lines 5287-5319) that early-returns when `mode === \\\"attached\\\"`, so an attached connection that drops is never re-established. jsonRpcClient.ts does parse `tcp://host:port` sockets (lines 40-48) and cli.tsx accepts `--socket <path>` (line 37), so a user could point `ade code --socket tcp://host:port` at a remote daemon, but there is no SSH transport/auth/bootstrap and the usage banner never mentions tcp:// or remote use. Reconnection robustness is the single most impactful gap: on socket close JsonRpcClient sets closed=true and rejects pending requests with \\\"ADE RPC socket closed.\\\" (lines 31-34) but exposes no close callback; nothing in app.tsx or connection.ts observes the drop, so the connection object stays non-null, the full-screen error gate (which requires `error && !connection`, line 10082) never fires, and the user is left with a frozen UI plus a tiny red error line at line 10241 and no retry path (confirmed: no reconnect/retry keybind exists). TWO DRAFT CLAIMS CORRECTED: (1) the report says the runtime mode is \\\"NEVER rendered\\\" — that is too strong; `mode === \\\"connecting\\\"` does drive the spinner (spinTickActive, line 3245), the drawer `loading` prop (line 10133), and setupRowsForRuntime, so there is initial-connect feedback. The true gap is the absence of an attached-vs-embedded / local-vs-remote / link-health chip, not zero rendering. (2) The mid-turn streaming-reset bug is narrower than stated: the chat-SEND failure path already calls setStreaming(false) (app.tsx line 6952), so the hang only occurs when the socket drops mid-turn while already streaming and the failure flows through the poll/pty_exit path's refreshState().catch(setError), which never clears streaming.", + "tuiStatus": [ + { + "feature": "Remote/SSH target connection (add, select, connect to a remote ade serve over SSH)", + "status": "missing", + "details": "Confirmed. The TUI has no SSH transport, no target registry, and no remote concept. connection.ts only does local Unix-socket attach (connectAttachedSocket / spawnDaemon at machineSocketPath, connectToAde lines 649-685) or in-process embedded. RuntimeMode in types.ts line 25 is literally `\"attached\" | \"embedded\"`. The only seam is jsonRpcClient.ts parsing `tcp://` URLs (lines 40-48) plus the `--socket <path>` flag in cli.tsx (line 37); there is no SSH/auth/bootstrap layer behind it.", + "gap": "Cannot connect ade code to a remote runtime through any supported SSH path; no target management at all.", + "tuiFiles": [ + "apps/ade-cli/src/tuiClient/connection.ts", + "apps/ade-cli/src/tuiClient/types.ts", + "apps/ade-cli/src/tuiClient/cli.tsx", + "apps/ade-cli/src/tuiClient/jsonRpcClient.ts" + ] + }, + { + "feature": "Target selection (multiple saved machines, switching between local/remote)", + "status": "missing", + "details": "Confirmed. The TUI binds to exactly one runtime determined at process launch from explicitSocketPath ?? machineSocketPath (connection.ts connectToAde, lines 656-659). The mount effect (app.tsx lines 5025-5057) connects once. There is no in-session target picker or switch.", + "gap": "No way to enumerate, choose, or switch between machines/runtimes inside the running TUI.", + "tuiFiles": [ + "apps/ade-cli/src/tuiClient/connection.ts", + "apps/ade-cli/src/tuiClient/app.tsx" + ] + }, + { + "feature": "Connection / sync status surface (connecting / connected / error / reconnecting indicator)", + "status": "partial", + "details": "CORRECTED from the draft's overstatement. The TUI tracks `mode: RuntimeMode | \"connecting\"` (app.tsx line 2234) and DOES surface the connecting phase: it drives spinTickActive (line 3245), the drawer `loading` prop (line 10133), and setupRowsForRuntime (line 1229). What is genuinely missing is a steady-state, health-aware indicator: Header.tsx (lines 43-87) renders only project/lane/branch/chat with no attached-vs-embedded, local-vs-remote, or link-health chip, and statusline/index.ts has no connection telemetry at all (grep for connect/attached/embedded/runtime/socket in statusline/ returns nothing). Post-connect, the only failure feedback is the full-screen `ade-code failed to start` gate when `error && !connection` (line 10082) and a bare red error string at line 10241.", + "gap": "User cannot see whether they are attached vs embedded, local vs remote, or whether an established link is still healthy; no status chip in Header/Footer/statusline beyond the transient connecting spinner.", + "tuiFiles": [ + "apps/ade-cli/src/tuiClient/components/Header.tsx", + "apps/ade-cli/src/tuiClient/app.tsx", + "apps/ade-cli/src/tuiClient/statusline/index.ts" + ] + }, + { + "feature": "Reconnection robustness after a dropped connection", + "status": "glitchy", + "details": "Confirmed. JsonRpcClient.ts subscribes to socket `close`/`error` and rejects all pending requests with `ADE RPC socket closed.` (lines 30-34, rejectAll lines 180-185) but exposes NO close callback, and AdeCodeConnection in types.ts (lines 43-59) / connection.ts has no onClose seam. Grep across all of tuiClient/ confirms the only socket `close` handler is inside jsonRpcClient.ts itself (line 31), never propagated upward. So `connection`/`connectionRef.current` stay non-null after a drop. The embedded→attached probe (app.tsx lines 5287-5319) early-returns when `mode === \"attached\"`, so an attached connection that dies is never rebuilt. The polling effect (lines 5211-5220) keeps calling refreshState and each failure re-sets `error`, but because `connection` is still truthy the full-screen recovery gate (requires `!connection`, line 10082) never shows — the user gets a frozen UI plus a tiny red `ADE RPC socket closed.` line and no retry keybind (confirmed: no reconnect/retry binding exists in app.tsx).", + "gap": "No detection of connection loss, no automatic reconnect/backoff for the attached path, no user-visible reconnecting state, and the UI is left stale rather than recovering.", + "tuiFiles": [ + "apps/ade-cli/src/tuiClient/jsonRpcClient.ts", + "apps/ade-cli/src/tuiClient/app.tsx", + "apps/ade-cli/src/tuiClient/connection.ts" + ] + }, + { + "feature": "Runtime discovery (LAN Bonjour / Tailscale peers)", + "status": "missing", + "details": "Confirmed. Grep of all of tuiClient/ for tailscale|bonjour|discoverMachine|remoteRuntime returns nothing. No discovery code exists anywhere under tuiClient/.", + "gap": "TUI users cannot discover nearby ADE machines/runtimes.", + "tuiFiles": [ + "apps/ade-cli/src/tuiClient/" + ] + }, + { + "feature": "Compatibility warnings (version skew, channel mismatch, missing capabilities, home fallback)", + "status": "missing", + "details": "Confirmed. connection.ts initialize() reads runtimeInfo but only checks defaultRole/buildHash/projectRoot for staleness (attachedRuntimeMismatchReason); it never surfaces version/capability skew to the user. No compatibilityWarnings equivalent is rendered.", + "gap": "No surfacing of remote runtime version/capability compatibility info.", + "tuiFiles": [ + "apps/ade-cli/src/tuiClient/connection.ts" + ] + }, + { + "feature": "Sync / multi-device: phone pairing, device registry, host election (Settings > Sync equivalent)", + "status": "missing", + "details": "Confirmed. Grep of all of tuiClient/ for pairing|connectToBrain|brain_status|tailscale|sync.setPin|RemoteRuntimeTarget returns nothing. The TUI has no device list, no PIN management, no host/cluster status, no phone-pairing affordance.", + "gap": "Entire sync/multi-device control surface is absent from the TUI.", + "tuiFiles": [ + "apps/ade-cli/src/tuiClient/" + ] + }, + { + "feature": "Persist + auto-restore last remote binding across launches", + "status": "missing", + "details": "Confirmed. saveAdeCodeProjectState only stores lastChatByLane/lastLaneId (app.tsx lines 5065-5069); no remote target/binding is persisted, consistent with there being no remote concept to persist.", + "gap": "No remembered remote target, so no auto-reconnect to a previously used remote machine.", + "tuiFiles": [ + "apps/ade-cli/src/tuiClient/app.tsx", + "apps/ade-cli/src/tuiClient/project.ts" + ] + } + ], + "bugs": [ + { + "title": "Attached connection drop is never detected — UI freezes with no recovery", + "severity": "high", + "description": "Verified. app.tsx never subscribes to socket close. JsonRpcClient (jsonRpcClient.ts lines 30-34) sets closed=true and rejects pending requests on socket `close`/`error`, but offers no callback, and AdeCodeConnection (types.ts lines 43-59) exposes no onClose. So connectionRef.current stays non-null after a drop. The full-screen recovery gate at app.tsx line 10082 requires `error && !connection`, which never becomes true; the polling refreshState (lines 5211-5220) just keeps failing and re-setting `error` to `ADE RPC socket closed.`, rendered only as a tiny red line at line 10241. There is no retry keybind. Net effect: frozen, stale UI with a cryptic error and no path back.", + "file": "apps/ade-cli/src/tuiClient/app.tsx" + }, + { + "title": "Reconnect probe is unreachable once attached, so attached connections can never self-heal", + "severity": "high", + "description": "Verified. The only re-attach effect (app.tsx lines 5287-5319) starts with `if (!connection || mode === \"attached\" || forceEmbedded) return;`. It exists solely to upgrade an embedded fallback to a real socket. Once mode is \"attached\" (the normal local-daemon case), the effect is a no-op, so there is no periodic health check or reconnect attempt for the primary connection path. Combined with the missing close detection above, a transient socket loss (daemon restart, machine sleep) is unrecoverable without killing and restarting `ade code`.", + "file": "apps/ade-cli/src/tuiClient/app.tsx" + }, + { + "title": "Mid-turn streaming state is not reset when the socket drops during an active turn", + "severity": "medium", + "description": "Verified but narrowed from the draft. The chat-SEND failure path already clears streaming (app.tsx line 6952 calls setStreaming(false) in submitPrompt's catch), and the explicit interrupt paths (lines 7490, 8585, 8606) also clear it. The genuine hang is only when the socket drops MID-TURN while `streaming` is already true: chatRefreshPollActive (lines 5207-5209) keeps the 1s fast poll running, refreshState fails and only re-sets `error` (line 5216), and nothing transitions `streaming`/spinTickActive back to false. The UI stays in a perpetual streaming/animating state against a dead socket until the process is killed. There is no connection-loss -> streaming=false transition.", + "file": "apps/ade-cli/src/tuiClient/app.tsx" + }, + { + "title": "Remote-capable transport (tcp://) is reachable but undocumented and unauthenticated", + "severity": "low", + "description": "Verified. jsonRpcClient.ts (lines 40-45) parses `tcp://host:port` sockets, and cli.tsx accepts `--socket <path>` (line 37), so `ade code --socket tcp://remote:PORT` attempts a raw TCP RPC attach to a remote daemon. This bypasses the entire SSH transport/bootstrap/compatibility/auth layer the desktop uses. The cli.tsx usage banner (lines 48-50) lists `--socket <path>` generically but never mentions tcp:// or remote use, so the affordance is effectively hidden and would connect insecurely. Either wire it into a real remote flow or guard/hide raw tcp:// until it has auth.", + "file": "apps/ade-cli/src/tuiClient/jsonRpcClient.ts" + } + ], + "polishOpportunities": [ + { + "title": "Persistent runtime/connection chip in the Header", + "description": "Add a compact right-aligned chip in Header.tsx (which currently renders only project/lane/branch/chat, lines 43-87) showing runtime mode and link health, e.g. `● local` / `● remote user@host` / `◌ connecting` / `✕ disconnected`, color-coded (green/amber/red) via theme.color. The existing connecting spinner is transient; users have no ambient sense of attached-vs-embedded or whether an established link is healthy. Highest-leverage Claude-Code-grade touch for this domain.", + "impact": "high" + }, + { + "title": "Animated reconnect banner with backoff countdown", + "description": "When the socket drops, render a non-blocking top banner like `Reconnecting to runtime… (attempt 2, retrying in 3s)` with a subtle spinner — the codebase already has SpinTickProvider/spinTick.tsx for tick animation (used at app.tsx line 10104). On success, flash a brief `Reconnected` toast via the existing addNotice('…','success') path. Converts the current frozen-UI failure into a calm, legible recovery.", + "impact": "high" + }, + { + "title": "Graceful degraded-mode messaging plus a retry keybind", + "description": "Replace the bare red `ADE RPC socket closed.` line (app.tsx line 10241) with a styled human banner ('Lost connection to the ADE runtime. Press r to retry, or check that ade serve is running.') and add an actual reconnect keybind — confirmed none exists today (no retry/reconnect binding in runKeybindingAction). Keep input echo working so the terminal does not feel dead.", + "impact": "medium" + }, + { + "title": "Connection lifecycle in the activity/statusline area", + "description": "Surface connecting/connected/reconnecting transitions in statusline/index.ts (which today has no connection telemetry) so users with the statusline enabled get unobtrusive, persistent connection state rather than a one-shot error. Mirrors the desktop's live connection snapshot feel.", + "impact": "low" + } + ], + "recommendations": [ + { + "title": "Detect connection loss and drive a visible reconnect state machine", + "description": "Add an onClose callback to JsonRpcClient (fire on socket `close`/`error`, currently only used internally at jsonRpcClient.ts line 31) and thread it through AdeCodeConnection in types.ts/connection.ts. In app.tsx, on close: set a `connectionState` of 'reconnecting', null out the dead connection so the recovery UI can engage, stop the fast poll, and clear `streaming`. Then run a bounded exponential-backoff reconnect loop calling connectToAde with the same args (connectAttachedSocketWithRetry already exists as a building block). This fixes both high-severity bugs (undetected drop + unreachable reconnect probe) and the medium streaming-hang bug, and is the prerequisite for any status UI.", + "effort": "M", + "priority": "P0" + }, + { + "title": "Render a runtime/connection status indicator", + "description": "Introduce a small connection-state model (connecting | connected | reconnecting | error | embedded) and render it as a Header chip (and optionally statusline), color-coded via theme. The `connecting` phase already feeds spinTickActive/loading; extend that into a steady-state health indicator and wire it to the reconnect state machine from the P0 item. Closes the most-felt parity gap (no attached-vs-embedded / link-health visibility).", + "effort": "M", + "priority": "P0" + }, + { + "title": "Reset streaming/animation state on mid-turn connection failure", + "description": "When refreshState (or the poll/pty_exit path) fails due to connection loss while `streaming` is true, transition `streaming` to false and stop fast-poll/animation so the UI does not hang in a perpetual spinner. Note the chat-send catch (app.tsx line 6952) already does this; the gap is only the already-streaming poll path. Tie this into the onClose handler from the P0 reconnect work.", + "effort": "S", + "priority": "P1" + }, + { + "title": "Decide and document the remote-runtime story for ade code", + "description": "Either (a) explicitly document that `ade code` runs ON the remote machine over the user's own SSH session (the architecturally clean path, since the headless install supports it) and remove the half-exposed tcp:// affordance, OR (b) invest in a real in-TUI remote target picker reusing the desktop remoteRuntime target registry + SSH bootstrap. At minimum, document `--socket` behavior in the cli.tsx usage banner and guard/forbid raw tcp:// until it has auth. Avoids users discovering an insecure, unsupported remote path.", + "effort": "M", + "priority": "P1" + }, + { + "title": "Add a minimal sync/device read-only surface (deferred)", + "description": "If multi-device parity is in scope long-term, add a read-only `/sync` view that reads SyncRoleSnapshot/device registry from the daemon (host/cluster status, paired devices, pairing PIN set/clear). Full pairing-PIN management and host election can follow. Largest gap but lowest near-term priority for a shell-first agent client.", + "effort": "XL", + "priority": "P2" + } + ], + "_key": "remote-sync", + "_kind": "parity" +} +``` + +</details> + +<details><summary><b>Workspace graph</b> (parity)</summary> + +```json +{ + "dimension": "Workspace graph", + "summary": "The desktop \"Workspace graph\" is a full React Flow canvas (apps/desktop/src/renderer/components/graph/WorkspaceGraphPage.tsx, ~196KB, lazy-routed from App.tsx) that projects lane topology, pairwise conflict-risk overlays, PR overlays, sync/activity signals, merge-simulation, and AI integration proposals into a pannable/zoomable spatial view with 4 view modes, drag-reparent, collapse/expand, a filter bar, risk matrix, minimap, and animations. The TUI has NO graph view, no graph route, and no graph keybinding — the literal string \"graph\" in the TUI appears only in `sortLanesForStackGraph` (apps/ade-cli/src/tuiClient/laneTree.ts), a lane-sorting helper. The closest TUI analog is the Drawer's lane stack tree (components/Drawer.tsx), which renders topology with ├─/└─ ASCII prefixes plus per-lane status chip, PR pill, VM badge, and selected-only diff stats — a vertical sidebar list, not a graph. The TUI never calls the runtime conflicts domain anywhere (its conn.action calls are limited to git/diff/lane/pr/linear_issue_tracker/feedback/file/chat/terminal), so it surfaces ZERO conflict-risk, merge-simulation, or integration-proposal data. A 2D canvas is not a sensible terminal port, but the underlying intelligence — pairwise conflict risk and stack/topology awareness — is high-value AND verified-feasible to expose textually: the runtime registers `conflicts.getLaneStatus`, `conflicts.listOverlaps`, and `conflicts.getBatchAssessment` as TUI-reachable JSON-RPC actions (apps/ade-cli/src/services/sync/syncRemoteCommandService.ts L2543-2547, viewerAllowed) via the same `run_ade_action` path the TUI's conn.action already uses. IMPORTANT CORRECTION vs the draft: merge simulation and the AI proposal flow are NOT reachable from the TUI — `simulateMerge`/prepareProposal/requestProposal/applyProposal exist only as in-process desktop conflictService methods exposed over Electron IPC, with no `conflicts.simulateMerge` (or proposal) JSON-RPC registration, so a merge-preview surface requires a small backend addition first, not \"no backend work.\" Separately, the TUI already has terminal mouse infrastructure (parseTerminalMouseInput + click/drag/wheel decoding in app.tsx ~L1713-1761) and an ↵/click affordance in the Drawer, so click-to-select/act on lane rows is feasible today.", + "desktopCapabilities": [ + { + "feature": "Spatial lane topology canvas (React Flow)", + "description": "Primary-centric row layout where the primary lane sits at top and each descendant renders on row depth*Y_STEP below, with pan/zoom, node drag with per-view-mode position persistence, and collapse/expand of subtrees (collapsedLaneIds with collapsedChildCount on parent). Orphan lanes bucket into a bottom row via sentinel depth 10_000.", + "keyFiles": [ + "apps/desktop/src/renderer/components/graph/WorkspaceGraphPage.tsx", + "apps/desktop/src/renderer/components/graph/graphLayout.ts", + "docs/features/workspace-graph/README.md" + ] + }, + { + "feature": "Four view modes sharing one layout", + "description": "GraphViewMode = all (Overview, primary-centric tree, overlap web behind a toggle) | stack (Dependencies) | risk (Conflict Risk, risk edges always drawn between overlapping lanes) | activity (siblings sorted by activity score). lastViewMode persists; modes share the row layout so switching doesn't rearrange the canvas.", + "keyFiles": [ + "apps/desktop/src/renderer/components/graph/graphLayout.ts", + "apps/desktop/src/renderer/components/graph/graphTypes.ts" + ] + }, + { + "feature": "Conflict-risk overlay edges", + "description": "RiskEdge renders edges colored by pairwise risk level (none/low/medium/high) via riskStrokeColor, with reduced opacity for stale assessments (0.38), dim (0.16)/highlight (1.0) states, dashed styles, and PR-aware coloring via getPrEdgeColor. The Overview overlap web is toggled with a 'Show overlap web' button.", + "keyFiles": [ + "apps/desktop/src/renderer/components/graph/graphEdges/RiskEdge.tsx", + "apps/desktop/src/renderer/components/graph/graphHelpers.ts" + ] + }, + { + "feature": "Lane node with status/sync/PR/activity badges", + "description": "LaneNode renders sync badge, PR badge, role-label chip (Primary/Attached/Lane/Integration), custom lane.icon (House fallback for primary), an L{depth} stack badge or amber orphan hint, parent breadcrumb, activity-bucket-scaled dimensions/shadows, and animation classes (ade-node-failed-pulse, ade-node-merging, focus ring/glow, integration purple gradient).", + "keyFiles": [ + "apps/desktop/src/renderer/components/graph/graphNodes/LaneNode.tsx", + "apps/desktop/src/renderer/components/graph/graphTypes.ts" + ] + }, + { + "feature": "Merge-simulation conflict panel on edge click", + "description": "ConflictPanel opens on edge click showing lane A↔B header, merge outcome (clean/conflict/count via ade.conflicts.simulateMerge), overlapping files list, an 'Apply to' target chooser, and an AI proposal flow (prepare→request→apply) via ade.conflicts.prepareProposal/requestProposal/applyProposal. NOTE: simulateMerge and the proposal methods are desktop-in-process conflictService calls over Electron IPC; they are NOT registered as run_ade_action JSON-RPC actions, so they are not reachable from the TUI without new backend registration.", + "keyFiles": [ + "apps/desktop/src/renderer/components/graph/graphDialogs/ConflictPanel.tsx", + "apps/desktop/src/shared/ipc.ts", + "apps/desktop/src/main/services/conflicts/conflictService.ts" + ] + }, + { + "feature": "Project-wide risk matrix", + "description": "RiskMatrix renders a pairwise grid color-coded by risk (high red / medium amber / low emerald / none), with selected-cell ring, stale cells at reduced opacity with clock icon and 'Last computed N min ago' tooltip, increased/decreased flash animations between polls, entry animation, and a progress indicator from prediction-progress events.", + "keyFiles": [ + "apps/desktop/src/renderer/components/graph/shared/RiskMatrix.tsx", + "apps/desktop/src/renderer/components/graph/shared/RiskTooltip.tsx" + ] + }, + { + "feature": "Integration proposal nodes", + "description": "Virtual proposal nodes with purple styling, 'Fed By' source chips (integrationSources), and proposalOutcome (clean/conflict/blocked); detected via isIntegrationLaneFromMetadata.", + "keyFiles": [ + "apps/desktop/src/renderer/components/graph/graphNodes/ProposalNode.tsx", + "apps/desktop/src/renderer/components/graph/graphTypes.ts" + ] + }, + { + "feature": "Filter bar, minimap, dot background, drag-reparent, context menu", + "description": "Filter bar with status/lane-type/tag/search filters (Funnel badge shows active count); React Flow MiniMap; dot-grid Background; node drag-to-reparent persisting to session layout; right-click context menu (reparent, archive, delete, create child, view diff, open terminal). Staged hydration: canvas paints first, then risk/PR/sync overlays load with refresh coalescing and in-flight guards.", + "keyFiles": [ + "apps/desktop/src/renderer/components/graph/WorkspaceGraphPage.tsx", + "apps/desktop/src/renderer/components/graph/graphLayout.ts", + "docs/features/workspace-graph/data-sources.md" + ] + } + ], + "tuiStatus": [ + { + "feature": "Graph canvas / spatial topology view", + "status": "missing", + "details": "No graph view in the TUI: no route, no keybinding, no pan/zoom/viewport/minimap-of-lanes. grep for 'graph' across tuiClient surfaces only sortLanesForStackGraph and 'paragraph' tokens. GridMiniMap.tsx is a chat-grid pane indicator unrelated to the workspace graph. A literal 2D canvas is not terminal-appropriate, but the absence means none of the graph's information is reachable here.", + "gap": "Entire feature absent. The terminal can't host a pan/zoom canvas, but the topology + conflict intelligence has no textual surface at all.", + "tuiFiles": [ + "apps/ade-cli/src/tuiClient/app.tsx", + "apps/ade-cli/src/tuiClient/laneTree.ts", + "apps/ade-cli/src/tuiClient/components/GridMiniMap.tsx" + ] + }, + { + "feature": "Lane topology / stack tree", + "status": "partial", + "details": "The Drawer renders an ASCII stack tree via sortLanesForStackGraph + computeStackRowMeta + stackPrefix (├─ / └─ / │ glyphs), mirroring the desktop's parent-child stack edges. Each LaneCard (Drawer.tsx ~L360-510) shows name, status chip (run/wait/fail/miss), exec-provider glyph, branchRef, selected-only diff add/del, age, PR pill, VM badge. Closest analog and reasonably faithful for shallow topology. But orphan lanes are folded under primary (laneTree.ts L22-23/L91 reparent orphans to primaryId) rather than flagged, and depth>2 tree fidelity is explicitly approximated (laneTree.ts L62-76 comment) because rows don't track per-ancestor lastness.", + "gap": "No L{depth} badge, no orphan/'not stacked' distinction, approximate tree glyphs at depth>2, no parent breadcrumb chip, no integration-lane styling, no activity-bucket sizing/intensity.", + "tuiFiles": [ + "apps/ade-cli/src/tuiClient/components/Drawer.tsx", + "apps/ade-cli/src/tuiClient/laneTree.ts" + ] + }, + { + "feature": "Conflict-risk overlays (pairwise risk, overlap web, risk matrix)", + "status": "missing", + "details": "The TUI never calls the conflicts domain. conn.action usage is limited to git/diff/lane/pr/linear_issue_tracker/feedback/file/chat/terminal; no reference to conflicts/listOverlaps/getBatchAssessment anywhere in tuiClient. No risk level computed, shown, or color-coded; no overlapping-files awareness; no risk-matrix equivalent. Feasibility note: conflicts.getLaneStatus/listOverlaps/getBatchAssessment ARE registered as viewerAllowed run_ade_action JSON-RPC handlers (syncRemoteCommandService.ts L2543-2547), reachable via the existing conn.action client — so this is a wiring gap, not a backend gap.", + "gap": "Zero conflict-risk surface. TUI users cannot see which lanes overlap/conflict, at what risk level, or on which files — despite the data being one conn.action('conflicts',...) call away.", + "tuiFiles": [ + "apps/ade-cli/src/tuiClient/adeApi.ts", + "apps/ade-cli/src/tuiClient/app.tsx", + "apps/ade-cli/src/tuiClient/components/RightPane.tsx" + ] + }, + { + "feature": "Merge simulation + AI integration proposals", + "status": "missing", + "details": "No equivalent to ConflictPanel: no merge-simulation preview (clean/conflict/count), no overlapping-files-from-merge list, no proposal prepare/request/apply flow, no integration/proposal node concept. CORRECTION vs draft: simulateMerge/prepareProposal/requestProposal/applyProposal are NOT TUI-reachable — they are in-process desktop conflictService methods over Electron IPC with no run_ade_action registration in syncRemoteCommandService.ts. listOverlaps (which is registered) gives overlapping files but not a true clean/conflict merge verdict. So a merge-preview surface requires registering a new conflicts.simulateMerge runtime action first.", + "gap": "Merge preview and AI conflict-resolution proposals are entirely unavailable in the TUI, and unlike the risk read-surface they require new backend (runtime JSON-RPC) registration before any TUI work.", + "tuiFiles": [ + "apps/ade-cli/src/tuiClient/app.tsx", + "apps/ade-cli/src/tuiClient/components/RightPane.tsx", + "apps/ade-cli/src/services/sync/syncRemoteCommandService.ts" + ] + }, + { + "feature": "Reparent / restructure stack", + "status": "partial", + "details": "A /reparent slash command exists (commands.ts:48; handler app.tsx:6003) that lists candidate parent lanes textually (reparentTargetsForLane, app.tsx:793) and calls conn.action('lane','reparent',...). Functional but text-only — the user must know/type a parent lane id or name. Note: the Drawer already supports mouse click selection (↵/click affordance at Drawer.tsx:285) and the TUI has full terminal mouse decoding (app.tsx ~L1713-1761), so click-to-select/act on lane rows is feasible without a canvas.", + "gap": "No visual/interactive reparent and no new-tree confirmation; discoverability is low (invoke a slash command, read a list) vs the desktop's drag-in-graph or context menu. Mouse affordances exist in the TUI but are not yet applied to lane-restructure actions.", + "tuiFiles": [ + "apps/ade-cli/src/tuiClient/commands.ts", + "apps/ade-cli/src/tuiClient/app.tsx", + "apps/ade-cli/src/tuiClient/components/Drawer.tsx" + ] + }, + { + "feature": "Activity signals overlay", + "status": "missing", + "details": "The Drawer derives a coarse lane status (running/attention/idle/failed via deriveLaneStatus) from active sessions and rebase state, but there is no activity-score bucketing and no activity-sorted lane ordering like the desktop activity view mode. lastActivityAt is read only to pick a lane's session provider (Drawer.tsx L760-761), not to rank lanes.", + "gap": "No activity-ranked lane view; lanes can't be surfaced/sorted by how busy they are. Session lastActivityAt data is available, so a coarse bucket is feasible.", + "tuiFiles": [ + "apps/ade-cli/src/tuiClient/components/Drawer.tsx" + ] + }, + { + "feature": "PR overlay on topology", + "status": "partial", + "details": "The Drawer shows a PR pill (number + open state + checks passed/total) per lane via DrawerPrSummary/PrPill, and the lane-details RightPane carries PR state/checks. Covers basic PR badging. Lacks the graph's richer GraphPrOverlay (review status, change-request count, mergeable/conflicts, behindBaseBy, activityState/stale, PR-colored edges).", + "gap": "PR badge is minimal vs desktop overlay; no PR-aware edge coloring, mergeability, or review-status detail in the lane list.", + "tuiFiles": [ + "apps/ade-cli/src/tuiClient/components/Drawer.tsx", + "apps/ade-cli/src/tuiClient/components/RightPane.tsx" + ] + } + ], + "bugs": [ + { + "title": "TUI silently collapses orphan lanes under primary, hiding 'not stacked under primary' state", + "severity": "low", + "file": "apps/ade-cli/src/tuiClient/laneTree.ts", + "description": "sortLanesForStackGraph (L22-23, effectiveParentId) and computeStackRowMeta effectiveParent (L91) reassign any lane whose parentLaneId is missing/unresolved to the primary lane id, so orphan/detached lanes always render as children of primary with a normal ├─ glyph. The desktop graph distinguishes orphans (sentinel depth 10_000) with an amber 'Not stacked under the workspace primary' hint. In the TUI an orphaned lane is visually indistinguishable from a properly stacked child, which can mislead the user about real stack structure. Not a crash, but a correctness/fidelity defect in the only topology surface the TUI has. Verified in source." + }, + { + "title": "Stack tree glyphs are inaccurate beyond depth 2", + "severity": "low", + "file": "apps/ade-cli/src/tuiClient/laneTree.ts", + "description": "stackPrefix (L70-76) repeats '│ ' for every ancestor regardless of whether that ancestor is itself a last-sibling, because ade rows don't track per-ancestor lastness (acknowledged in the L62-76 comment). For deep stacks this draws continuation rails (│) under branches that have already ended, producing a subtly wrong tree. The desktop graph has no such ambiguity since it lays out real positions. Low severity (cosmetic) but a real inaccuracy in the parity-relevant topology rendering. Verified in source." + } + ], + "polishOpportunities": [ + { + "title": "Textual conflict-risk lane list (the graph's brain, terminal-native)", + "description": "Rather than a 2D canvas, render a sorted 'overlap/risk' list: for the active lane, list peer lanes by risk level with a colored severity glyph (● high red / ● medium amber / ● low green) and overlap file count, e.g. 'feature-x ● high 3 files'. Delivers the graph's most valuable signal (who conflicts with whom) in a scannable, terminal-native form. Pull from conflicts.getBatchAssessment (whole matrix) or conflicts.listOverlaps (per-lane) — both confirmed reachable via the TUI's existing conn.action run_ade_action client, no backend work.", + "impact": "high" + }, + { + "title": "Inline merge-preview before push/merge", + "description": "When the user runs a merge/PR action, show a one-line verdict in the RightPane ('Clean merge' green / '3 conflicting files' amber with an expandable file list), with a brief 'Simulating merge…' spinner then a crisp colored result, mirroring the desktop ConflictPanel outcome line without a dialog. IMPORTANT: simulateMerge is not currently a TUI-reachable runtime action, so this requires first registering a conflicts.simulateMerge JSON-RPC handler (or approximating from listOverlaps, which lists overlapping files but cannot assert a clean/conflict merge result).", + "impact": "high" + }, + { + "title": "Risk-aware coloring of the Drawer stack tree", + "description": "Tint each lane's ├─/└─ connector or name by its highest pairwise risk against siblings (subtle red/amber), so the existing tree doubles as the 'risk edge' overlay. Keep it behind a toggle (keybind) so the default tree stays calm, matching the desktop's 'overlap web hidden by default' contract. Depends on the risk read-surface (above).", + "impact": "medium" + }, + { + "title": "L{depth} badge + orphan flag + parent breadcrumb in LaneCard", + "description": "Add the desktop's L{depth} stack badge near the branch ref, an amber 'orphan' marker for lanes not under primary, and an 'on <parent>' breadcrumb line — small textual additions that bring the lane card toward graph-node parity and fix the orphan-confusion bug. Confirmed absent from LaneCard (Drawer.tsx ~L360-510).", + "impact": "medium" + }, + { + "title": "Click-to-select / act on lane rows (mouse affordance already exists)", + "description": "The TUI already decodes terminal mouse clicks (app.tsx ~L1713-1761) and the Drawer already advertises ↵/click for chat rows (Drawer.tsx:285). Extend that to lane rows so a click selects a lane (and, with a small menu, triggers reparent/diff/open) — a low-cost interactivity win that narrows the gap to the desktop's drag/context-menu without a canvas.", + "impact": "medium" + }, + { + "title": "Activity-bucket emphasis in the lane tree", + "description": "Compute a coarse activity bucket (running > awaiting-input > recent ops, using session lastActivityAt which is already available) to bold/brighten busy lanes and optionally offer an activity-sorted ordering toggle, echoing the desktop activity view mode — a delightful at-a-glance 'where's the action' cue.", + "impact": "low" + }, + { + "title": "Smooth status-transition flashes", + "description": "When a lane transitions (rebase fails, merge completes, risk increases), play a brief one-frame color flash/pulse on its Drawer row (the codebase already has spinTick/useSpinFrame infrastructure), echoing the desktop's ade-node-failed-pulse / RiskMatrix increased-decreased flashes for a polished, alive feel.", + "impact": "low" + } + ], + "recommendations": [ + { + "title": "Add a textual conflict-risk surface to the TUI (wire conflicts.getBatchAssessment + listOverlaps)", + "description": "The single biggest parity gap, and fully feasible with NO backend work: conflicts.getLaneStatus/listOverlaps/getBatchAssessment are already viewerAllowed run_ade_action handlers (syncRemoteCommandService.ts L2543-2547) reachable via the existing conn.action client. Add a RightPaneContent kind (e.g. 'conflict-risk') and a slash command (e.g. /conflicts or /risk) that calls conflicts.getBatchAssessment (project matrix) and/or conflicts.listOverlaps (active lane) and renders a risk-sorted peer list with severity glyphs and overlap file counts. Highest-value terminal-native projection of the graph.", + "effort": "M", + "priority": "P0" + }, + { + "title": "Fix orphan-lane handling and add L{depth}/orphan/parent-breadcrumb to the lane tree", + "description": "Stop reparenting orphan lanes onto primary in laneTree.ts (L22-23/L91); instead flag them ('not stacked under primary', amber) and render an L{depth} stack badge plus 'on <parent>' breadcrumb in LaneCard (Drawer.tsx ~L360-510). Corrects the topology-fidelity bug and closes most of the lane-node parity gap. Small, self-contained change in Drawer.tsx + laneTree.ts.", + "effort": "S", + "priority": "P1" + }, + { + "title": "Add inline merge-simulation preview (requires a small backend action first)", + "description": "Surface a colored clean/conflict verdict (with expandable conflicting-file list) in the RightPane around merge/PR-merge actions. CORRECTION vs draft: simulateMerge is NOT a TUI-reachable runtime action today (it lives only in the desktop in-process conflictService over Electron IPC), so this needs a new viewerAllowed conflicts.simulateMerge run_ade_action registration in syncRemoteCommandService.ts before the TUI work — it is not zero-backend. Bumped to M/P1 accordingly.", + "effort": "M", + "priority": "P1" + }, + { + "title": "Optional risk-tinted stack tree + activity emphasis + click-to-select (toggleable)", + "description": "Once risk data is wired (P0), tint Drawer tree connectors/names by highest pairwise risk behind a toggle, add an activity bucket (from session lastActivityAt) to bold busy lanes / offer activity-sorted ordering, and extend the existing terminal-mouse decoding (app.tsx ~L1713-1761; ↵/click already shown in Drawer) to lane rows for click-to-select. Mirrors the desktop's risk-edge overlay, activity view mode, and drag/context-menu interactivity while honoring the 'calm by default' contract.", + "effort": "M", + "priority": "P1" + }, + { + "title": "Defer/decline the full 2D graph canvas and AI integration-proposal flow", + "description": "A pannable React-Flow-style canvas is not terminal-appropriate; explicitly scope it OUT of TUI parity and track the textual projections above instead. The multi-step AI integration-proposal flow (prepare/request/apply with mode + commit message) is high-complexity, low-frequency AND not currently TUI-reachable over JSON-RPC; treat as a later, optional addition only after the read-oriented risk/merge surfaces land.", + "effort": "L", + "priority": "P2" + } + ], + "_key": "graph", + "_kind": "parity" +} +``` + +</details> + +<details><summary><b>Usage/cost, onboarding/help, Linear, deeplinks</b> (parity)</summary> + +```json +{ + "dimension": "Usage/cost, onboarding/help, Linear, deeplinks", + "summary": "Verified against the cited TUI files; the draft is substantially accurate with two overstatements corrected. The TUI covers Linear and deeplinks at a usable-but-mostly-text level and feedback at a basic level, but has a near-total gap on the desktop provider-quota Usage panel and zero first-run/onboarding. The desktop ships a multi-provider Usage/quota popup (HeaderUsageControl + UsageQuotaPanel) exposed via usage.getUsageSnapshot/forceRefresh (registry.ts:618) and an ade usage CLI; the TUI has ZERO references to getUsageSnapshot/forceRefresh in tuiClient/ (grep-confirmed) and /usage (commands.ts:42) is a chat-placement Claude-SDK passthrough. CORRECTION: the snapshot is reachable via the generic /ade usage.getUsageSnapshot escape hatch (app.tsx:6315) but only as renderObject raw JSON and undiscoverable, so effectively MISSING. The only first-class cost surface is FooterControls context-token bar plus last-turn dollar amount (formatTokenSummary, app.tsx:704-716). Onboarding: no first-run, no welcome/empty-state (grep finds only unrelated terminal-chrome filtering), static 11-line HelpPane (RightPane.tsx:773-787) that /help renders verbatim. Linear CORRECTION vs draft 'almost every result raw JSON': list uses routeRows and status/comments are hand-formatted, but run/route/sync/ingress, generic dispatch (app.tsx:6212), pull (6156) and comment (6170) dump renderObject raw JSON. Deeplinks: Ctrl+Y copies ade:// for focused lane/PR (deeplinkRow.ts, app.tsx:7776-7792), /open routes current context to desktop (app.tsx:6497), no inbound resolve, no branch/linear-issue copy. Feedback: working form-to-GitHub flow (app.tsx:6704-6743) but single-line truncated fields (RightPane.tsx:1210-1224), hardcoded modelId/reasoningEffort null (app.tsx:6712-6713), no multiline/AI-assist/label-edit/preview.", + "tuiStatus": [ + { + "feature": "Provider quota / usage panel (5h, weekly, monthly meters)", + "status": "missing", + "details": "Desktop UsageQuotaPanel renders per-provider meters for five_hour/weekly/monthly windows with threshold colors, reset-countdown sublabels, a 7-day sparkline, and an extra-usage USD spend card. The TUI never calls usage.getUsageSnapshot/forceRefresh (grep-confirmed: zero references in tuiClient/), has no quota command, and /usage (commands.ts:42) is a chat-placement Claude-SDK passthrough. CORRECTION vs draft: the snapshot is NOT fully unreachable - /ade usage.getUsageSnapshot works through the generic run_ade_action escape hatch (app.tsx:6315), but it returns renderObject raw JSON and is undiscoverable, so there is no real quota surface. Data and runtime action already exist (registry.ts:618, ade usage CLI), so this is purely a missing first-class TUI surface.", + "gap": "Add a /usage (or /quota) right-pane that calls conn.action('usage','getUsageSnapshot') and renders provider windows with percent bars (reuse the existing TokenBar primitive), reset countdowns, and the extra-usage spend line.", + "tuiFiles": [ + "apps/ade-cli/src/tuiClient/commands.ts", + "apps/ade-cli/src/tuiClient/app.tsx", + "apps/ade-cli/src/tuiClient/components/RightPane.tsx" + ] + }, + { + "feature": "Cost display (session/turn cost)", + "status": "partial", + "details": "FooterControls shows a context-token bar plus percent and a compact last-turn summary in/out (cache) dollar amount (formatTokenSummary at app.tsx:704-716, FooterControls.tsx:206-222). There is no cumulative session cost, no per-day/weekly spend, and no budget-cap awareness. costUsd is only the single most-recent turn and is recomputed each turn.", + "gap": "Surface cumulative session cost and optionally budget-cap status; today only the last turn's cost is shown and it is replaced each turn.", + "tuiFiles": [ + "apps/ade-cli/src/tuiClient/app.tsx", + "apps/ade-cli/src/tuiClient/components/FooterControls.tsx" + ] + }, + { + "feature": "First-run onboarding / setup wizard", + "status": "missing", + "details": "Desktop has a multi-step ProjectSetupPage wizard plus onboardingService status tracking. The TUI has no first-run detection, no welcome screen, and no empty-state guidance - grep for welcome/onboard/first-run/empty-state in tuiClient/ returns only unrelated terminal-chrome filtering (TerminalPane.tsx) and a hydration comment. cli.tsx renders AdeCodeApp with no onboarding gate.", + "gap": "No guided setup or even a one-time welcome banner for new users; the TUI assumes the runtime/providers are already configured.", + "tuiFiles": [ + "apps/ade-cli/src/tuiClient/cli.tsx", + "apps/ade-cli/src/tuiClient/app.tsx" + ] + }, + { + "feature": "Help / keymap discovery", + "status": "partial", + "details": "HelpPane (RightPane.tsx:773-787) is a static 11-line list of navigation/key hints (model row, ctrl-o/g/p/a, shift-tab, ctrl-c, slash and at menus). /help renders it verbatim (app.tsx:5699,5756,7501,9249). ade code --help (cli.tsx) prints a short usage block. There is no searchable command reference, no per-command help, no contextual help, and no glossary. Help does not enumerate the ~50 slash commands defined in commands.ts.", + "gap": "Help is a fixed cheat-sheet; it omits all slash commands and offers no search or contextual discovery like the desktop tours/glossary.", + "tuiFiles": [ + "apps/ade-cli/src/tuiClient/components/RightPane.tsx", + "apps/ade-cli/src/tuiClient/cli.tsx" + ] + }, + { + "feature": "Linear commands (route/run/sync/ingress/list/pull/comment/assign)", + "status": "partial", + "details": "linearCommands.ts builds a comprehensive tool-request surface (workflows/run/route/sync/ingress) and app.tsx:6135-6213 wires list/status/pull/comment/comments/assign plus generic /linear dispatch. CORRECTION vs draft ('almost every result is raw JSON'): /linear list uses routeRows (semi-formatted rows, app.tsx:6137), and status/comments have hand-formatters (formatLinearStatus/formatLinearIssueComments, rightPaneFormatters.ts:267-298). But run/route/sync/ingress plus generic dispatch (app.tsx:6212), pull context (6156), and comment result (6170) all use renderObject raw JSON. assign returns a hand-written sentence. Desktop has dedicated Linear UI (LinearIssueBrowser, LinearSyncPanel, resolve modals, badges). No issue browsing/filtering, no interactive resolve.", + "gap": "Run/route/sync/ingress/dispatch output is undigested JSON; no formatted sync/queue/ingress views, no interactive resolve flow, no issue browser equivalent.", + "tuiFiles": [ + "apps/ade-cli/src/tuiClient/linearCommands.ts", + "apps/ade-cli/src/tuiClient/app.tsx", + "apps/ade-cli/src/tuiClient/rightPaneFormatters.ts" + ] + }, + { + "feature": "Deeplink copy (Ctrl+Y) for focused lane/PR", + "status": "partial", + "details": "deeplinkRow.ts plus app.tsx:7448-7478 resolve the focused lane or PR row, and app.tsx:7776-7792 (app:copyAdeDeeplink) build the ade:// URL via buildDeeplinkForRow and copy it. buildDeeplinkForRow supports only lane and pr kinds (deeplinkRow.ts:58-83) and hardcodes form 'ade' - branch and linear-issue forms supported by the shared builder are not copyable. On copy failure it falls back to printing the URL as an info notice. The success notice is a generic 'ADE deeplink copied' that does not say which target type.", + "gap": "Copy supports lane/PR only with the ade:// (non-social) form hardcoded; no branch or linear-issue deeplink copy, no https ade.app/open social form, and the toast does not confirm the target type.", + "tuiFiles": [ + "apps/ade-cli/src/tuiClient/deeplinkRow.ts", + "apps/ade-cli/src/tuiClient/app.tsx" + ] + }, + { + "feature": "Deeplink open / inbound routing", + "status": "partial", + "details": "The ade CLI has ade open <url> (commands/deeplinks.ts) and the runtime registers an OS protocol handler; the TUI /open (app.tsx:6497-6524) routes the current context to desktop via navigateDesktop, falling back to spawning open and re-attaching. But within the interactive TUI there is no way to paste/open an arbitrary ade:// or https ade.app/open URL and navigate to that lane/PR/issue.", + "gap": "TUI can emit deeplinks and open the current context in desktop, but cannot consume an inbound deeplink to jump to a target inside the TUI session.", + "tuiFiles": [ + "apps/ade-cli/src/tuiClient/app.tsx", + "apps/ade-cli/src/commands/deeplinks.ts" + ] + }, + { + "feature": "Feedback submission to GitHub", + "status": "partial", + "details": "feedback.ts builds category-aware draft input and app.tsx:6704-6743 posts via feedback.prepareDraft plus submitPreparedDraft with success/error notices. But the form renderer (RightPane.tsx:1210-1224) collapses whitespace and endTruncates every field to roughly paneWidth-label-8 chars on a single line; values are edited through the shared prompt buffer with no multiline body editing. modelId/reasoningEffort are hardcoded null (app.tsx:6712-6713) so the AI-assist path the desktop uses is unreachable. No label editing, no draft preview.", + "gap": "Functional but minimal: single-line truncated fields, no multiline/textarea, no AI-assisted title/labels, no draft preview, no label editing.", + "tuiFiles": [ + "apps/ade-cli/src/tuiClient/feedback.ts", + "apps/ade-cli/src/tuiClient/app.tsx", + "apps/ade-cli/src/tuiClient/components/RightPane.tsx" + ] + }, + { + "feature": "Custom Claude status line (statusLine command)", + "status": "full", + "details": "statusline/index.ts reads user/project/local .claude settings, runs the configured statusLine command with a rich Claude-compatible payload (model, context_window, rate_limits, cost, effort, vim, etc.), and renders the output. /statusline shows config diagnostics. This is at parity with Claude Code's status line contract.", + "gap": "None - this is the one strongly polished surface in the domain.", + "tuiFiles": [ + "apps/ade-cli/src/tuiClient/statusline/index.ts", + "apps/ade-cli/src/tuiClient/app.tsx" + ] + } + ], + "bugs": [ + { + "title": "Feedback always sends modelId/reasoningEffort = null, disabling AI-assisted drafts", + "severity": "low", + "description": "app.tsx:6710-6714 calls feedback.prepareDraft with modelId null and reasoningEffort null hardcoded. The desktop FeedbackReporterModal passes the active model so the backend can suggest a title/labels. In the TUI the AI-assist path is silently unreachable even though the user has an active model selected - the draft is always deterministic with a summary-seeded title.", + "file": "apps/ade-cli/src/tuiClient/app.tsx" + }, + { + "title": "Generic feedback/form fields truncate user-entered values, hiding long input", + "severity": "medium", + "description": "The non-lane-delete form renderer (RightPane.tsx:1210-1224) collapses whitespace and endTruncates each field value to roughly paneWidth-label-8 chars for display. For feedback fields like Summary/Details/Expected/Actual the user cannot see the full text they typed - it lives in the shared prompt buffer but only a truncated single line is echoed - making multi-sentence bug reports effectively un-reviewable before submit. There is no multiline rendering for any feedback field.", + "file": "apps/ade-cli/src/tuiClient/components/RightPane.tsx" + }, + { + "title": "Linear run/route/sync/ingress output is raw JSON via renderObject, not human-readable", + "severity": "medium", + "description": "For /linear run/route/sync/ingress and the generic /linear dispatch, app.tsx:6212 sets the pane body to renderObject(result, 24) - an unformatted nested dump; /linear pull (6156) and /linear comment (6170) do the same. Only status and comments have dedicated formatters (rightPaneFormatters.ts) and list uses routeRows. A user resolving a Linear run or inspecting a sync queue sees deep JSON instead of a digestible summary, a parity and usability gap vs the desktop LinearSyncPanel/resolve modals.", + "file": "apps/ade-cli/src/tuiClient/app.tsx" + }, + { + "title": "/usage is a Claude-SDK passthrough; ADE quota data is only reachable as raw JSON via /ade", + "severity": "low", + "description": "commands.ts:42 registers /usage with description 'Show Claude usage through the active SDK session' and placement 'chat'. Given the desktop's prominent Usage popup shows provider QUOTA (5h/weekly/monthly), a user typing /usage reasonably expects the quota panel but gets the Claude SDK's own usage routed into chat. CORRECTION vs draft 'not reachable from any TUI command': usage.getUsageSnapshot IS reachable via the generic /ade usage.getUsageSnapshot escape hatch (app.tsx:6315), but it renders renderObject raw JSON and is undiscoverable, so there is no usable quota surface.", + "file": "apps/ade-cli/src/tuiClient/commands.ts" + } + ], + "polishOpportunities": [ + { + "title": "Animated quota meters with threshold colors in a /usage pane", + "description": "Mirror UsageQuotaPanel: render per-provider 5h/weekly/monthly bars using the existing TokenBar primitive (FooterControls.tsx:27-38), color-graded green/amber/red at 50/80/95 (tokenBarColor at FooterControls.tsx:20-24 already encodes exactly these thresholds), with 'resets in 2h 13m' sublabels and an extra-usage USD line. A subtle braille/block 7-day sparkline would make it feel alive and Claude-Code-grade. The primitive reuse makes this a low-risk implementation.", + "impact": "high" + }, + { + "title": "Persistent cumulative cost/usage chip instead of a vanishing last-turn number", + "description": "Today cost is only the last turn (dollar amount via formatTokenSummary) and is replaced next turn. A small persistent footer chip showing session cumulative cost and the nearest quota window with color grading would give continuous awareness, matching the desktop header usage chip's at-a-glance feel.", + "impact": "medium" + }, + { + "title": "First-run welcome plus empty-state guidance", + "description": "On a fresh launch with no chats, show a short welcome card (project, lane, slash for commands, ? for help, /model to pick a model) instead of a bare prompt. A one-time dismissible banner pointing to /help and /model would replicate the desktop onboarding's 'show the fastest path first' principle without a full wizard.", + "impact": "high" + }, + { + "title": "Searchable, complete help that enumerates slash commands", + "description": "Replace the static 11-line HelpPane (RightPane.tsx:773-787) with a scrollable, grouped list generated from BUILTIN_COMMANDS in commands.ts (already carries description, argumentHint, placement, and provider scoping), plus a quick filter. This turns help into real command discovery rather than a fixed cheat-sheet.", + "impact": "medium" + }, + { + "title": "Formatted Linear run/sync/queue views with status glyphs", + "description": "Add formatters (alongside formatLinearStatus) for run status, sync dashboard, and ingress events using status glyphs and relative timestamps and aligned columns instead of renderObject JSON at app.tsx:6212. Color the run state and surface the next actionable resolve options inline.", + "impact": "medium" + }, + { + "title": "Deeplink copy feedback plus target-aware toast and form choice", + "description": "On Ctrl+Y, replace the generic 'ADE deeplink copied' notice (app.tsx:7788) with a target-aware toast ('Lane link copied' / 'PR link copied') and optionally let a modifier pick the https ade.app/open social form. Extend buildDeeplinkForRow (deeplinkRow.ts:58) to branch and linear-issue targets so the TUI matches the full shared deeplink contract.", + "impact": "low" + } + ], + "recommendations": [ + { + "title": "Add a /usage (quota) right-pane backed by usage.getUsageSnapshot", + "description": "Wire a new quota command (or repurpose /usage) to call conn.action('usage','getUsageSnapshot') / 'forceRefresh' and render per-provider 5h/weekly/monthly meters with threshold colors, reset countdowns, and the extra-usage USD line - reusing the existing TokenBar primitive and tokenBarColor thresholds (FooterControls.tsx:20-38). This is the single biggest parity gap: the data and runtime action already exist (registry.ts:618, ade usage CLI) and are even reachable today as raw JSON via /ade, so only a formatted TUI surface is missing.", + "effort": "M", + "priority": "P0" + }, + { + "title": "Format Linear run/route/sync/ingress output instead of dumping raw JSON", + "description": "Add hand-formatters for run status, route results, sync dashboard/queue, and ingress events (alongside formatLinearStatus/formatLinearIssueComments) so /linear run/route/sync/ingress (app.tsx:6212), pull, and comment show digestible, color-coded summaries rather than renderObject(result,24).", + "effort": "M", + "priority": "P1" + }, + { + "title": "Make the feedback form usable: multiline fields, label editing, optional AI assist", + "description": "Render feedback field values without truncation (full multiline echo or an expanded edit view, RightPane.tsx:1210-1224), allow editing labels, and pass the active modelId/reasoningEffort to feedback.prepareDraft (currently hardcoded null at app.tsx:6712-6713) so AI-assisted title/labels work like the desktop modal.", + "effort": "M", + "priority": "P1" + }, + { + "title": "Add a first-run welcome / empty-state and a complete searchable /help", + "description": "Show a one-time welcome plus empty-state guidance on fresh launch and replace the static HelpPane (RightPane.tsx:773) with a command list generated from BUILTIN_COMMANDS in commands.ts (grouped, filterable, with argument hints). Closes the onboarding/help discovery gap without a full wizard port.", + "effort": "M", + "priority": "P1" + }, + { + "title": "Persistent cumulative session cost chip in the footer", + "description": "Track and display cumulative session cost (and optionally budget-cap status) as a small persistent footer chip rather than only the last-turn dollar figure (formatTokenSummary) that is replaced each turn.", + "effort": "S", + "priority": "P2" + }, + { + "title": "Extend deeplink copy to branch/linear-issue, add a target-aware toast and social-form option", + "description": "Support branch and linear-issue targets in buildDeeplinkForRow/resolveFocusedDeeplinkRow (deeplinkRow.ts:58), replace the generic copy notice with a target-aware toast (app.tsx:7788), and let the user copy the https ade.app/open social form. Optionally support opening a pasted inbound deeplink within the TUI session.", + "effort": "M", + "priority": "P2" + } + ], + "_key": "misc", + "_kind": "parity" +} +``` + +</details> + +<details><summary><b>Full mouse control</b> (cross)</summary> + +```json +{ + "dimension": "Full mouse control", + "summary": "CORRECTION: chat links ARE styled (LINK_COLOR + underline, ChatView 1115-1120) but not clickable, href dropped (format.ts 234-238); so partial not missing. All other findings verified in app.tsx. See tuiStatus and recommendations.", + "tuiStatus": [ + { + "feature": "Mouse enable, parsing, terminal passthrough", + "status": "full", + "details": "DECSET 1000/1002/1003/1006/1015 on mount; SGR/rxvt/X10 parsed; attached terminal gets raw stdin forwarded (4193-4215).", + "gap": "1003 floods stdin; legacy 1015 emitted.", + "tuiFiles": [ + "apps/ade-cli/src/tuiClient/app.tsx" + ] + }, + { + "feature": "Hit-test registry and click routing", + "status": "full", + "details": "rect+zIndex; one effect (~9360-9998) registers header, footer, drawer, model-picker, chat-info, lane-details, forms, approval and question targets.", + "gap": "Geometry hand-computed; drift breaks hot-zones; duplicate hardcoded footer toggle targets at columns-38/26/15 (9589-9606) can mis-click in non-overlapping cells.", + "tuiFiles": [ + "apps/ade-cli/src/tuiClient/app.tsx", + "apps/ade-cli/src/tuiClient/hitTestRegistry.ts" + ] + }, + { + "feature": "Hover affordances on registered targets", + "status": "missing", + "details": "hoverTest+setHoveredHitId per move (7862-7870, full root re-render, no throttle) but only MultiChatGrid reads hoveredId; footer/drawer/model-picker/right-pane/approval have no onHover.", + "gap": "All surfaces except grid tiles show no hover despite the per-move re-render.", + "tuiFiles": [ + "apps/ade-cli/src/tuiClient/app.tsx", + "apps/ade-cli/src/tuiClient/components/FooterControls.tsx" + ] + }, + { + "feature": "Wheel scrolling and scrollbars", + "status": "partial", + "details": "Wheel scrolls chat (8039-8044) only inCenterPane/inTranscriptRows; drawer/right/terminal ignore wheel; no scrollbar UI.", + "gap": "Only center transcript scrolls; no visible/draggable scroll position.", + "tuiFiles": [ + "apps/ade-cli/src/tuiClient/app.tsx", + "apps/ade-cli/src/tuiClient/components/RightPane.tsx" + ] + }, + { + "feature": "Clickable links in chat output", + "status": "partial", + "details": "CORRECTED: link runs tagged (137-143) and rendered LINK_COLOR+underline (1115-1120); but href dropped (234-238); no OSC-8, no hit target.", + "gap": "Styled but not clickable, destination hidden; draft wrongly called this missing.", + "tuiFiles": [ + "apps/ade-cli/src/tuiClient/format.ts", + "apps/ade-cli/src/tuiClient/components/ChatView.tsx" + ] + }, + { + "feature": "Drag-drop, pane resize, double/right-click", + "status": "partial", + "details": "Drawer chat-row drag-into-grid (7882-7905) and chat-text drag select (8003-8033) work; no divider drag; decodeMouseButton (1739-1754) handles left only.", + "gap": "Drag only under selected lane; no reorder, no mouse pane resize, single left-click only.", + "tuiFiles": [ + "apps/ade-cli/src/tuiClient/app.tsx" + ] + } + ], + "recommendations": [ + { + "title": "Make hover universal: consume hoveredId in Drawer, Footer, ModelPicker, RightPane", + "description": "Pass each clickable id down (or use useHitTestTarget) and highlight when hoveredId matches; the pipeline already re-renders on every move. Biggest feels-alive win.", + "effort": "L", + "priority": "P0" + }, + { + "title": "Throttle/coalesce mouse-move hover processing", + "description": "Drop 1003 for 1002, or skip move-to-hoverTest-to-setHoveredHitId when the cell/id is unchanged, avoiding a root re-render per motion.", + "effort": "S", + "priority": "P0" + }, + { + "title": "Route wheel scroll to the pane under the cursor", + "description": "Replace the inCenterPane-only wheel gate (8037-8044) with a dispatch scrolling the region under the cursor via per-pane offsets.", + "effort": "M", + "priority": "P1" + }, + { + "title": "De-duplicate and render-couple footer hit targets", + "description": "Remove the hardcoded columns-38/26/15 footer targets (9589-9606) and derive rects from the same layout FooterControls renders.", + "effort": "M", + "priority": "P1" + }, + { + "title": "Make chat links openable (href + OSC 8 + hit target)", + "description": "Carry the href on the link run, emit OSC-8, register a hit target calling openExternal. Lower-risk since styling exists.", + "effort": "M", + "priority": "P1" + } + ], + "_key": "mouse", + "_kind": "cross" +} +``` + +</details> + +<details><summary><b>Performance & smoothness</b> (cross)</summary> + +```json +{ + "dimension": "Performance & smoothness", + "summary": "VERIFIED. The TUI's render pipeline is structurally heavier than desktop's and does no event coalescing — the dominant cause of streaming jank. Every confirmed against source: (1) onChatEvent (app.tsx:5084) fires 2-3 synchronous setState calls per envelope with no batch/throttle/queue, whereas desktop coalesces into a 16ms setTimeout-debounced queue (scheduleQueuedEventFlush, chat/AgentChatPane.tsx:4671) and force-flushes only on done/user_message/status edges (4743-4753). (2) The full O(n) transcript aggregation (aggregateChatBlocks -> renderChatLines over ALL events) runs FOUR times per token from independently-memoized, non-shared sites: computeChatScrollMaxOffset (app.tsx:3249), renderChatVisibleSelectionRows (3272), renderChatSelectableRowTexts (3297), and ChatView's own useMemo (ChatView.tsx:1541 — the underlying functions call aggregateChatBlocks at ChatView.tsx:1311/1354/1394). (3) No row windowing: visibleRowsForBlocks (ChatView.tsx:1269) builds rows for the ENTIRE transcript via selectableRowsForBlocks then slices afterward (sliceRows:1042), so cost grows with transcript length not viewport; desktop virtualizes above 60 rows (VIRTUALIZATION_THRESHOLD, chat/AgentChatMessageList.tsx:3514) with measured heights + React.memo'd rows. (4) ChatView has ZERO React.memo (ChatRow is a plain function, line 1168) vs desktop's memoized MarkdownBlock (chat/AgentChatMessageList.tsx:860) and EventRow (3377). (5) SpinTickProvider wraps the whole tree (app.tsx:10104-10325) at 100ms (spinTick.tsx:14), and the rows useMemo depends on brailleFrame/spinFrame/dotPulse (ChatView.tsx:1568), so the whole transcript's rows rebuild every tick during streaming. (6) appendDedupedTuiEvent re-runs dedupeTuiEvents([...prev, envelope]) recomputing tuiEventDedupKey (with JSON.stringify of the event) for every event (eventDedup.ts:21,32,106) on the per-session path. The good news held up under inspection: no synchronous spawnSync/readFileSync on the hot render/input path; highlight cache is solid (LRU, MAX_ENTRIES=500/MAX_BYTES=50MB, fnv1a32 key, highlightCache.ts:101-272); hover hit-testing is change-gated; TerminalPane writes incrementally into the headless xterm via chunkIndexRef (TerminalPane.tsx:408); poll/heartbeat cadences are sane. NOTE: the desktop file paths in the original draft were wrong — they are under .../components/chat/ not .../components/; corrected throughout. The remaining wins are all coalescing streaming setState, sharing the aggregation, decoupling the spinner, and windowing the row build.", + "tuiStatus": [ + { + "feature": "Streaming event coalescing / setState throttling", + "status": "missing", + "details": "VERIFIED. connection.onChatEvent (app.tsx:5084-5161) fires synchronously per envelope: setEventsBySessionId (rebuilds+re-dedupes via appendDedupedTuiEvent, line 5098-5101), then setEvents (appendReservedTuiEvent, 5105-5116), plus conditional setSessionStreaming/setInterrupted/setRightPane. No debounce, no rAF/timer batching, no queue — one full React render per token during a fast stream. Desktop batches into a 16ms setTimeout queue (chat/AgentChatPane.tsx:4671) and force-flushes only on done/user_message/status edges (4743-4753).", + "gap": "Add a ref-backed per-frame (16-33ms) coalescing queue that accumulates incoming envelopes and applies them in a single batched setState; force-flush on lifecycle edges (status/done/user_message) so optimistic UI stays instant.", + "tuiFiles": [ + "apps/ade-cli/src/tuiClient/app.tsx", + "apps/ade-cli/src/tuiClient/eventDedup.ts" + ] + }, + { + "feature": "Single shared transcript aggregation per render", + "status": "glitchy", + "details": "VERIFIED. aggregateChatBlocks (aggregate.ts) runs renderChatLines over ALL events. It is invoked from four sites that don't share the result: computeChatScrollMaxOffset path (app.tsx:3249), renderChatVisibleSelectionRows (ChatView.tsx:1354, called app.tsx:3272), renderChatSelectableRowTexts (ChatView.tsx:1394, called app.tsx:3297), and ChatView's own useMemo (ChatView.tsx:1541). All are separately memoized but every memo depends on displayEvents, so the heavy O(n) walk runs ~4x whenever events identity changes (every token).", + "gap": "Compute blocks once (useMemo keyed on events/notices/activeSession/expandedLineIds) at the app level and thread AggregatedBlock[] into the scroll-max, selection-row, selectable-text, and ChatView paths instead of re-deriving from raw events four times.", + "tuiFiles": [ + "apps/ade-cli/src/tuiClient/app.tsx", + "apps/ade-cli/src/tuiClient/components/ChatView.tsx", + "apps/ade-cli/src/tuiClient/aggregate.ts" + ] + }, + { + "feature": "Row windowing / virtualization of the transcript", + "status": "missing", + "details": "VERIFIED. visibleRowsForBlocks (ChatView.tsx:1269) calls selectableRowsForBlocks (builds RenderedChatRow[] for the entire block list — text wrapping, markdown, highlight tokens) then sliceRows (1042) trims to the viewport AFTER. So per-render work scales with full transcript size, not the visible window. Desktop virtualizes above 60 rows (chat/AgentChatMessageList.tsx:3514, calculateVirtualWindow:3533, shouldVirtualize:4290) with measured heights.", + "gap": "Build rows only for blocks within (and slightly around) the visible scroll window, or memoize per-block row output keyed on block identity so unchanged historical blocks aren't re-wrapped every render.", + "tuiFiles": [ + "apps/ade-cli/src/tuiClient/components/ChatView.tsx" + ] + }, + { + "feature": "Memoized row components (avoid full subtree reconciliation)", + "status": "missing", + "details": "VERIFIED. grep -c React.memo in ChatView.tsx returns 0. ChatRow (ChatView.tsx:1168) is a plain function; rows.map(<ChatRow/>) (1593-1595) re-renders every time rows recomputes — every token and every 100ms spinner tick during streaming. Desktop wraps MarkdownBlock (chat/AgentChatMessageList.tsx:860) and EventRow (3377) in React.memo.", + "gap": "Wrap ChatRow (and InlineSpans) in React.memo with a stable key; ensure row objects are referentially stable for unchanged history so Ink can skip reconciling them.", + "tuiFiles": [ + "apps/ade-cli/src/tuiClient/components/ChatView.tsx" + ] + }, + { + "feature": "Spinner cadence isolation (avoid full-tree re-render per tick)", + "status": "glitchy", + "details": "VERIFIED. SpinTickProvider wraps essentially the entire app (app.tsx:10104-10325); its tick advances every 100ms while spinTickActive (spinTick.tsx:14). In ChatView the rows useMemo depends on brailleFrame/spinFrame/dotPulse (ChatView.tsx:1568) and rowsForBlocks threads spinFrame/brailleFrame into every block (ChatView.tsx:1010-1019), so the ENTIRE transcript's rows rebuild every 100ms during streaming even though only live blocks need the new frame. Header/Drawer/Footer also re-render via context propagation.", + "gap": "Feed the spinner frame only into the small live-indicator rows (active turn, running-tool glyph, compaction spinner) rather than the whole rowsForBlocks pass; or split the live tail into its own component so historical rows are frame-independent.", + "tuiFiles": [ + "apps/ade-cli/src/tuiClient/spinTick.tsx", + "apps/ade-cli/src/tuiClient/components/ChatView.tsx", + "apps/ade-cli/src/tuiClient/app.tsx" + ] + }, + { + "feature": "Per-event dedup cost", + "status": "partial", + "details": "VERIFIED. setEventsBySessionId (app.tsx:5098) calls appendDedupedTuiEvent -> dedupeTuiEvents([...prev, envelope]) (eventDedup.ts:106) which rebuilds a Set over the whole list, recomputing tuiEventDedupKey for each — and tuiEventDedupKey embeds JSON.stringify(envelope.event) (eventDedup.ts:21 in the correlation path, 32 in the fallback). The active-session path is smarter (appendReservedTuiEvent does incremental key reservation, 5103-5116) but the bySessionId path is O(n) per incoming event. NUANCE: JSON.stringify is inside tuiEventDedupKey itself and is appended even when correlation IDs exist (line 21), so the per-session win is reducing key computation from O(n) to O(1) calls; eliminating the stringify is a separate, additional change.", + "gap": "Use the incremental reserve/append approach (already present for the active session) for the bySessionId map too, with a per-session key set. Separately, consider dropping the JSON.stringify suffix from the correlation-key branch when stable IDs are present.", + "tuiFiles": [ + "apps/ade-cli/src/tuiClient/eventDedup.ts", + "apps/ade-cli/src/tuiClient/app.tsx" + ] + }, + { + "feature": "PTY live-data setState", + "status": "partial", + "details": "VERIFIED. pty_data subscription (app.tsx:5173-5178) does setTerminalLiveChunks(prev => [...(prev[sid]??[]), data].slice(-500)) per chunk — O(n) array rebuild + parent App re-render per chunk burst. TerminalPane itself writes incrementally into the headless xterm using chunkIndexRef so only new chunks are written (TerminalPane.tsx:406-411) — that part is good — but the upstream App re-render still re-flows chat aggregation each burst when both panes are mounted.", + "gap": "Buffer pty chunks in a ref and flush on a rAF/16ms timer like the desktop event queue; or push chunks straight into the headless terminal without storing the rolling array in React state.", + "tuiFiles": [ + "apps/ade-cli/src/tuiClient/app.tsx", + "apps/ade-cli/src/tuiClient/components/TerminalPane.tsx" + ] + }, + { + "feature": "Syntax highlight caching", + "status": "full", + "details": "VERIFIED. highlightCache.ts implements an LRU keyed on fnv1a32(code)+length+language (line 240) with MAX_ENTRIES=500 / MAX_BYTES=50MB caps (101-102) and LRU eviction (272). Correctly prevents re-highlighting on every render. No gap.", + "gap": "None.", + "tuiFiles": [ + "apps/ade-cli/src/tuiClient/highlightCache.ts" + ] + }, + { + "feature": "Synchronous I/O off the render/input path", + "status": "full", + "details": "VERIFIED (spot-checked). spawnSync/readFileSync in app.tsx are confined to lazy useState initializers and one-shot user-triggered command/$EDITOR handlers; none run inside render or useInput per keystroke. No sync I/O found in components/. heartbeat.ts does fs work on an unref'd timer only.", + "gap": "None on the hot path; the $EDITOR spawnSync intentionally blocks while editing, which is acceptable.", + "tuiFiles": [ + "apps/ade-cli/src/tuiClient/app.tsx", + "apps/ade-cli/src/tuiClient/heartbeat.ts" + ] + }, + { + "feature": "Background poll cadence", + "status": "full", + "details": "Reasonable and gated on active state per the draft (chat refresh 1s active / longer idle; lane diff, PR-by-lane, right-pane git refresh, terminal preview 500ms only when a terminal is focused; heartbeat unref'd). Not re-line-verified this pass but consistent with the codebase structure observed.", + "gap": "None, though the 500ms terminal preview poll could be event-driven instead of polled.", + "tuiFiles": [ + "apps/ade-cli/src/tuiClient/app.tsx", + "apps/ade-cli/src/tuiClient/heartbeat.ts" + ] + }, + { + "feature": "Hover hit-testing throughput", + "status": "full", + "details": "VERIFIED. createHitTestRegistry (hitTestRegistry.ts:49) — bestTarget is a linear scan (38-47), fine for current target counts. The move handler only updates hovered state when the resolved target id changes (per draft, app.tsx). MINOR: register does targets=[...filter, target] (line 53), an O(n) filter+spread whenever an inline target object identity changes (every render) — worth memoizing target objects under many targets, but not a current bottleneck.", + "gap": "Minor: memoize hit-test target objects to avoid O(n) re-register churn under many targets.", + "tuiFiles": [ + "apps/ade-cli/src/tuiClient/hitTestRegistry.ts", + "apps/ade-cli/src/tuiClient/app.tsx" + ] + } + ], + "bugs": [ + { + "title": "Transcript aggregation runs ~4x per render of the active chat", + "severity": "high", + "description": "VERIFIED. aggregateChatBlocks (which internally walks the entire event list) is invoked from four independently-memoized sites that don't share the result: the computeChatScrollMaxOffset path (app.tsx:3249), renderChatVisibleSelectionRows (ChatView.tsx:1354, used at app.tsx:3272), renderChatSelectableRowTexts (ChatView.tsx:1394, used at app.tsx:3297), and ChatView's own useMemo (ChatView.tsx:1541). All four memos depend on displayEvents (plus width/streaming) so they invalidate together on every streaming token, executing the heavy O(n) walk ~4x per token. Single largest avoidable CPU cost during streaming.", + "file": "apps/ade-cli/src/tuiClient/app.tsx:3249,3272,3297; apps/ade-cli/src/tuiClient/components/ChatView.tsx:1311,1354,1394,1541" + }, + { + "title": "No coalescing of streaming setState — one full render per token", + "severity": "high", + "description": "VERIFIED. connection.onChatEvent (app.tsx:5084) applies setEventsBySessionId + setEvents (+ sometimes setSessionStreaming/setInterrupted/setRightPane) synchronously per envelope with no debounce or queue, driving a React render per event — each re-running the ~4x aggregation above. Desktop solves this with scheduleQueuedEventFlush (chat/AgentChatPane.tsx:4671) flushing a 16ms-batched queue and force-flushing only on done/user_message/status edges (4743-4753). The TUI has no equivalent.", + "file": "apps/ade-cli/src/tuiClient/app.tsx:5084" + }, + { + "title": "Entire transcript row list rebuilt every spinner tick (100ms) while streaming", + "severity": "high", + "description": "VERIFIED. ChatView's rows useMemo (ChatView.tsx:1554) depends on brailleFrame/spinFrame/dotPulse (deps array line 1568), and rowsForBlocks (ChatView.tsx:1007-1022) threads spinFrame/brailleFrame into rows for ALL blocks (text wrapping, markdown, highlight token assembly) before sliceRows trims to the viewport. Since SpinTickProvider (app.tsx:10104) advances every 100ms (spinTick.tsx:14) during streaming, the full transcript's rows are rebuilt ~10x/second even though only the live indicator needs the new frame. Cost scales with transcript length, not viewport.", + "file": "apps/ade-cli/src/tuiClient/components/ChatView.tsx:1554,1568,1007; apps/ade-cli/src/tuiClient/spinTick.tsx:14" + }, + { + "title": "Per-session dedup re-serializes the whole event array per event", + "severity": "medium", + "description": "VERIFIED. setEventsBySessionId (app.tsx:5098) calls appendDedupedTuiEvent -> dedupeTuiEvents([...prev, envelope]) (eventDedup.ts:106), which recomputes tuiEventDedupKey for every event in the list; tuiEventDedupKey embeds JSON.stringify(envelope.event) (eventDedup.ts:21 correlation path, 32 fallback). This runs for every incoming event, making the bySessionId update O(n) with full JSON serialization, even though the active-session path already has an incremental reserve/append implementation (appendReservedTuiEvent) that computes the key once.", + "file": "apps/ade-cli/src/tuiClient/eventDedup.ts:21,32,106; apps/ade-cli/src/tuiClient/app.tsx:5098" + }, + { + "title": "No row windowing — render cost grows unbounded with transcript length", + "severity": "medium", + "description": "VERIFIED. Unlike the desktop (VIRTUALIZATION_THRESHOLD=60, chat/AgentChatMessageList.tsx:3514), the TUI always materializes the full row set (visibleRowsForBlocks -> selectableRowsForBlocks, ChatView.tsx:1269-1279) and slices last (sliceRows:1042). The 500-event dedup cap (eventDedup.ts:38) bounds the worst case, but a 500-event transcript with large tool outputs/code blocks still wraps and tokenizes every block on every render, producing visible lag and redraw cost on long sessions.", + "file": "apps/ade-cli/src/tuiClient/components/ChatView.tsx:1269,1042" + }, + { + "title": "ChatView has no React.memo, so Ink reconciles every row each render", + "severity": "medium", + "description": "VERIFIED. grep shows zero React.memo in ChatView.tsx. ChatRow (1168) and InlineSpans (1102) are plain components rendered in a .map (1593-1595); every render (every token, every spinner tick) reconciles the entire row subtree. Desktop memoizes MarkdownBlock (chat/AgentChatMessageList.tsx:860) and EventRow (3377). Without memo + stable row identity, unchanged history rows are needlessly diffed.", + "file": "apps/ade-cli/src/tuiClient/components/ChatView.tsx:1168,1593" + }, + { + "title": "PTY data drives O(n) array rebuild and a parent App re-render per chunk", + "severity": "low", + "description": "VERIFIED. The pty subscription (app.tsx:5174) does setTerminalLiveChunks(prev => [...(prev[sid]??[]), data].slice(-500)) for every pty_data chunk. Each chunk allocates a new <=500-element array and re-renders App, which re-runs chat aggregation when the chat pane is also mounted. TerminalPane already writes incrementally (chunkIndexRef, TerminalPane.tsx:408), so the array should be ref-buffered and flushed on a timer rather than stored in React state per chunk.", + "file": "apps/ade-cli/src/tuiClient/app.tsx:5174" + } + ], + "polishOpportunities": [ + { + "title": "Smooth, jank-free token streaming", + "description": "With a 16-33ms coalescing flush (matching desktop's 16ms setTimeout debounce) the transcript would advance in steady frames instead of stuttering on every token, eliminating visible 'typewriter judder' on fast streams. Highest-impact feel improvement and directly matches Claude Code's smooth stream.", + "impact": "high" + }, + { + "title": "Frame-locked spinner that doesn't re-flow the transcript", + "description": "Decouple the spinner cadence from row construction so the working indicator/tool glyph animates at 100ms without rebuilding historical rows. Reduces CPU and removes the subtle full-pane flicker that can occur when Ink redraws large regions every tick.", + "impact": "high" + }, + { + "title": "Stable scroll & no redraw storms on long transcripts", + "description": "Per-block row memoization (and optional windowing) keeps scroll and resize responsive on long sessions; today wrapping+highlighting the whole transcript on each render makes scrolling and window resize feel heavy. Desktop already feels light here thanks to virtualization.", + "impact": "medium" + }, + { + "title": "Instant lifecycle edges with batched bulk", + "description": "Mirror desktop's pattern of force-flushing on user_message/status/done (chat/AgentChatPane.tsx:4743-4753) while batching mid-turn tokens. The user's own message and turn-start appear instantly (no perceived input latency) while the bulk of streaming stays coalesced.", + "impact": "medium" + }, + { + "title": "Event-driven terminal preview instead of 500ms poll", + "description": "The focused terminal preview polls every 500ms. Since PTY data already arrives via runtime events (app.tsx:5168), driving the preview from the event stream (debounced) would make terminal output feel live rather than tick-updated, and drop a recurring timer.", + "impact": "low" + } + ], + "recommendations": [ + { + "title": "Add a per-frame streaming event coalescer in app.tsx", + "description": "Introduce a ref-backed pending-envelope queue plus a 16-33ms timer that applies setEvents/setEventsBySessionId in a single batched update; force-flush immediately when the envelope is status/done/user_message (and for visible grid tiles), mirroring desktop's scheduleQueuedEventFlush/flushQueuedEvents (chat/AgentChatPane.tsx:4611-4756). Collapses N renders/sec into ~30/sec — the single biggest smoothness win.", + "effort": "M", + "priority": "P0" + }, + { + "title": "Compute aggregateChatBlocks once and thread blocks through all consumers", + "description": "Hoist a single useMemo(() => aggregateChatBlocks({events,notices,activeSession,expandedLineIds})) at the app level and pass the resulting AggregatedBlock[] into the scroll-max calculation, selection rows, selectable text, and ChatView (refactor computeChatScrollMaxOffset / renderChatVisibleSelectionRows / renderChatSelectableRowTexts in ChatView.tsx:1311-1407 to accept blocks). Eliminates 3 of the ~4 redundant full-transcript walks per render.", + "effort": "M", + "priority": "P0" + }, + { + "title": "Decouple spinner frame from full row construction", + "description": "Stop feeding brailleFrame/spinFrame/dotPulse into the whole rowsForBlocks pass (ChatView.tsx:1554-1568, 1007-1022). Render historical/non-live rows frame-independently (memoized on blocks+width only) and render the live tail (active turn indicator, running-tool glyph, compaction spinner) in a separate small component that consumes the spin tick. Removes the ~10x/sec full-transcript rebuild during streaming.", + "effort": "M", + "priority": "P0" + }, + { + "title": "Memoize per-block row output and wrap ChatRow in React.memo", + "description": "Cache rowsForBlock results keyed on block identity (live blocks bypass the cache) so unchanged history isn't re-wrapped/re-highlighted each render, and wrap ChatRow/InlineSpans in React.memo with stable keys so Ink skips reconciling unchanged rows — matching desktop's memoized EventRow/MarkdownBlock (chat/AgentChatMessageList.tsx:860,3377).", + "effort": "M", + "priority": "P1" + }, + { + "title": "Make the per-session dedup incremental", + "description": "Replace appendDedupedTuiEvent in setEventsBySessionId (app.tsx:5098) with the incremental reserve/append path already used for the active session (appendReservedTuiEvent + a per-session key set), so the key is computed once per event instead of O(n) per event. Note: the active-session path still calls JSON.stringify inside tuiEventDedupKey, so this fixes the O(n) call count; trimming the stringify suffix from the correlation branch (eventDedup.ts:21) is an optional follow-up.", + "effort": "S", + "priority": "P1" + }, + { + "title": "Ref-buffer PTY chunks and flush on a timer", + "description": "Accumulate pty_data in a ref and apply setTerminalLiveChunks on a 16ms timer (or write directly into the headless xterm via TerminalPane's chunkIndexRef path and bump a renderTick), instead of allocating a sliced 500-element array and re-rendering App per chunk (app.tsx:5174).", + "effort": "S", + "priority": "P2" + }, + { + "title": "Introduce row windowing for very long transcripts", + "description": "Once the cheaper wins land, build rows only for blocks near the visible scroll window (analogous to desktop's calculateVirtualWindow above 60 rows, chat/AgentChatMessageList.tsx:3533) so render cost tracks viewport, not transcript length. Add a stress test (e.g. 500 events with large code blocks) to lock in the budget — confirmed none exists today (no perf/stress test among the TUI __tests__; only aggregate.test.ts and ChatView.test.tsx).", + "effort": "L", + "priority": "P2" + } + ], + "_key": "perf", + "_kind": "cross" +} +``` + +</details> + +<details><summary><b>Visual & motion delight</b> (cross)</summary> + +```json +{ + "dimension": "Visual & motion delight", + "summary": "The TUI has a genuinely strong static design foundation: a well-organized theme.ts with a violet brand family, provider brand glyphs/colors, an ANSI-shadow ADE wordmark, a bordered \"hero\" boot card, a thresholded TokenBar, and per-status glyphs. Three spinner families exist in spinTick.tsx (quarter-circle useSpinFrame, braille useBrailleSpin, dot-pulse useDotPulse) wired into live tool/file/runtime/plan groups, the compaction row's trailing braille frame, and the TerminalPane header. Against the desktop's motion arsenal (apps/desktop/src/renderer/index.css: ade-shimmer-text, ade-streaming-shimmer, ade-thinking-pulse, animate-spin/animate-ping with ade-fade-in completion glyph, rainbow orchestrator text, spring popovers, true colored Monaco diffs) it is comparatively flat, but the gap is narrower than a naive read suggests because of a key architectural fact verified here: CLAUDE TURNS DO NOT RENDER IN ChatView AT ALL. submitPrompt (app.tsx:7004) routes every Claude turn through startClaudeTerminalSession, and the render tree (app.tsx:10166) shows a TerminalPane that streams Claude Code's OWN live PTY — including Claude's native animated spinner / 'esc to interrupt' chrome — written tick-by-tick into a HeadlessTerminal. So the much-flagged 'showWorkingIndicator = provider !== claude' suppression (ChatView.tsx:1549, app.tsx:3248) is correct-by-design: it prevents a redundant ADE indicator on top of Claude's native one, and a streaming Claude turn is NOT frozen. The real, confirmed motion/visual gaps are elsewhere: no shimmer/gradient sweep anywhere; file changes show only '+N −M' (the raw diff is discarded by aggregate.ts, never colorized green/red); no fade-in completion glyph; the connect path spins the 100ms tick with no spinner consuming it (only a static dim 'Loading lanes…'); the TokenBar, BootHero, and AdeWordmark never animate; and Drawer/RightPane rows have no mouse-hover affordance (only MultiChatGrid does). The opportunity is to layer Claude-Code-grade motion onto the already-solid token system for the NON-Claude (codex/cursor/gemini) ChatView path and the shared chrome, without changing product behavior.", + "tuiStatus": [ + { + "feature": "Animated working/thinking spinner", + "status": "partial", + "details": "spinTick.tsx provides quarter-circle, braille, and dot-pulse frame hooks at a 100ms tick. ChatView wires them into tool-calls / files-changed / runtime / plan group glyphs and the '✦ model working…' dot-pulse line for the codex/cursor/gemini providers. For Claude the ADE indicator is intentionally suppressed (ChatView.tsx:1549, app.tsx:3248) because Claude does NOT render in ChatView — it renders in TerminalPane (app.tsx:10166) which streams Claude Code's own native animated spinner via the live PTY preview. So every provider DOES have a live activity affordance during streaming; they just come from different render paths. The remaining gap vs desktop is qualitative: ADE's own indicator is a single dot-pulse line with no shimmer, and the non-attached Claude preview relies on Claude's chrome rather than an ADE-branded one.", + "gap": "The ADE-rendered indicator (non-Claude) is a single flat dot-pulse line; no shimmer/gradient like desktop's ade-shimmer-text. No ADE-branded activity affordance layered over the non-attached Claude live preview (Claude's own spinner carries it).", + "tuiFiles": [ + "apps/ade-cli/src/tuiClient/spinTick.tsx", + "apps/ade-cli/src/tuiClient/components/ChatView.tsx", + "apps/ade-cli/src/tuiClient/components/TerminalPane.tsx", + "apps/ade-cli/src/tuiClient/app.tsx" + ] + }, + { + "feature": "Shimmer / gradient sweep on streaming text", + "status": "missing", + "details": "Desktop applies ade-shimmer-text to the 'Thinking'/'Working…' label and ade-streaming-shimmer over the active bubble. The TUI has no analog: the non-Claude streaming label is a flat violet '✦ model working…' (activeTurnRows, ChatView.tsx:901-911) and streaming assistant text renders with no shimmer or color movement. The spin tick is available to drive a per-character brightness/violet ramp but is not used for this.", + "gap": "No shimmer/gradient motion anywhere. A terminal-friendly equivalent (cycling bright cell / violet ramp across the label characters via the spin tick) is absent.", + "tuiFiles": [ + "apps/ade-cli/src/tuiClient/components/ChatView.tsx", + "apps/ade-cli/src/tuiClient/spinTick.tsx" + ] + }, + { + "feature": "Colored / animated diffs", + "status": "missing", + "details": "aggregate.ts diffStats() (line 129) parses +/- counts and then DISCARDS the raw diff — FileChangeEntry (aggregate.ts:25-33) retains only path/kind/status/additions/deletions, no diff text. filesChangedGroupRows (ChatView.tsx:731-794) renders only a badge + path + '+N −M' summary (statsColor is t4, or error only for deletes). The actual hunk lines are never shown with green/red coloring. theme.color.running(green)/error(red) exist but are unused for diff bodies. Desktop renders full Monaco AdeDiffViewer with emerald/red hunks.", + "gap": "No green-added / red-removed line coloring, no progressive reveal. Because the raw diff is dropped at aggregation, surfacing colored hunks requires threading the diff text through FileChangeEntry first — genuinely more than a render tweak.", + "tuiFiles": [ + "apps/ade-cli/src/tuiClient/components/ChatView.tsx", + "apps/ade-cli/src/tuiClient/aggregate.ts", + "apps/ade-cli/src/tuiClient/format.ts" + ] + }, + { + "feature": "Connecting / boot loading animation", + "status": "glitchy", + "details": "app.tsx:3243-3247 includes mode==='connecting' in spinTickActive so the 100ms SpinTickProvider interval runs during boot, but nothing in the connect render consumes a spin frame. The Drawer only receives loading={mode==='connecting' || lanes.length===0} (app.tsx:10133) and shows a STATIC dim 'Loading lanes…' (Drawer.tsx:217-219, 650-653) plus a 'LANES · …' header — its useSpinFrame() (ActiveChatSpin, Drawer.tsx:597-600) is for running chats, not the loading state. BootHero is fully static.", + "gap": "spinTick is active during connect but no spinner consumes it → wasted 100ms re-renders; the only connect feedback is static dim 'Loading lanes…' with no animated 'Connecting to runtime…' affordance.", + "tuiFiles": [ + "apps/ade-cli/src/tuiClient/app.tsx", + "apps/ade-cli/src/tuiClient/components/Drawer.tsx", + "apps/ade-cli/src/tuiClient/components/ChatView.tsx" + ] + }, + { + "feature": "Completion / celebration state", + "status": "missing", + "details": "On 'done' the non-Claude path flips streaming/interrupted flags and shows a plain '[done] status · in X · out Y · $cost' notice (format.ts). No green checkmark flourish, no summary chip, no momentary celebratory color. Desktop animates a fade-in CheckCircle (chatStatusVisuals.tsx). (Claude completion is shown by Claude's own native PTY chrome in TerminalPane, so this gap is specific to the ADE-rendered ChatView path.)", + "gap": "Turn completion in ChatView is visually indistinct from any other notice; no fade-in success/failure glyph, no positive-affect color moment.", + "tuiFiles": [ + "apps/ade-cli/src/tuiClient/app.tsx", + "apps/ade-cli/src/tuiClient/format.ts" + ] + }, + { + "feature": "Focus transitions / hover states", + "status": "partial", + "details": "Hit-test hover works ONLY for multi-chat tiles: MultiChatGrid uses useHitTestTarget (lines 90, 101) for the tile body and the × button. Slash/mention palette rows update selection on hover, and the prompt border tints violet on focus / plan-cyan in plan mode. BUT Drawer.tsx and RightPane.tsx contain ZERO useHitTestTarget/hover references — their lane/chat/list rows are keyboard-only with a static rail glyph — and all focus changes are instantaneous hard swaps (no eased focus ring).", + "gap": "No animated focus transition; Drawer and RightPane rows lack any mouse-hover affordance that the desktop has everywhere.", + "tuiFiles": [ + "apps/ade-cli/src/tuiClient/components/MultiChatGrid.tsx", + "apps/ade-cli/src/tuiClient/components/Drawer.tsx", + "apps/ade-cli/src/tuiClient/components/RightPane.tsx", + "apps/ade-cli/src/tuiClient/components/ChatView.tsx" + ] + }, + { + "feature": "Rich status line / context meter", + "status": "full", + "details": "FooterControls.tsx renders a strong status line: provider brand chip, model, fast badge, reasoning effort, permission, subagent count, a TokenBar (▓/░ with green→violet→amber→red thresholds via tokenBarColor at line 20), context %, token summary, and a grid mini-map. It also honors a user Claude statusLine command (statusline/index.ts → ModelStatus.tsx, up to 3 lines). This is close to desktop parity and arguably richer than Claude Code's footer.", + "gap": "Static only — the TokenBar (FooterControls.tsx:27-37) takes no tick and does not animate fill growth or pulse near the danger threshold; the bar is a plain ▓ block.", + "tuiFiles": [ + "apps/ade-cli/src/tuiClient/components/FooterControls.tsx", + "apps/ade-cli/src/tuiClient/components/ModelStatus.tsx", + "apps/ade-cli/src/tuiClient/statusline/index.ts" + ] + }, + { + "feature": "Brand / gradient moments (wordmark, hero)", + "status": "partial", + "details": "AdeWordmark.tsx renders an ANSI-shadow ADE figlet with a two-tone violet (accent face + violetDeep shadow) and BootHero (ChatView.tsx:244) wraps it in a double-bordered card with a dotted halo and subtitle — a real brand moment. BUT AdeWordmark.tsx imports no tick/animation hook (no useEffect/useState/useSpinFrame): it is fully static — no entrance reveal, no per-row gradient ramp, no animated halo. Desktop has gradient-accent tokens and rainbow text for brand surfaces.", + "gap": "Wordmark/hero never animate (no fade/row-by-row reveal on first paint, no per-row violet gradient). Halo dots are static.", + "tuiFiles": [ + "apps/ade-cli/src/tuiClient/components/AdeWordmark.tsx", + "apps/ade-cli/src/tuiClient/components/ChatView.tsx" + ] + }, + { + "feature": "Empty / loading states with personality", + "status": "partial", + "details": "ChatView shows the rich BootHero when truly empty with a worktree, and a flat 'No transcript yet.' in tile mode. RightPane and Drawer have many terse dim empties ('No changed files.', 'No plan yet.', 'no subagents yet', 'No chats open.', static 'Loading lanes…'). Functional but flat — no thoughtful copy, no spinner while loading, no inviting illustration like the boot hero.", + "gap": "Secondary empty/loading states are bare dim text with no motion or warmth; loading diffs/plans show no spinner (desktop uses animate-pulse 'Loading diff...').", + "tuiFiles": [ + "apps/ade-cli/src/tuiClient/components/RightPane.tsx", + "apps/ade-cli/src/tuiClient/components/MultiChatGrid.tsx", + "apps/ade-cli/src/tuiClient/components/Drawer.tsx", + "apps/ade-cli/src/tuiClient/components/ChatView.tsx" + ] + }, + { + "feature": "Compaction / progress shimmer", + "status": "partial", + "details": "compactionRows (ChatView.tsx:831) shows '⟳ compacting context · trigger ⠋' with the live braille frame — a nice touch, confirmed. But it is a single trailing braille glyph, not a progress shimmer/bar. No progress shimmer or determinate progress exists for long-running tools or file writes; group headers show a spinner + 'working…' but no proportional progress.", + "gap": "No progress shimmer or determinate progress for long operations; only a trailing spinner glyph.", + "tuiFiles": [ + "apps/ade-cli/src/tuiClient/components/ChatView.tsx" + ] + } + ], + "bugs": [ + { + "title": "File-change diffs never render colored hunk lines (raw diff discarded at aggregation)", + "severity": "medium", + "description": "aggregate.ts diffStats() (line 129) parses +/- counts but FileChangeEntry (aggregate.ts:25-33) keeps only additions/deletions, not the diff text. filesChangedGroupRows (ChatView.tsx:731-794) therefore renders only a badge + path + '+N −M' summary; the hunk lines are never shown with per-line green/red coloring. theme.color.running(green)/error(red) exist but are unused for diff bodies, unlike the desktop's emerald/red Monaco diff. Note: fixing this requires first threading the raw diff through FileChangeEntry, not just a render change.", + "file": "apps/ade-cli/src/tuiClient/aggregate.ts:25" + }, + { + "title": "spinTick interval runs during 'connecting' but no spinner consumes it", + "severity": "medium", + "description": "app.tsx:3243-3247 includes mode==='connecting' in spinTickActive, so the 100ms SpinTickProvider interval re-renders during boot, but nothing in the connect render reads a spin frame — Drawer gets only a boolean loading prop (app.tsx:10133) and shows a static dim 'Loading lanes…' (Drawer.tsx:217-219, 650-653) while BootHero is static. Result: wasted 100ms re-renders with no animated connecting payoff. (Lower-bound impact: there IS static text feedback, so it is not a fully blank screen.)", + "file": "apps/ade-cli/src/tuiClient/app.tsx:3243" + }, + { + "title": "InlineSpans code-run branch overrides color and drops dim/bold/italic flags", + "severity": "low", + "description": "In InlineSpans (ChatView.tsx:1116-1120) code runs render `color={run.color ?? theme.color.codeInline}` and pass none of dimColor/bold/italic (unlike the default branch at line 1123 which honors run.dim/run.bold/run.italic). Code-span runs from format.ts never set run.color, so all inline code is forced to violet codeInline even inside dim/quoted contexts, and dim/bold/italic intent is lost on the code branch. Minor visual inconsistency vs surrounding text.", + "file": "apps/ade-cli/src/tuiClient/components/ChatView.tsx:1116" + }, + { + "title": "[CORRECTION — NOT A BUG] Claude working indicator suppression is correct-by-design", + "severity": "low", + "description": "An earlier draft flagged 'showWorkingIndicator = provider !== claude' (ChatView.tsx:1549, app.tsx:3248) as a high-severity frozen-Claude bug. Verified false: submitPrompt routes every Claude turn through startClaudeTerminalSession (app.tsx:7004-7019), and the render tree (app.tsx:10166) renders a TerminalPane that streams Claude Code's OWN live PTY (HeadlessTerminal fed by liveChunks, TerminalPane.tsx:403-422) — including Claude's native animated spinner and 'esc to interrupt' chrome, plus an animated spinFrame in the pane header. A streaming Claude turn is NOT frozen; the suppression merely avoids a duplicate ADE indicator on top of Claude's native one. Listed here only to retract the prior claim — no action needed.", + "file": "apps/ade-cli/src/tuiClient/app.tsx:7004" + } + ], + "polishOpportunities": [ + { + "title": "Shimmer the 'model working…' label (non-Claude path) using the existing spin tick", + "description": "Replace the flat violet '✦ model working…' (activeTurnRows, ChatView.tsx:901-911) with a terminal shimmer: split the label into per-character runs and ramp brightness/violet across them driven by the spinTick context (a moving bright cell over t3→violet→t1). Closest terminal analog to desktop's ade-shimmer-text and the single highest-impact 'alive' moment for codex/cursor/gemini turns. Reuses theme.color.violet/accent/t3 — no new tokens. Note: this does NOT apply to Claude turns, which render via TerminalPane with Claude's own chrome.", + "impact": "high" + }, + { + "title": "Colorize diff hunk lines green/red with a left gutter", + "description": "Thread the raw diff text through FileChangeEntry (aggregate.ts) then emit actual diff lines in filesChangedGroupRows with theme.color.running for '+' lines and theme.color.error for '-' lines, dimmed context lines, and a subtle '▎' gutter — matching the desktop's emerald/red diff. Even capped at N lines per file this turns the flattest area of the TUI into a recognizable, Claude-Code-grade diff. Higher effort than a pure render change because the diff is currently discarded at aggregation.", + "impact": "high" + }, + { + "title": "Fade-in success/failure glyph on turn completion (ChatView path)", + "description": "On 'done'/status completed in the ADE-rendered path, render a brief emphasized green '✓ done' (red '✗ failed' on error) using a 1-2 tick brightness ramp before settling to the muted summary, mirroring desktop's ade-fade-in CheckCircle. Gives a satisfying close to non-Claude turns.", + "impact": "medium" + }, + { + "title": "Animate the TokenBar fill and pulse near the danger threshold", + "description": "The TokenBar (FooterControls.tsx:27-37) is static and takes no tick. Ease the filled-cell count toward target across a few ticks and blink/pulse the last filled cell when percent>=95 so context exhaustion is felt, not just shown. Reuses tokenBarColor thresholds.", + "impact": "medium" + }, + { + "title": "Show an animated 'Connecting to runtime…' affordance (and stop the idle tick)", + "description": "Either drop mode==='connecting' from spinTickActive, or actually consume a braille frame in the connect render (Drawer's 'Loading lanes…' / BootHero) so boot shows live progress instead of static dim text plus wasted 100ms re-renders.", + "impact": "medium" + }, + { + "title": "Hover tinting on Drawer and RightPane rows", + "description": "Register useHitTestTarget for Drawer lane/chat rows and RightPane list rows so mouse hover tints the row (t2→t1 + rail brighten) the way MultiChatGrid tiles already do (MultiChatGrid.tsx:90,101). Brings the TUI's mouse affordance up to the desktop's pervasive hover feedback.", + "impact": "medium" + }, + { + "title": "Boot hero entrance reveal + gradient wordmark", + "description": "On first paint of BootHero, reveal AdeWordmark rows top-to-bottom over a few ticks and apply a per-row violet gradient (accent→violetDeep) instead of the current two-tone. AdeWordmark.tsx currently has no animation hook at all. Memorable first-run brand moment without steady-state cost.", + "impact": "medium" + }, + { + "title": "Progress shimmer for long-running tool/runtime groups", + "description": "For live tool-calls / runtime groups that run a while, add a thin animated shimmer bar (cycling bright cell across a fixed-width '─' rule) beneath the group header, distinct from the per-entry spinner, to convey ongoing work — analogous to ade-streaming-shimmer.", + "impact": "low" + }, + { + "title": "Warmer, motion-aware secondary empty/loading states", + "description": "Replace bare dim 'No plan yet.' / 'No changed files.' / 'No chats open.' / static 'Loading lanes…' with short inviting copy and a small braille spinner while loading (desktop uses animate-pulse 'Loading diff...'). Avoid generic phrasing per the design-feedback memory.", + "impact": "low" + } + ], + "recommendations": [ + { + "title": "Add a terminal shimmer to the working label via the spin tick (non-Claude ChatView path)", + "description": "Implement a useShimmerFrame helper in spinTick.tsx (a moving highlight index) and apply a per-character brightness/violet ramp to activeTurnRows (ChatView.tsx:901) and group-header 'working…' text for codex/cursor/gemini turns. Reuses existing tick + theme tokens; the marquee 'Claude Code feel' moment for the ADE-rendered path. Highest-leverage liveness win now that the Claude path is confirmed to already animate via TerminalPane.", + "effort": "M", + "priority": "P0" + }, + { + "title": "Render colored diff hunk lines for file changes", + "description": "Thread the per-file diff text through FileChangeEntry/aggregate.ts into filesChangedGroupRows and emit green(+)/red(−)/dim-context lines (capped) with a gutter, using theme.color.running/error. Turns the flattest TUI surface into a real diff comparable to the desktop's emerald/red viewer. L (not S) because the raw diff is currently discarded at aggregation and must first be retained.", + "effort": "L", + "priority": "P1" + }, + { + "title": "Fade-in success/failure glyph on turn completion (ChatView path)", + "description": "In the 'done'/status-completed path for ADE-rendered (non-Claude) turns, render a brief emphasized green '✓ done'/red '✗ failed' line that ramps brightness over 1-2 ticks before settling. Mirrors desktop ChatStatusGlyph ade-fade-in; gives each turn a satisfying close.", + "effort": "S", + "priority": "P1" + }, + { + "title": "Stop the idle connect-time tick and show a connecting spinner", + "description": "Either drop mode==='connecting' from spinTickActive (app.tsx:3243) or actually render a braille 'Connecting to runtime…' affordance in BootHero/Drawer (currently static 'Loading lanes…'). Eliminates pointless 100ms re-renders and gives animated boot feedback.", + "effort": "S", + "priority": "P1" + }, + { + "title": "Animate the TokenBar and pulse at the danger threshold", + "description": "Ease the filled-cell count toward target and blink the last cell when percent>=95 in FooterControls.tsx (TokenBar at line 27). Makes context pressure felt; low risk, reuses tokenBarColor.", + "effort": "S", + "priority": "P2" + }, + { + "title": "Add mouse-hover tinting to Drawer and RightPane rows", + "description": "Register useHitTestTarget (as MultiChatGrid already does at lines 90/101) for Drawer and RightPane rows so hover tints them, matching the desktop's pervasive hover affordance. These files currently have zero hit-test references.", + "effort": "M", + "priority": "P2" + }, + { + "title": "Boot hero entrance reveal + per-row gradient wordmark", + "description": "Row-by-row reveal of AdeWordmark on first paint and an accent→violetDeep gradient across rows in AdeWordmark.tsx (which currently has no animation hook). Memorable first-run brand moment with no steady-state cost.", + "effort": "M", + "priority": "P2" + }, + { + "title": "Fix InlineSpans code-run branch to honor color/dim/bold/italic", + "description": "In ChatView.tsx:1116-1120, stop forcing codeInline color when run.color is unset is fine, but also pass dimColor/bold/italic like the default branch (line 1123) so inline code inside dim/quoted contexts isn't visually inconsistent. Trivial correctness/polish fix.", + "effort": "S", + "priority": "P2" + } + ], + "_key": "visual", + "_kind": "cross" +} +``` + +</details>