From 2f37d055d98c9688a7218814fc06cd114102137f Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Mon, 15 Jun 2026 12:17:37 -0400 Subject: [PATCH 01/10] commit from ade code --- apps/ade-cli/src/cli.ts | 9 + .../src/tuiClient/__tests__/ChatView.test.tsx | 170 ++- .../__tests__/MultiChatGrid.test.tsx | 93 +- .../tuiClient/__tests__/TerminalPane.test.tsx | 79 +- .../src/tuiClient/__tests__/appInput.test.ts | 71 ++ .../tuiClient/__tests__/appPolling.test.tsx | 2 + .../src/tuiClient/__tests__/cli.test.tsx | 28 + .../tuiClient/__tests__/connection.test.ts | 46 + .../src/tuiClient/__tests__/feedback.test.ts | 2 + .../src/tuiClient/__tests__/project.test.ts | 45 + .../__tests__/remoteLauncher.test.ts | 52 + apps/ade-cli/src/tuiClient/app.tsx | 464 +++++++- apps/ade-cli/src/tuiClient/cli.tsx | 15 + .../src/tuiClient/components/ChatView.tsx | 239 +++-- .../tuiClient/components/FooterControls.tsx | 9 + .../ModelPicker/ModelPickerPane.tsx | 51 +- .../ModelPicker/modelPickerGeometry.test.ts | 40 + .../ModelPicker/modelPickerGeometry.ts | 121 ++- .../tuiClient/components/MultiChatGrid.tsx | 178 +++- .../src/tuiClient/components/TerminalPane.tsx | 120 ++- apps/ade-cli/src/tuiClient/connection.ts | 15 +- apps/ade-cli/src/tuiClient/format.ts | 9 +- apps/ade-cli/src/tuiClient/project.ts | 49 +- apps/ade-cli/src/tuiClient/remoteLauncher.ts | 997 ++++++++++++++++++ apps/ade-cli/src/tuiClient/types.ts | 7 + 25 files changed, 2667 insertions(+), 244 deletions(-) create mode 100644 apps/ade-cli/src/tuiClient/__tests__/remoteLauncher.test.ts create mode 100644 apps/ade-cli/src/tuiClient/remoteLauncher.ts diff --git a/apps/ade-cli/src/cli.ts b/apps/ade-cli/src/cli.ts index 1b694c7a2..a60bf6d70 100644 --- a/apps/ade-cli/src/cli.ts +++ b/apps/ade-cli/src/cli.ts @@ -79,6 +79,10 @@ import type { AdeRuntime } from "./bootstrap"; import { reseedBundledAdeSkillsForCli } from "./bootstrap"; import { EncryptedFileCredentialStore } from "./services/credentials/credentialStore"; import { DEFAULT_SYNC_HOST_PORT } from "./services/sync/syncProtocol"; +import { + runAdeCodeRemote, + takeAdeCodeRemoteArgs, +} from "./tuiClient/remoteLauncher"; type JsonObject = Record; @@ -10591,6 +10595,11 @@ async function runAdeCode( ): Promise<{ output: string; exitCode: number }> { const modulePath = resolveAdeCodeModulePath(); const { runAdeCodeCli } = await import(pathToFileURL(modulePath).href); + const remoteArgs = takeAdeCodeRemoteArgs(rest); + if (remoteArgs) { + const exitCode = await runAdeCodeRemote(remoteArgs, runAdeCodeCli); + return { output: "", exitCode }; + } const exitCode = await runAdeCodeCli(buildAdeCodeArgs(rest, options)); return { output: "", exitCode }; } diff --git a/apps/ade-cli/src/tuiClient/__tests__/ChatView.test.tsx b/apps/ade-cli/src/tuiClient/__tests__/ChatView.test.tsx index 17e08c1b9..4ae897f2e 100644 --- a/apps/ade-cli/src/tuiClient/__tests__/ChatView.test.tsx +++ b/apps/ade-cli/src/tuiClient/__tests__/ChatView.test.tsx @@ -6,8 +6,12 @@ import { computeChatScrollMaxOffset, renderChatSelectableRowTexts, renderChatTranscriptPlainText, + renderChatVisibleSelectionRows, selectedTextFromChatRows, + workGroupExpandKey, } from "../components/ChatView"; +import { aggregateChatBlocks } from "../aggregate"; +import { chatEventLineId } from "../format"; import { buildSubagentTranscriptEvents } from "../subagentPane"; import { parseAssistantMarkdown, @@ -34,16 +38,34 @@ function stripAnsi(value: string): string { return value.replace(/\[[0-9;]*m/g, ""); } +// Tool-call / file-change groups collapse to a single header row by default. +// Tests that assert per-entry rendering (every call/file, glyphs, durations, +// badges) pass `expanded: true` to open every work group. +function expandAllWorkGroups( + events: AgentChatEventEnvelope[], + activeSession: AgentChatSessionSummary | null, +): Set { + const blocks = aggregateChatBlocks({ events, notices: [], activeSession }); + const ids = new Set(); + for (const block of blocks) { + if (block.kind === "tool-calls-group" || block.kind === "files-changed-group") { + ids.add(workGroupExpandKey(block.id)); + } + } + return ids; +} + function renderEvents( events: AgentChatEventEnvelope[], - options: { maxRows?: number; scrollOffsetRows?: number; width?: number; streaming?: boolean; interrupted?: boolean; provider?: AdeCodeProvider; olderHistory?: "loading" | "available" | "exhausted" | null } = {}, + options: { maxRows?: number; scrollOffsetRows?: number; width?: number; streaming?: boolean; interrupted?: boolean; provider?: AdeCodeProvider; olderHistory?: "loading" | "available" | "exhausted" | null; expanded?: boolean } = {}, ): string { const provider = options.provider ?? "codex"; + const activeSession = { ...session, provider }; const result = render( , ); return stripAnsi(result.lastFrame() ?? ""); @@ -416,10 +439,13 @@ describe("ChatView", () => { }); expect(frame).not.toContain("Runtime"); - // Headerless: the per-call lines stack directly, no "Tool calls (N)" rows. - expect(frame).not.toContain("Tool calls"); - expect(frame).toContain("grep"); + expect(frame).not.toContain("Processing tool input"); + // The two real tool calls collapse to a single header row; the latest call + // (read) previews, the earlier one (grep) hides behind the collapsed group. + expect(frame).toContain("Tool calls"); + expect(frame).toContain("(2)"); expect(frame).toContain("read"); + expect(frame).not.toContain("grep"); expect(frame).toContain("Let me look at the sendMessage flow more carefully and what events are emitted when a session is resumed."); }); @@ -635,7 +661,7 @@ describe("ChatView", () => { expect(transcriptLines(frame).at(-1)).toContain("↓ newer messages"); }); - it("renders a command as a headerless stacked tool line with shell label and command", () => { + it("renders an expanded command as a shell tool line with label, command, and duration", () => { const frame = renderEvents([ { sessionId: "s1", @@ -643,8 +669,9 @@ describe("ChatView", () => { sequence: 1, event: { type: "command", command: "git branch", cwd: "/repo", output: "main", itemId: "cmd-1", status: "completed", exitCode: 0, durationMs: 12 }, }, - ], { width: 100 }); - expect(frame).not.toContain("Tool calls"); + ], { width: 100, expanded: true }); + expect(frame).toContain("Tool calls"); + expect(frame).toContain("(1)"); expect(frame).toMatch(/✓ shell\s+git branch\s+12ms/); }); @@ -671,14 +698,15 @@ describe("ChatView", () => { }, ]; const frame = renderEvents(events, { width: 100 }); - // Headerless groups: the tool lines and the badge/stats file row stack - // directly, in event order, without "Tool calls"/"files changed" rows. - expect(frame).not.toContain("Tool calls"); - expect(frame).not.toContain("file changed"); + // Typed split: the command group and the file-change group each get their + // own collapsible header (in event order). Each single-entry group previews + // its call/file inline, so the collapsed headers still carry the signal. + expect(frame).toContain("Tool calls"); + expect(frame).toContain("Files changed"); expect(frame).toContain("npm test"); expect(frame).toContain("npm run typecheck"); expect(frame).toContain("auth.ts"); - // File rows keep their badge + diff stats format. + // The collapsed file header keeps the badge + diff stats format. expect(frame).toContain("TS"); expect(frame).toContain("+2 −1"); }); @@ -763,7 +791,9 @@ describe("ChatView", () => { }, ], { width: 100 }); - expect(frame).not.toContain("Tool calls"); + // The top-level spawn collapses into a single "Tool calls" header that + // previews it; the subagent's own child tool chatter stays suppressed. + expect(frame).toContain("Tool calls"); expect(frame).toContain("spawn_agent"); expect(frame).toContain("Explore renderer"); expect(frame).not.toContain("child launch spam"); @@ -834,7 +864,7 @@ describe("ChatView", () => { expect(transcriptBody).not.toContain("unrelated agent result"); }); - it("collapses tool-calls-group on done with failed and ok summary", () => { + it("renders per-call ok/failed glyphs for an expanded finished tool group", () => { const turnId = "turn-done"; const events: AgentChatEventEnvelope[] = [ { @@ -868,12 +898,13 @@ describe("ChatView", () => { event: { type: "done", turnId, status: "completed", usage: { inputTokens: 4000, outputTokens: 2200 }, costUsd: 0.31 }, }, ]; - const frame = renderEvents(events, { width: 100 }); - // Headerless: ok/failed status lives on each line's glyph, not a summary row. - expect(frame).not.toContain("Tool calls"); + const frame = renderEvents(events, { width: 100, expanded: true }); + // Expanded group: ok/failed status lives on each call's glyph. + expect(frame).toContain("Tool calls"); + expect(frame).toContain("(4)"); expect(frame.match(/✓/g)).toHaveLength(3); expect(frame.match(/✗/g)).toHaveLength(1); - // Most recent shell commands visible. + // Every shell command is visible when expanded. expect(frame).toContain("npm test"); expect(frame).toContain("echo two"); expect(frame).not.toContain("8.3s"); @@ -902,7 +933,7 @@ describe("ChatView", () => { }, ]; - const frame = renderEvents(events, { width: 100 }); + const frame = renderEvents(events, { width: 100, expanded: true }); expect(frame).toContain("instant"); expect(frame).toContain("measured"); expect(frame).toContain("12ms"); @@ -988,7 +1019,7 @@ describe("ChatView", () => { expect(text).not.toContain("gpt"); }); - it("stacks every tool call as its own line like the desktop work log", () => { + it("collapses many tool calls to one header row and expands to stack every call", () => { const turnId = "turn-many"; const events: AgentChatEventEnvelope[] = Array.from({ length: 12 }, (_, index): AgentChatEventEnvelope => ({ sessionId: "s1", @@ -1006,13 +1037,22 @@ describe("ChatView", () => { turnId, }, })); - const frame = renderEvents(events, { width: 120, maxRows: 40 }); - expect(frame).not.toContain("Tool calls"); - expect(frame).not.toContain("more"); - // Every consecutive call stacks as its own single line (desktop parity). - expect(frame).toContain("cmd-1"); - expect(frame).toContain("cmd-5"); - expect(frame).toContain("cmd-12"); + + // Collapsed (default): one header row with the count + the latest call's + // preview; the earlier calls are hidden behind the collapsed group. + const collapsed = renderEvents(events, { width: 120, maxRows: 40 }); + expect(collapsed).toContain("Tool calls"); + expect(collapsed).toContain("(12)"); + expect(collapsed).toContain("cmd-12"); + expect(collapsed).not.toContain("cmd-1 "); + expect(collapsed).not.toContain("cmd-5"); + + // Expanded: the header stays, and every consecutive call stacks one per line. + const expanded = renderEvents(events, { width: 120, maxRows: 40, expanded: true }); + expect(expanded).toContain("Tool calls"); + expect(expanded).toContain("cmd-1"); + expect(expanded).toContain("cmd-5"); + expect(expanded).toContain("cmd-12"); }); it("strips the /bin/zsh -lc launcher wrapper from shell commands", () => { @@ -1048,7 +1088,7 @@ describe("ChatView", () => { }, }, ]; - const frame = renderEvents(events, { width: 120 }); + const frame = renderEvents(events, { width: 120, expanded: true }); expect(frame).toContain("git status --short"); expect(frame).toContain("npm test"); // Launcher prefix is gone. @@ -1077,8 +1117,9 @@ describe("ChatView", () => { event: { type: "file_change", path: "docs/notes.md", diff: "+line1", kind: "create", itemId: "f3", status: "completed", turnId: "t1" }, }, ]; - const frame = renderEvents(events, { width: 120 }); - expect(frame).not.toContain("files changed"); + const frame = renderEvents(events, { width: 120, expanded: true }); + expect(frame).toContain("Files changed"); + expect(frame).toContain("(3)"); expect(frame).toContain("TSX"); expect(frame).toContain("JS"); expect(frame).toContain("MD"); @@ -1086,6 +1127,73 @@ describe("ChatView", () => { expect(frame).toContain("Deleted"); }); + it("collapses file changes to one header row by default, previewing the latest edit", () => { + const events: AgentChatEventEnvelope[] = [ + { + sessionId: "s1", + timestamp: "2026-01-01T12:00:00.000Z", + sequence: 1, + event: { type: "file_change", path: "src/early.ts", diff: "+a\n+b\n-c", kind: "modify", itemId: "f1", status: "completed", turnId: "t1" }, + }, + { + sessionId: "s1", + timestamp: "2026-01-01T12:00:01.000Z", + sequence: 2, + event: { type: "file_change", path: "src/recent.ts", diff: "+x", kind: "modify", itemId: "f2", status: "completed", turnId: "t1" }, + }, + ]; + const collapsed = renderEvents(events, { width: 120 }); + expect(collapsed).toContain("Files changed"); + expect(collapsed).toContain("(2)"); + // Latest edit previews; the earlier file hides behind the collapsed group. + expect(collapsed).toContain("recent.ts"); + expect(collapsed).not.toContain("early.ts"); + + const expanded = renderEvents(events, { width: 120, expanded: true }); + expect(expanded).toContain("early.ts"); + expect(expanded).toContain("recent.ts"); + }); + + it("tags a collapsed work-group header with an expandable click-target id", () => { + const events: AgentChatEventEnvelope[] = [ + { + sessionId: "s1", + timestamp: "2026-01-01T12:00:00.000Z", + sequence: 1, + event: { type: "command", command: "alpha", cwd: "/repo", output: "", itemId: "c1", status: "completed", exitCode: 0, durationMs: 10, turnId: "t1" }, + }, + { + sessionId: "s1", + timestamp: "2026-01-01T12:00:01.000Z", + sequence: 2, + event: { type: "command", command: "beta", cwd: "/repo", output: "", itemId: "c2", status: "completed", exitCode: 0, durationMs: 10, turnId: "t1" }, + }, + ]; + const expectedKey = workGroupExpandKey(chatEventLineId(events[0]!, 0)); + const rows = renderChatVisibleSelectionRows({ + events, + notices: [], + activeSession: session, + width: 120, + }); + const headerRow = rows.find((row) => row.expandableId != null); + // The collapsed group renders exactly one clickable header carrying the + // expand key the transcript click handler toggles in expandedLineIds. + expect(headerRow?.expandableId).toBe(expectedKey); + expect(rows.filter((row) => row.expandableId != null)).toHaveLength(1); + + const expandedRows = renderChatVisibleSelectionRows({ + events, + notices: [], + activeSession: session, + expandedLineIds: new Set([expectedKey]), + width: 120, + }); + // Still exactly one clickable header (now ▾) plus the stacked call rows. + expect(expandedRows.filter((row) => row.expandableId != null)).toHaveLength(1); + expect(expandedRows.length).toBeGreaterThan(rows.length); + }); + it("renders fenced code with highlight.js-derived per-line tokens", () => { const events: AgentChatEventEnvelope[] = [ { diff --git a/apps/ade-cli/src/tuiClient/__tests__/MultiChatGrid.test.tsx b/apps/ade-cli/src/tuiClient/__tests__/MultiChatGrid.test.tsx index 476619968..59949e5bb 100644 --- a/apps/ade-cli/src/tuiClient/__tests__/MultiChatGrid.test.tsx +++ b/apps/ade-cli/src/tuiClient/__tests__/MultiChatGrid.test.tsx @@ -6,6 +6,7 @@ import { HitTestProvider, createHitTestRegistry } from "../hitTestRegistry"; import { computeTileRects } from "../multiChatLayout"; import type { LocalNotice } from "../types"; import type { AgentChatSessionSummary } from "../../../../desktop/src/shared/types/chat"; +import type { ChatTerminalSession } from "../../../../desktop/src/shared/types/sessions"; function makeSession(sessionId: string, laneId: string, title: string): AgentChatSessionSummary { return { @@ -40,7 +41,7 @@ function userMessage(sessionId: string, text: string) { }; } -async function renderGrid(options: { width: number; height: number; baseX?: number; baseY?: number; registry?: ReturnType }) { +async function renderGrid(options: { width: number; height: number; baseX?: number; baseY?: number; registry?: ReturnType; notices?: LocalNotice[] }) { const tiles = [ { sessionId: "s1", laneId: "lane-1" }, { sessionId: "s2", laneId: "lane-2" }, @@ -66,7 +67,7 @@ async function renderGrid(options: { width: number; height: number; baseX?: numb s1: [userMessage("s1", "hello from alpha")], s2: [userMessage("s2", "hello from beta")], }} - notices={notices} + notices={options.notices ?? notices} streamingBySessionId={{}} interruptedBySessionId={{}} scrollBySessionId={{}} @@ -92,6 +93,33 @@ describe("MultiChatGrid", () => { expect(occurrences).toBe(1); }); + it("scopes session-tagged notices to their own tile, including unfocused tiles", async () => { + // Layout: s1 (focused) is the left tile (cols 0-59), s2 (unfocused) the right + // tile (cols 60-119). A notice tagged to s2 must render in s2's tile even though + // it is unfocused (the old behaviour only fed notices to the focused tile), and + // a notice tagged to s1 must not leak into s2's column range. + const frame = await renderGrid({ + width: 120, + height: 20, + notices: [ + { id: "a", timestamp: "2026-01-01T12:00:01.000Z", tone: "success", text: "alpha-only notice", sessionId: "s1" }, + { id: "b", timestamp: "2026-01-01T12:00:02.000Z", tone: "success", text: "beta-only notice", sessionId: "s2" }, + ], + }); + const columnOf = (needle: string): number => { + for (const line of frame.split("\n")) { + const at = line.indexOf(needle); + if (at >= 0) return at; + } + return -1; + }; + // Each notice appears exactly once, in its own tile's column range. + expect(frame.split("alpha-only notice").length - 1).toBe(1); + expect(frame.split("beta-only notice").length - 1).toBe(1); + expect(columnOf("alpha-only notice")).toBeLessThan(60); + expect(columnOf("beta-only notice")).toBeGreaterThanOrEqual(60); + }); + it("fills the full height it is given, bottom borders on the last row", async () => { const height = 20; const frame = await renderGrid({ width: 120, height }); @@ -101,6 +129,67 @@ describe("MultiChatGrid", () => { expect(lines[height - 1]).toMatch(/[╰└╚]/); }); + it("renders a Claude terminal tile (live pane + naming hint) instead of a chat transcript", async () => { + const terminal: ChatTerminalSession = { + terminalId: "t1", + ptyId: "pty-1", + chatSessionId: null, + laneId: "lane-1", + laneName: "Lane 1", + title: "Claude Code", + toolType: "claude", + goal: null, + status: "running", + runtimeState: "running", + active: true, + startedAt: "2026-01-01T12:00:00.000Z", + endedAt: null, + exitCode: null, + pid: null, + resumeCommand: null, + resumeMetadata: null, + lastOutputPreview: null, + summary: null, + }; + const result = render( + + {}} + onRemoveTile={() => {}} + /> + , + ); + await new Promise((resolve) => setTimeout(resolve, 0)); + const frame = stripAnsi(result.lastFrame() ?? ""); + // The terminal tile shows the session title + the "naming…" loading hint (placeholder title), + // and the chat tile still renders its transcript — proving the per-tile branch. + expect(frame).toContain("Claude Code"); + expect(frame).toContain("naming…"); + expect(frame).toContain("hello from alpha"); + }); + it("registers the remove hit-target on each tile's title row", async () => { const registry = createHitTestRegistry(); const baseX = 21; diff --git a/apps/ade-cli/src/tuiClient/__tests__/TerminalPane.test.tsx b/apps/ade-cli/src/tuiClient/__tests__/TerminalPane.test.tsx index 976c05ee9..8d5b0ce48 100644 --- a/apps/ade-cli/src/tuiClient/__tests__/TerminalPane.test.tsx +++ b/apps/ade-cli/src/tuiClient/__tests__/TerminalPane.test.tsx @@ -37,6 +37,7 @@ function preview( status?: "running" | "completed" | "failed" | "disposed" | "detached"; runtimeState?: "running" | "waiting-input" | "idle" | "exited" | "killed"; resumeCommand?: string | null; + serialized?: string; } = {}, ): ChatTerminalPreviewResult { return { @@ -55,7 +56,7 @@ function preview( cursorY: 0, baseY: 0, viewportY: 0, - serialized: "", + serialized: overrides.serialized ?? "", visibleRows: rows, }, transcript: overrides.transcript ?? null, @@ -149,6 +150,82 @@ describe("TerminalPane", () => { expect(frame).toContain("permission prompt"); }); + it("shows live PTY chunks instead of waiting for the next snapshot refresh", async () => { + const result = render( + , + ); + + result.rerender( + , + ); + + await waitFor(() => stripAnsi(result.lastFrame() ?? "").includes("fresh echo")); + expect(stripAnsi(result.lastFrame() ?? "")).toContain("fresh echo"); + }); + + it("applies live PTY chunks on top of the newest snapshot seed", async () => { + const result = render( + , + ); + + result.rerender( + , + ); + + result.rerender( + , + ); + + await waitFor(() => stripAnsi(result.lastFrame() ?? "").includes("fresh echo")); + const frame = stripAnsi(result.lastFrame() ?? ""); + expect(frame).toContain("new snapshot"); + expect(frame).toContain("fresh echo"); + expect(frame).not.toContain("old snapshot"); + }); + it("uses transcript history for closed terminal sessions instead of the final resume-only snapshot", async () => { const result = render( { settingsRows: [{ kind: "permission", label: "Permissions", value: "auto" }], }); }); + + it("wraps model picker provider tabs in both directions", () => { + const providerTabs = [ + { key: "chat" }, + { key: "agent" }, + { key: "composer" }, + ]; + + expect(nextModelPickerProviderTabKey({ providerTabs, providerTabIndex: 0, delta: 1 })).toBe("agent"); + expect(nextModelPickerProviderTabKey({ providerTabs, providerTabIndex: 0, delta: -1 })).toBe("composer"); + expect(nextModelPickerProviderTabKey({ providerTabs: [{ key: "only" }], providerTabIndex: 0, delta: 1 })).toBeNull(); + }); }); describe("drawer mouse hit testing", () => { @@ -1279,3 +1294,59 @@ describe("encodeTerminalPromptSubmit", () => { expect(CLAUDE_TERMINAL_SUBMIT_CONFIRM_DELAY_MS).toBeGreaterThanOrEqual(1000); }); }); + +describe("mergeOptimisticTerminalSessions", () => { + const makeTerminal = (terminalId: string): ChatTerminalSession => ({ + terminalId, + ptyId: null, + chatSessionId: null, + laneId: "lane-1", + laneName: "Lane 1", + title: "Claude Code", + toolType: "claude", + goal: null, + status: "running", + runtimeState: "running", + active: true, + startedAt: "2026-01-01T00:00:00.000Z", + endedAt: null, + exitCode: null, + pid: null, + resumeCommand: null, + resumeMetadata: null, + lastOutputPreview: null, + summary: null, + }); + + it("returns the listed sessions unchanged when there are no optimistic entries", () => { + const listed = [makeTerminal("a")]; + expect(mergeOptimisticTerminalSessions(listed, new Map())).toBe(listed); + }); + + it("prepends an optimistic terminal the runtime list has not surfaced yet", () => { + // This is the new-chat reroute fix: a freshly-created Claude terminal must be + // present so resolveTuiChatRefreshTarget keeps it selected. + const optimistic = new Map([["new", makeTerminal("new")]]); + const merged = mergeOptimisticTerminalSessions([makeTerminal("old")], optimistic); + expect(merged.map((session) => session.terminalId)).toEqual(["new", "old"]); + }); + + it("self-cleans an optimistic entry once the real list reports it (no duplicate)", () => { + const optimistic = new Map([["a", makeTerminal("a")]]); + const merged = mergeOptimisticTerminalSessions([makeTerminal("a")], optimistic); + expect(merged.map((session) => session.terminalId)).toEqual(["a"]); + expect(optimistic.has("a")).toBe(false); + }); +}); + +describe("isClaudePlaceholderTitle", () => { + it("treats generic/empty Claude titles as placeholders awaiting auto-naming", () => { + for (const title of ["Claude Code", "claude", "Claude CLI", "claude session", "", " "]) { + expect(isClaudePlaceholderTitle(title)).toBe(true); + } + }); + + it("treats a real generated title as named", () => { + expect(isClaudePlaceholderTitle("Fix the sync race")).toBe(false); + }); +}); diff --git a/apps/ade-cli/src/tuiClient/__tests__/appPolling.test.tsx b/apps/ade-cli/src/tuiClient/__tests__/appPolling.test.tsx index 922cf6014..d1408b922 100644 --- a/apps/ade-cli/src/tuiClient/__tests__/appPolling.test.tsx +++ b/apps/ade-cli/src/tuiClient/__tests__/appPolling.test.tsx @@ -118,6 +118,8 @@ describe("AdeCodeApp polling", () => { projectRoot: "/repo", workspaceRoot: "/repo", laneHint: null, + sessionHint: null, + remote: false, }; beforeEach(() => { diff --git a/apps/ade-cli/src/tuiClient/__tests__/cli.test.tsx b/apps/ade-cli/src/tuiClient/__tests__/cli.test.tsx index ad38cb17e..14ae010c7 100644 --- a/apps/ade-cli/src/tuiClient/__tests__/cli.test.tsx +++ b/apps/ade-cli/src/tuiClient/__tests__/cli.test.tsx @@ -8,9 +8,12 @@ const renderMock = vi.hoisted(() => const detectProjectLaunchContextMock = vi.hoisted(() => vi.fn(() => ({ + launchCwd: "/repo", projectRoot: "/repo", workspaceRoot: "/repo", laneHint: null, + sessionHint: null, + remote: false, })), ); @@ -47,6 +50,31 @@ describe("ade code CLI entrypoint", () => { expect(parseArgs(["--prefer-service-repair"]).preferServiceRepair).toBe(true); }); + it("accepts remote launch context flags", () => { + expect(parseArgs([ + "--remote", + "--project-root", + "/remote/project", + "--workspace-root", + "/remote/project", + "--lane", + "lane-1", + "--session", + "session-1", + "--socket", + "tcp://127.0.0.1:43333", + "--require-socket", + ])).toMatchObject({ + remote: true, + projectRoot: "/remote/project", + workspaceRoot: "/remote/project", + laneHint: "lane-1", + sessionHint: "session-1", + socketPath: "tcp://127.0.0.1:43333", + requireSocket: true, + }); + }); + it("passes Ctrl+C handling through to ADE Code instead of Ink", async () => { await expect(runAdeCodeCli(["--embedded"])).resolves.toBe(0); diff --git a/apps/ade-cli/src/tuiClient/__tests__/connection.test.ts b/apps/ade-cli/src/tuiClient/__tests__/connection.test.ts index a77321a8a..974b6b8b1 100644 --- a/apps/ade-cli/src/tuiClient/__tests__/connection.test.ts +++ b/apps/ade-cli/src/tuiClient/__tests__/connection.test.ts @@ -82,6 +82,8 @@ const project: ProjectLaunchContext = { projectRoot: "/tmp/ade-code", workspaceRoot: "/tmp/ade-code", laneHint: null, + sessionHint: null, + remote: false, }; const originalArgv1 = process.argv[1]; @@ -280,6 +282,50 @@ describe("connectToAde embedded mode", () => { fs.rmSync(tmpDir, { recursive: true, force: true }); }); + it("allows remote sockets to differ by build hash and project root", async () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "ade-code-remote-socket-")); + const socketPath = path.join(tmpDir, "ade.sock"); + const requests: string[] = []; + const server = net.createServer((socket) => { + let buffer = ""; + socket.on("data", (chunk) => { + buffer += chunk.toString("utf8"); + while (true) { + const newline = buffer.indexOf("\n"); + if (newline < 0) return; + const line = buffer.slice(0, newline).trim(); + buffer = buffer.slice(newline + 1); + if (!line) continue; + const request = JSON.parse(line) as { id: number; method: string }; + requests.push(request.method); + const result = request.method === "ade/initialize" + ? { + runtimeInfo: { + defaultRole: "cto", + projectRoot: "/remote/project", + buildHash: "remote-build", + }, + } + : null; + socket.write(`${JSON.stringify({ jsonrpc: "2.0", id: request.id, result })}\n`); + } + }); + }); + await new Promise((resolve) => server.listen(socketPath, resolve)); + + const connection = await connectToAde({ + project, + socketPath, + requireSocket: true, + remote: true, + }); + await connection.close(); + + expect(requests).toEqual(["ade/initialize", "ade/initialized"]); + await new Promise((resolve) => server.close(() => resolve())); + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + it("registers the project and injects projectId when attached to the machine daemon", async () => { const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "ade-code-connection-")); const socketPath = path.join(tmpDir, "ade.sock"); diff --git a/apps/ade-cli/src/tuiClient/__tests__/feedback.test.ts b/apps/ade-cli/src/tuiClient/__tests__/feedback.test.ts index fd1dbbcf2..3180cad3b 100644 --- a/apps/ade-cli/src/tuiClient/__tests__/feedback.test.ts +++ b/apps/ade-cli/src/tuiClient/__tests__/feedback.test.ts @@ -86,6 +86,8 @@ describe("ADE Code feedback helpers", () => { projectRoot: "/tmp/project", workspaceRoot: "/tmp/project", laneHint: null, + sessionHint: null, + remote: false, }, lane()); const fields = feedbackFormFields(environment); diff --git a/apps/ade-cli/src/tuiClient/__tests__/project.test.ts b/apps/ade-cli/src/tuiClient/__tests__/project.test.ts index 7a65d8184..1d076b102 100644 --- a/apps/ade-cli/src/tuiClient/__tests__/project.test.ts +++ b/apps/ade-cli/src/tuiClient/__tests__/project.test.ts @@ -53,6 +53,23 @@ describe("chooseInitialLane", () => { expect(context.laneHint).toBe("feature-a"); }); + it("allows remote-only roots and carries the initial session hint", () => { + const context = detectProjectLaunchContext({ + cwd: "/tmp", + projectRoot: "/remote/project", + workspaceRoot: "/remote/project/.ade/worktrees/work", + laneHint: "work", + sessionHint: "session-remote", + remote: true, + }); + + expect(context.projectRoot).toBe("/remote/project"); + expect(context.workspaceRoot).toBe("/remote/project/.ade/worktrees/work"); + expect(context.laneHint).toBe("work"); + expect(context.sessionHint).toBe("session-remote"); + expect(context.remote).toBe(true); + }); + it("prefers the ADE worktree lane hint", () => { const lanes = [ lane({ id: "main", name: "main", laneType: "primary", worktreePath: "/repo" }), @@ -131,6 +148,34 @@ describe("chooseMostRecentSessionLane", () => { }); describe("resolveTuiChatRefreshTarget", () => { + it("uses the active session lane when an initial session hint points outside the default lane", () => { + const lanes = [ + lane({ id: "main", name: "main", laneType: "primary", worktreePath: "/repo" }), + lane({ id: "feature-a", name: "Feature A", laneType: "worktree", worktreePath: "/repo/.ade/worktrees/feature-a" }), + ]; + const hinted = chat("hinted-chat", "feature-a", "2026-01-02T00:00:00.000Z"); + + const target = resolveTuiChatRefreshTarget({ + lanes, + sessions: [ + chat("main-chat", "main", "2026-01-01T00:00:00.000Z"), + hinted, + ], + context: { workspaceRoot: "/repo", laneHint: null }, + lastLaneId: "main", + activeLaneId: null, + activeSessionId: "hinted-chat", + draftChatActive: false, + initialNewChatPreview: false, + newChatPreviewLaneId: null, + selectedDrawerChatAction: null, + drawerLaneId: null, + }); + + expect(target.laneId).toBe("feature-a"); + expect(target.session?.sessionId).toBe("hinted-chat"); + }); + it("launches into a new-chat preview for the most recent runtime lane without hydrating its last chat", () => { const lanes = [ lane({ id: "main", name: "main", laneType: "primary", worktreePath: "/repo" }), diff --git a/apps/ade-cli/src/tuiClient/__tests__/remoteLauncher.test.ts b/apps/ade-cli/src/tuiClient/__tests__/remoteLauncher.test.ts new file mode 100644 index 000000000..93f4e2afc --- /dev/null +++ b/apps/ade-cli/src/tuiClient/__tests__/remoteLauncher.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from "vitest"; +import { + buildRemoteRuntimeRpcCommand, + parseRemoteAdeCodeArgs, + takeAdeCodeRemoteArgs, +} from "../remoteLauncher"; + +describe("ade code remote launcher", () => { + it("detects remote as the first standalone ade code positional", () => { + expect(takeAdeCodeRemoteArgs(["remote", "session", "--target", "mac"])).toEqual([ + "session", + "--target", + "mac", + ]); + expect(takeAdeCodeRemoteArgs(["--session", "remote"])).toBeNull(); + expect(takeAdeCodeRemoteArgs(["project"])).toBeNull(); + }); + + it("parses project and session selection flags", () => { + expect(parseRemoteAdeCodeArgs([ + "session", + "--target", + "workstation", + "--project", + "ADE", + "--session", + "chat-1", + ])).toMatchObject({ + scope: "session", + targetQuery: "workstation", + projectQuery: "ADE", + sessionQuery: "chat-1", + }); + }); + + it("builds the remote ADE stdio command for the selected runtime home", () => { + const layout: Parameters[0] = { + channel: "beta", + homeDirName: ".ade-beta", + homeDirExpr: "$HOME/.ade-beta", + binDirExpr: "$HOME/.ade-beta/bin", + runtimeDirExpr: "$HOME/.ade-beta/runtime", + socketExpr: "$HOME/.ade-beta/sock/ade.sock", + binaryExpr: "$HOME/.ade-beta/bin/ade", + }; + + expect(buildRemoteRuntimeRpcCommand(layout)).toContain('export ADE_HOME="$HOME/.ade-beta"'); + expect(buildRemoteRuntimeRpcCommand(layout)).toContain('export ADE_PACKAGE_CHANNEL="beta"'); + expect(buildRemoteRuntimeRpcCommand(layout)).toContain('export ADE_PTY_HOST_WORKER_COMMAND="$HOME/.ade-beta/bin/ade"'); + expect(buildRemoteRuntimeRpcCommand(layout)).toContain("exec $HOME/.ade-beta/bin/ade --socket $HOME/.ade-beta/sock/ade.sock rpc --stdio"); + }); +}); diff --git a/apps/ade-cli/src/tuiClient/app.tsx b/apps/ade-cli/src/tuiClient/app.tsx index a2bead610..4223e7be2 100644 --- a/apps/ade-cli/src/tuiClient/app.tsx +++ b/apps/ade-cli/src/tuiClient/app.tsx @@ -213,6 +213,9 @@ import { type HitTarget, } from "./hitTestRegistry"; import { + asTileCount, + canRenderMultiChatGrid, + computeTileRects, focusedSessionIdForMultiView, type MultiViewState, type MultiViewTile, @@ -401,6 +404,16 @@ export function modelPickerProviderSwitchBlocked(args: { && args.currentProvider !== args.nextProvider; } +export function nextModelPickerProviderTabKey(args: { + providerTabs: ReadonlyArray<{ key: string }>; + providerTabIndex: number; + delta: -1 | 1; +}): string | null { + if (args.providerTabs.length <= 1) return null; + const nextIndex = (args.providerTabIndex + args.delta + args.providerTabs.length) % args.providerTabs.length; + return args.providerTabs[nextIndex]?.key ?? null; +} + export function mergeNewChatModelPickerContext( prev: Extract, next: Extract, @@ -665,6 +678,22 @@ export function mergeOptimisticChatSessions( return pending.length ? [...pending, ...sessions] : sessions; } +// Terminal-session analogue of mergeOptimisticChatSessions. A freshly-created +// Claude CLI terminal (or a just-resumed one) is not always returned by the very +// next `terminal.list` poll, so without this its session id is absent from the +// merged session list when `resolveTuiChatRefreshTarget` runs — and the resolver +// falls back to the newest existing chat, yanking focus onto a different session. +// Keyed by terminalId; self-cleans an entry once the real list reports it. +export function mergeOptimisticTerminalSessions( + sessions: ChatTerminalSession[], + optimistic: Map, +): ChatTerminalSession[] { + if (optimistic.size === 0) return sessions; + for (const session of sessions) optimistic.delete(session.terminalId); + const pending = [...optimistic.values()]; + return pending.length ? [...pending, ...sessions] : sessions; +} + const DESKTOP_COMMAND_ROUTES: Record = { "/app-control": "/app-control", "/browser": "/browser", @@ -683,6 +712,7 @@ type AdeCodeAppProps = { requireSocket?: boolean; socketPath?: string | null; preferServiceRepair?: boolean; + remote?: boolean; }; type RefreshStateOptions = { @@ -2428,6 +2458,14 @@ export function isTerminalControlToggle(input: string, key: { ctrl?: boolean; me return isCtrlInput(input, key, "t"); } +// Mirrors ptyService.isCliPlaceholderTitle for Claude: a session still showing a +// generic default title is awaiting the background auto-naming job (when enabled). +const CLAUDE_PLACEHOLDER_TITLES = new Set(["claude", "claude cli", "claude session", "claude code"]); +export function isClaudePlaceholderTitle(title: string | null | undefined): boolean { + const normalized = String(title ?? "").trim().toLowerCase(); + return normalized.length === 0 || CLAUDE_PLACEHOLDER_TITLES.has(normalized); +} + export function splitTerminalControlInput(raw: string): { detach: boolean; forwarded: string } { const forwarded = raw.replace(/[\x14\x1d]/g, ""); return { detach: forwarded.length !== raw.length, forwarded }; @@ -2562,7 +2600,7 @@ function resolveCenterPaneWidth(columns: number, drawerOpen: boolean, rightPaneW ); } -export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath, preferServiceRepair }: AdeCodeAppProps) { +export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath, preferServiceRepair, remote }: AdeCodeAppProps) { const { exit } = useApp(); const [columns, rows] = useTerminalDimensions(); useTerminalAlternateScreen(); @@ -2579,6 +2617,10 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath, const [sessions, setSessions] = useState([]); const [terminalSessions, setTerminalSessions] = useState([]); const [terminalPreview, setTerminalPreview] = useState(null); + // Per-terminal seed snapshots for grid tiles (the single-view pane uses + // `terminalPreview`). Live updates after the seed arrive via terminalLiveChunks, + // which the global pty subscription already buffers per session id. + const [terminalPreviewById, setTerminalPreviewById] = useState>({}); const [attachedTerminalId, setAttachedTerminalId] = useState(null); const [terminalLiveChunks, setTerminalLiveChunks] = useState>({}); // Scrollback position + "↓ N new" counter per Claude PTY session. @@ -2600,7 +2642,7 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath, visibleText: "", }); const [activeLaneId, setActiveLaneId] = useState(null); - const [activeSessionId, setActiveSessionId] = useState(null); + const [activeSessionId, setActiveSessionId] = useState(project.sessionHint); const [events, setEvents] = useState([]); const [notices, setNotices] = useState([]); const [slashCommands, setSlashCommands] = useState([]); @@ -2710,7 +2752,8 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath, const connectionRef = useRef(null); const connectionLostRef = useRef(false); const activeLaneIdRef = useRef(null); - const activeSessionIdRef = useRef(null); + const activeSessionIdRef = useRef(project.sessionHint); + const initialSessionHintRef = useRef(project.sessionHint); const multiViewRef = useRef(null); const gridViewActiveRef = useRef(false); // Show/hide the grid without destroying it. Sets the ref synchronously so the @@ -2787,9 +2830,16 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath, const activeSessionRef = useRef(null); const sessionsRef = useRef([]); const optimisticChatSessionsRef = useRef>(new Map()); + const optimisticTerminalSessionsRef = useRef>(new Map()); const activeTerminalSessionRef = useRef(null); const terminalSessionsRef = useRef([]); const attachedTerminalIdRef = useRef(null); + // When Ctrl+T control is entered from a grid tile, remember the tile's session id + // so exiting control (Ctrl+T / Ctrl+]) drops back into the grid focused on it. + const controlReturnToGridRef = useRef(null); + // Show the auto-naming directive once per process when the first Claude CLI + // session is created, so the "naming…" loading hint has context. + const claudeAutoNamingHintShownRef = useRef(false); const claudeTerminalSubmitQueueRef = useRef>(Promise.resolve()); const lastModelPickerClaudeSentKeyRef = useRef(null); const exitRequestedRef = useRef(false); @@ -3334,7 +3384,20 @@ 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.filter((session) => !session.archivedAt).map((session) => session.sessionId)), [sessions]); + // Keyed by terminalId; lets the grid tell a terminal tile from a chat tile and + // render a live TerminalPane for it. + const terminalSessionById = useMemo(() => { + const out: Record = {}; + for (const terminal of terminalSessions) out[terminal.terminalId] = terminal; + return out; + }, [terminalSessions]); + // Both agent chats and Claude CLI terminal sessions can be tiled in the grid. + // Terminal tiles render a live TerminalPane instead of a ChatView transcript. + const tileableSessionIds = useMemo(() => { + const ids = new Set(sessions.filter((session) => !session.archivedAt).map((session) => session.sessionId)); + for (const terminal of terminalSessions) ids.add(terminal.terminalId); + return ids; + }, [sessions, terminalSessions]); const tileableDisplaySessions = useMemo( () => displaySessions.filter((session) => tileableSessionIds.has(session.sessionId)), [displaySessions, tileableSessionIds], @@ -3944,7 +4007,14 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath, .catch(() => { /* keep the locally-reconstructed transcript */ }); return () => { cancelled = true; }; }, [activeLane?.id, activeSessionId, chatInfo.capability.canViewFullTranscript, realSubagentTranscript, selectedAgentSnapshot]); - const displayNotices = useMemo(() => (selectedAgentSnapshot ? [] : notices), [notices, selectedAgentSnapshot]); + // Notices are a single global list, but each one is tagged with the chat that was + // active when it fired. Only show a notice in its own chat (session-less notices + // fall back to whichever chat is active) so cross-chat feedback like "Model set + // to…" or "Attached clipboard image." can't bleed into the selected transcript. + const displayNotices = useMemo( + () => (selectedAgentSnapshot ? [] : notices.filter((notice) => !notice.sessionId || notice.sessionId === activeSessionId)), + [notices, selectedAgentSnapshot, activeSessionId], + ); // 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 @@ -4139,6 +4209,9 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath, useEffect(() => { if (!connection || !activeTerminalSession) return; + // In grid view the per-tile resize effect owns terminal sizing; running the + // single-view (full-pane) resize too would thrash the PTY between sizes. + if (gridViewActive) return; const cols = clampTerminalPaneCols(claudeTerminalControlActive ? terminalPaneWidth - 2 : terminalPaneWidth); const terminalRows = claudeTerminalControlActive ? Math.max(4, chatRowBudget - 1) @@ -4155,10 +4228,13 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath, return () => { cancelled = true; }; - }, [activeTerminalSession, chatRowBudget, claudeTerminalControlActive, connection, terminalPaneWidth]); + }, [activeTerminalSession, chatRowBudget, claudeTerminalControlActive, connection, gridViewActive, terminalPaneWidth]); useEffect(() => { if (!connection || !activeTerminalSession) return; + if (claudeTerminalControlActive) return; + // Single-view preview poll only; grid tiles follow live pty chunks instead. + if (gridViewActive) return; let cancelled = false; const refreshPreview = () => { void previewTerminal(connection, activeTerminalSession.terminalId) @@ -4179,7 +4255,7 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath, cancelled = true; clearInterval(timer); }; - }, [activeTerminalSession, chatRowBudget, connection, terminalPaneWidth]); + }, [activeTerminalSession, chatRowBudget, claudeTerminalControlActive, connection, gridViewActive, terminalPaneWidth]); useEffect(() => { modelStateRef.current = modelState; @@ -4863,7 +4939,7 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath, const addNotice = useCallback((text: string, tone: LocalNotice["tone"] = "info") => { setNotices((prev) => [ ...prev.slice(-10), - { id: noticeId(), timestamp: new Date().toISOString(), text, tone }, + { id: noticeId(), timestamp: new Date().toISOString(), text, tone, sessionId: activeSessionIdRef.current }, ]); }, []); @@ -4931,6 +5007,33 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath, } }, [clearedAt]); + const isTerminalTileSessionId = useCallback((sessionId: string | null | undefined) => { + if (!sessionId) return false; + return terminalSessionsRef.current.some((terminal) => terminal.terminalId === sessionId); + }, []); + + // Seed a terminal tile's preview snapshot once when it joins the grid. Live + // output then flows through the per-session terminalLiveChunks buffer. + const hydrateTerminalTilePreview = useCallback(async (terminalId: string) => { + const conn = connectionRef.current; + if (!conn) return; + try { + const preview = await previewTerminal(conn, terminalId); + setTerminalPreviewById((prev) => ({ ...prev, [terminalId]: preview })); + } catch { + // Non-fatal: the tile renders from live chunks / fallback until the next poll. + } + }, []); + + // For a freshly-added tile, fetch chat history or terminal preview as fitting. + const hydrateTileTarget = useCallback((sessionId: string) => { + if (isTerminalTileSessionId(sessionId)) { + void hydrateTerminalTilePreview(sessionId); + return; + } + void hydrateTileHistory(sessionId).catch((err) => addNotice(err instanceof Error ? err.message : String(err), "error")); + }, [addNotice, hydrateTerminalTilePreview, hydrateTileHistory, isTerminalTileSessionId]); + const focusMultiViewTile = useCallback((index: number) => { setMultiView((prev) => { if (!prev) return prev; @@ -5009,14 +5112,15 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath, const isTileableChatSessionId = useCallback((sessionId: string | null | undefined) => { if (!sessionId) return false; - return sessionsRef.current.some((session) => session.sessionId === sessionId); + return sessionsRef.current.some((session) => session.sessionId === sessionId) + || terminalSessionsRef.current.some((terminal) => terminal.terminalId === sessionId); }, []); const addTileToGrid = useCallback((sessionId: string, laneId: string) => { if (!sessionId || !laneId) return; if (!isTileableChatSessionId(sessionId)) { - flashMultiViewNotice("Only agent chats can be split right now"); - addNotice("Only agent chats can be added to split view.", "info"); + flashMultiViewNotice("That session can't be split right now"); + addNotice("Only active agent chats and Claude CLI sessions can be added to split view.", "info"); setPaneFocus("addMode"); return; } @@ -5069,14 +5173,20 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath, setGridView(true); } } - void hydrateTileHistory(sessionId).catch((err) => addNotice(err instanceof Error ? err.message : String(err), "error")); + hydrateTileTarget(sessionId); const currentSessionId = activeSessionIdRef.current; - if (currentSessionId && isTileableChatSessionId(currentSessionId) && !eventsBySessionIdRef.current[currentSessionId]) { + if ( + currentSessionId + && currentSessionId !== sessionId + && isTileableChatSessionId(currentSessionId) + && !isTerminalTileSessionId(currentSessionId) + && !eventsBySessionIdRef.current[currentSessionId] + ) { void hydrateTileHistory(currentSessionId).catch(() => undefined); } setAddMode(null); setPaneFocus("chat"); - }, [addNotice, flashMultiViewNotice, hydrateTileHistory, isTileableChatSessionId, selectActiveLaneId, selectActiveSessionId, setGridView, setPaneFocus]); + }, [addNotice, flashMultiViewNotice, hydrateTileHistory, hydrateTileTarget, isTerminalTileSessionId, isTileableChatSessionId, selectActiveLaneId, selectActiveSessionId, setGridView, setPaneFocus]); const startAddMode = useCallback(() => { const firstLane = orderedDrawerLanes[0] ?? null; @@ -5871,7 +5981,8 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath, const nextLanes = await listLanes(conn); const listedSessions = await listChatSessions(conn); const nextSessions = mergeOptimisticChatSessions(listedSessions, optimisticChatSessionsRef.current); - const nextTerminalSessions = await listTerminalSessions(conn).catch(() => []); + const listedTerminalSessions = await listTerminalSessions(conn).catch(() => []); + const nextTerminalSessions = mergeOptimisticTerminalSessions(listedTerminalSessions, optimisticTerminalSessionsRef.current); const nextDisplaySessions = [...nextSessions, ...nextTerminalSessions.map(terminalSessionToChatSummary)]; const draftMode = draftChatActiveRef.current; const target = resolveTuiChatRefreshTarget({ @@ -6322,24 +6433,26 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath, let cancelled = false; void (async () => { try { - const conn = await connectToAde({ project, forceEmbedded, requireSocket, socketPath, preferServiceRepair }); + const conn = await connectToAde({ project, forceEmbedded, requireSocket, socketPath, preferServiceRepair, remote }); if (cancelled) { await conn.close(); return; } - heartbeatRef.current = startTuiHeartbeat(project.projectRoot, { - beforeSignalExit: () => { - signalActiveTerminalForExitSync(); - return signalActiveTerminalForExit(); - }, - }); + heartbeatRef.current = remote + ? null + : startTuiHeartbeat(project.projectRoot, { + beforeSignalExit: () => { + signalActiveTerminalForExitSync(); + return signalActiveTerminalForExit(); + }, + }); connectionRef.current = conn; setConnection(conn); setMode(conn.mode); draftSeededFromHistoryRef.current = false; newChatPreviewLaneIdRef.current = null; setDraftChatMode(false); - selectActiveSessionId(null); + selectActiveSessionId(initialSessionHintRef.current); eventDedupKeysRef.current.clear(); eventDedupKeyOrderRef.current = []; eventCountRef.current = 0; @@ -6377,7 +6490,7 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath, connectionRef.current = null; void conn?.close().catch(() => {}); }; - }, [forceEmbedded, preferServiceRepair, project, requireSocket, signalActiveTerminalForExit, signalActiveTerminalForExitSync, socketPath]); + }, [forceEmbedded, preferServiceRepair, project, remote, 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 @@ -6773,6 +6886,7 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath, requireSocket: true, socketPath, preferServiceRepair, + remote, }); if (attached.mode !== "attached") { await attached.close().catch(() => {}); @@ -6945,6 +7059,44 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath, return next; }, [models]); + // Make a just-created / just-resumed Claude terminal immediately visible to the + // session list and to refreshState's target resolver, so selecting it sticks + // instead of getting clobbered back to the newest existing chat while the + // runtime's `terminal.list` catches up. Mirrors the optimistic-chat path. + const registerOptimisticTerminalSession = useCallback((args: { + sessionId: string; + laneId: string; + title?: string | null; + session?: ChatTerminalSession | null; + }) => { + const lane = lanesById[args.laneId] ?? null; + const optimistic: ChatTerminalSession = args.session + ? { ...args.session, terminalId: args.sessionId } + : { + terminalId: args.sessionId, + ptyId: null, + chatSessionId: null, + laneId: args.laneId, + laneName: lane?.name ?? args.laneId, + title: args.title?.trim() || "Claude Code", + toolType: "claude", + goal: null, + status: "running", + runtimeState: "running", + active: true, + startedAt: new Date().toISOString(), + endedAt: null, + exitCode: null, + pid: null, + resumeCommand: null, + resumeMetadata: null, + lastOutputPreview: null, + summary: null, + }; + optimisticTerminalSessionsRef.current.set(optimistic.terminalId, optimistic); + setTerminalSessions((current) => mergeOptimisticTerminalSessions(current, optimisticTerminalSessionsRef.current)); + }, [lanesById]); + const submitClaudePromptToTerminal = useCallback(async (terminal: ChatTerminalSession, text: string) => { const conn = connectionRef.current; const trimmed = text.trim(); @@ -6976,6 +7128,12 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath, pendingNewChatTitleRef.current = null; setDraftChatMode(false); activeTerminalSessionRef.current = normalizeChatTerminalSession(created.session); + registerOptimisticTerminalSession({ + sessionId: created.sessionId, + laneId: terminal.laneId, + title: terminal.title, + session: normalizeChatTerminalSession(created.session), + }); selectActiveSessionId(created.sessionId); showChatInfoAfterDraftCommit(); await refreshState(); @@ -6986,7 +7144,7 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath, .then(run); claudeTerminalSubmitQueueRef.current = queued; return await queued; - }, [addNotice, chatRowBudget, refreshState, refreshTerminalPreview, selectActiveSessionId, setDraftChatMode, showChatInfoAfterDraftCommit, terminalPaneWidth]); + }, [addNotice, chatRowBudget, refreshState, refreshTerminalPreview, registerOptimisticTerminalSession, selectActiveSessionId, setDraftChatMode, showChatInfoAfterDraftCommit, terminalPaneWidth]); const resumeClosedTerminalSession = useCallback(async (terminal: ChatTerminalSession): Promise => { const conn = connectionRef.current; @@ -7003,11 +7161,17 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath, pendingNewChatTitleRef.current = null; setDraftChatMode(false); activeTerminalSessionRef.current = normalizeChatTerminalSession(resumed.session); + registerOptimisticTerminalSession({ + sessionId: resumed.sessionId, + laneId: terminal.laneId, + title: terminal.title, + session: normalizeChatTerminalSession(resumed.session), + }); selectActiveSessionId(resumed.sessionId); showChatInfoAfterDraftCommit(); await refreshState(); return true; - }, [chatRowBudget, refreshState, selectActiveSessionId, setDraftChatMode, showChatInfoAfterDraftCommit, terminalPaneWidth]); + }, [chatRowBudget, refreshState, registerOptimisticTerminalSession, selectActiveSessionId, setDraftChatMode, showChatInfoAfterDraftCommit, terminalPaneWidth]); const startClaudeTerminalForPrompt = useCallback(async (text: string): Promise => { const conn = connectionRef.current; @@ -7045,11 +7209,21 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath, pendingNewChatTitleRef.current = null; setDraftChatMode(false); if (created.session) activeTerminalSessionRef.current = created.session; + registerOptimisticTerminalSession({ + sessionId: created.sessionId, + laneId, + title, + session: created.session, + }); + if (!claudeAutoNamingHintShownRef.current) { + claudeAutoNamingHintShownRef.current = true; + addNotice("Claude sessions auto-name in the background when enabled — toggle in ADE desktop → Settings → AI Features.", "info"); + } selectActiveSessionId(created.sessionId); showChatInfoAfterDraftCommit(); await refreshState(); return created.sessionId; - }, [addNotice, chatRowBudget, lanes, refreshState, selectActiveSessionId, setDraftChatMode, showChatInfoAfterDraftCommit, terminalPaneWidth]); + }, [addNotice, chatRowBudget, lanes, refreshState, registerOptimisticTerminalSession, selectActiveSessionId, setDraftChatMode, showChatInfoAfterDraftCommit, terminalPaneWidth]); const sendClaudeModelCommandToTerminal = useCallback(async (modelRef?: string | null): Promise => { const terminal = activeTerminalSessionRef.current; @@ -8329,7 +8503,7 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath, addNotice(result.message ?? "Desktop route unavailable; launched ADE.", "info"); for (let attempt = 0; attempt < 8; attempt += 1) { await delay(750); - const attached = await connectToAde({ project, forceEmbedded: false, socketPath, preferServiceRepair }).catch(() => null); + const attached = await connectToAde({ project, forceEmbedded: false, socketPath, preferServiceRepair, remote }).catch(() => null); if (!attached || attached.mode !== "attached") { await attached?.close().catch(() => {}); continue; @@ -8916,21 +9090,29 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath, return; } const terminalPrompt = promptTextForTerminal(text, promptAttachments); - const activeTerminal = activeTerminalSessionRef.current; + // In grid view the focused tile is the sole submit target. If it's a Claude + // terminal tile, route the prompt there; otherwise fall through to the chat + // path with that tile's session id. We never spawn a NEW terminal from a grid + // submit — that would replace the focused tile out from under the user. + const focusedSessionId = (gridViewActiveRef.current ? focusedSessionIdForMultiView(multiViewRef.current) : null); + const activeTerminal = gridViewActiveRef.current + ? (focusedSessionId + ? terminalSessionsRef.current.find((entry) => entry.terminalId === focusedSessionId) ?? null + : null) + : activeTerminalSessionRef.current; if (activeTerminal) { if (await submitClaudePromptToTerminal(activeTerminal, terminalPrompt)) { setSelectedMentions((prev) => prev.filter((mention) => !mention.attachment)); } return; } - if (modelStateRef.current.provider === "claude") { + if (!gridViewActiveRef.current && modelStateRef.current.provider === "claude") { const terminalId = await startClaudeTerminalForPrompt(terminalPrompt || " "); if (terminalId) { setSelectedMentions((prev) => prev.filter((mention) => !mention.attachment)); } return; } - const focusedSessionId = (gridViewActiveRef.current ? focusedSessionIdForMultiView(multiViewRef.current) : null); const sessionId = focusedSessionId ?? await ensureActiveSession(); if (!sessionId) { addNotice("No active lane is available for chat.", "error"); @@ -8997,10 +9179,11 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath, const cols = clampTerminalPaneCols(terminalPaneWidth); const terminalRows = claudeTerminalRowsForPane(chatRowBudget); const terminalPrompt = promptTextForTerminal(text, promptAttachments); - await startClaudeTerminalSession({ + const claudeTitle = pendingNewChatTitleRef.current ?? "Claude Code"; + const createdTerminal = await startClaudeTerminalSession({ connection: conn, laneId, - title: pendingNewChatTitleRef.current ?? "Claude Code", + title: claudeTitle, model: normalized.modelId ?? normalized.model, reasoningEffort: normalized.reasoningEffort, permissionMode: normalized.permissionMode, @@ -9008,6 +9191,16 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath, cols, rows: terminalRows, }); + registerOptimisticTerminalSession({ + sessionId: createdTerminal.sessionId, + laneId, + title: claudeTitle, + session: createdTerminal.session, + }); + if (!claudeAutoNamingHintShownRef.current) { + claudeAutoNamingHintShownRef.current = true; + addNotice("Claude sessions auto-name in the background when enabled — toggle in ADE desktop → Settings → AI Features.", "info"); + } launched = true; } else { const requestedTitle = pendingNewChatTitleRef.current; @@ -9056,7 +9249,7 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath, setError(message); addNotice(launched ? `Launched chat, but follow-up failed: ${message}` : message, "error"); } - }, [addNotice, chatRowBudget, lanes, refreshState, selectedMentions, setChatScrollOffset, setDraftChatMode, terminalPaneWidth]); + }, [addNotice, chatRowBudget, lanes, refreshState, registerOptimisticTerminalSession, selectedMentions, setChatScrollOffset, setDraftChatMode, terminalPaneWidth]); const insertMention = useCallback((suggestion: MentionSuggestion) => { const range = activeMention(prompt); @@ -9517,7 +9710,6 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath, setStreaming(false); setInterrupted(true); void interruptChat(conn, sessionId) - .then(() => addNotice("Interrupted chat.", "info")) .catch((err) => addNotice(err instanceof Error ? err.message : String(err), "error")); } else { addNotice("No active response to interrupt.", "info"); @@ -9844,6 +10036,34 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath, return chatSelectionPointFromVisibleRows(visibleChatSelectionRows, visibleRow, column, clampToChat); }, [addModeRows, chatRowBudget, chatWrapWidth, drawerOpen, goalBannerRows, visibleChatSelectionRows]); + // Map a transcript click to a collapsible work-group header id (tool calls / + // file changes), if the click landed on one. Mirrors chatPointFromMouse's + // viewport math but returns the row's expandableId instead of a text point so + // a plain click can toggle the group's collapse state. + const expandableGroupIdFromMouse = useCallback(( + x: number | null, + y: number | null, + ): string | null => { + if (x == null || y == null) return null; + const drawerWidth = resolveDrawerPaneWidth(columns, drawerOpen); + const textStartColumn = drawerWidth + 2; + const textEndColumn = textStartColumn + Math.max(1, chatWrapWidth) - 1; + const topRow = 3 + goalBannerRows + addModeRows; + const bottomRow = topRow + Math.max(1, chatRowBudget) - 1; + if (x < textStartColumn || x > textEndColumn || y < topRow || y > bottomRow) return null; + const visibleRow = Math.max(0, Math.min(y - topRow, Math.max(0, chatRowBudget - 1))); + return visibleChatSelectionRows[visibleRow]?.expandableId ?? null; + }, [addModeRows, chatRowBudget, chatWrapWidth, columns, drawerOpen, goalBannerRows, visibleChatSelectionRows]); + + const toggleExpandedLineId = useCallback((lineId: string) => { + setExpandedLineIds((prev) => { + const next = new Set(prev); + if (next.has(lineId)) next.delete(lineId); + else next.add(lineId); + return next; + }); + }, []); + const chatSelectionEdgeFromMouseY = useCallback((y: number | null): ChatSelectionEdgeDirection | null => { const topRow = 3 + goalBannerRows + addModeRows; return chatSelectionEdgeDirectionForMouseY({ @@ -9857,10 +10077,39 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath, useInput((input, key) => { if (attachedTerminalIdRef.current) { - if (input === "\x1d" || isTerminalControlToggle(input, key)) setAttachedTerminalId(null); + if (input === "\x1d" || isTerminalControlToggle(input, key)) { + setAttachedTerminalId(null); + // If we popped out of the grid to take control, drop back into it. + const returnSessionId = controlReturnToGridRef.current; + controlReturnToGridRef.current = null; + if (returnSessionId && multiViewRef.current) { + const idx = multiViewRef.current.tiles.findIndex((tile) => tile.sessionId === returnSessionId); + if (idx >= 0) focusMultiViewTile(idx); + } + } return; } if (isTerminalControlToggle(input, key)) { + // Grid view: Ctrl+T pops the focused Claude terminal tile out to single view + // and enters control; exiting control returns to the grid (handled above). + if (gridViewActiveRef.current) { + const focusedId = focusedSessionIdForMultiView(multiViewRef.current); + const tile = focusedId + ? multiViewRef.current?.tiles.find((entry) => entry.sessionId === focusedId) ?? null + : null; + const terminal = focusedId + ? terminalSessionsRef.current.find((entry) => entry.terminalId === focusedId) ?? null + : null; + if (tile && terminal && terminal.status === "running" && terminalSessionProvider(terminal) === "claude") { + controlReturnToGridRef.current = focusedId; + setGridView(false); + selectActiveLaneId(tile.laneId); + selectActiveSessionId(focusedId); + focusChat(); + setAttachedTerminalId(focusedId); + } + return; + } const terminal = activeTerminalSession ?? activeTerminalSessionRef.current; if ( terminal?.terminalId === activeSessionIdRef.current @@ -10052,6 +10301,20 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath, } return; } + // A plain click on a collapsible work-group header (▸ Tool calls (N) / + // ▸ Files changed (N)) toggles it open/closed instead of starting a text + // selection. Shift-click still extends a selection across the header. + if (!mouse.shift) { + const groupId = expandableGroupIdFromMouse(mouse.x, mouse.y); + if (groupId) { + stopChatSelectionEdgeScroll(); + chatSelectionAnchorRef.current = null; + if (activeSelection) updateChatMouseSelection(null); + focusChat(); + toggleExpandedLineId(groupId); + return; + } + } stopChatSelectionEdgeScroll(); const point = chatPointFromMouse(mouse.x, mouse.y, false); if (point) { @@ -10548,6 +10811,46 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath, return; } } + if ( + pane === "details" + && rightOpen + && rightPane.kind === "model-picker" + && key.tab + && !key.ctrl + && !key.meta + && !rightPane.searchMode + && rightPane.footerFocus == null + ) { + const picker = rightPane; + const layout = buildModelPickerLayout({ + models, + catalog: modelCatalogRef.current ?? modelCatalog, + favorites: modelPickerFavorites, + recents: modelPickerRecents, + activeModelId: modelState.modelId, + activeReasoningEffort: modelState.reasoningEffort, + aiStatus, + 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 nextTabKey = nextModelPickerProviderTabKey({ + providerTabs: layout.providerTabs, + providerTabIndex: layout.providerTabIndex, + delta: key.shift ? -1 : 1, + }); + if (nextTabKey) { + setRightPane({ ...picker, providerTabKey: nextTabKey, focusedIndex: 0, footerFocus: null, railFocused: false }); + } else { + setRightPane({ ...picker, railFocused: picker.railFocused !== true }); + } + return; + } const keybindingContext = pane === "details" ? rightPane.kind === "help" ? "Help" : "Select" : pane === "drawer" ? "Tabs" : "Chat"; @@ -10870,7 +11173,6 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath, setStreaming(false); setInterrupted(true); void interruptChat(conn, sessionId) - .then(() => addNotice("Interrupted chat.", "info")) .catch((err) => addNotice(err instanceof Error ? err.message : String(err), "error")); return; } @@ -10891,7 +11193,6 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath, setStreaming(false); setInterrupted(true); void interruptChat(conn, sessionId) - .then(() => addNotice("Interrupted chat.", "info")) .catch((err) => addNotice(err instanceof Error ? err.message : String(err), "error")); return; } @@ -11320,10 +11621,13 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath, } 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]; - if (nextTab) { - setRightPane({ ...picker, providerTabKey: nextTab.key, focusedIndex: 0, footerFocus: null, railFocused: false }); + const nextTabKey = nextModelPickerProviderTabKey({ + providerTabs: layout.providerTabs, + providerTabIndex: layout.providerTabIndex, + delta, + }); + if (nextTabKey) { + setRightPane({ ...picker, providerTabKey: nextTabKey, focusedIndex: 0, footerFocus: null, railFocused: false }); } return; } @@ -12395,6 +12699,24 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath, zIndex: 4, }); }); + geometry.providerTabs.forEach(({ id, tabKey, rect }) => { + addTarget({ + id, + rect, + onClick: () => { + setRightPane({ + ...picker, + providerTabKey: tabKey, + focusedIndex: 0, + footerFocus: null, + railFocused: false, + query: "", + searchMode: false, + }); + }, + zIndex: 6, + }); + }); geometry.favorites.forEach(({ modelId, rect }) => { addTarget({ id: `right:model-picker:favorite:${modelId}`, @@ -12820,15 +13142,6 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath, }; }, [addModeRows, addNotice, chatRowBudget, drawerPaneWidth, goalBannerRows, visibleChatSelectionRows]); - if (error && !connection) { - return ( - - ade-code failed to start - {error} - - ); - } - // The multi-chat grid draws its own bottom border, so it must span the REAL // height of the center flex row — chatRowBudget is 2 rows short of it. The // fixed chrome around the center area is header (2) + prompt box @@ -12850,6 +13163,43 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath, - (modeChangeNotice ? 3 : 0), ); + // Grid view: size each tiled Claude terminal's PTY to its tile so output reflows + // to fit (matches the single-view resize). Single-tile fallback (the grid is too + // small to split) is left to the single-view resize effect. + useEffect(() => { + const conn = connectionRef.current; + if (!conn || !gridViewActive || !multiView) return; + const tiles = multiView.tiles.slice(0, 6); + if (!canRenderMultiChatGrid(tiles.length, chatWrapWidth, gridRowBudget)) return; + const rects = computeTileRects(asTileCount(tiles.length), chatWrapWidth, gridRowBudget); + tiles.forEach((tile, index) => { + if (!terminalSessionsRef.current.some((entry) => entry.terminalId === tile.sessionId)) return; + const rect = rects[index] ?? rects[0]; + if (!rect) return; + const cols = clampTerminalPaneCols(Math.max(1, rect.w - 2)); + const rows = claudeTerminalRowsForPane(Math.max(1, rect.h - 3)); + void resizeTerminal(conn, tile.sessionId, cols, rows).catch(() => undefined); + }); + }, [gridViewActive, multiView, chatWrapWidth, gridRowBudget]); + + // Whether the grid's focused tile is a running Claude terminal — drives the + // footer "^t control (single)" hint so the unavailable-in-grid control is clear. + const gridFocusedTileIsClaudeTerminal = useMemo(() => { + if (!gridViewActive || !multiView) return false; + const focusedId = multiView.tiles[multiView.focusedIndex]?.sessionId ?? null; + const terminal = focusedId ? terminalSessionById[focusedId] ?? null : null; + return Boolean(terminal && terminal.status === "running" && terminalSessionProvider(terminal) === "claude"); + }, [gridViewActive, multiView, terminalSessionById]); + + if (error && !connection) { + return ( + + ade-code failed to start + {error} + + ); + } + // Footer mini-map: show tile state when multi-view is open, or just the // transient notice when we have something to flash but no grid yet. const footerMultiViewMap = (() => { @@ -12917,23 +13267,32 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath, lanesById={lanesById} sessionBySessionId={sessionBySessionId} eventsBySessionId={eventsBySessionId} - notices={displayNotices} + notices={notices} streamingBySessionId={streamingBySessionId} interruptedBySessionId={interruptedBySessionId} scrollBySessionId={scrollBySessionId} selectionBySessionId={selectionBySessionId} expandedLineIds={expandedLineIds} + terminalSessionById={terminalSessionById} + terminalPreviewById={terminalPreviewById} + terminalLiveChunksById={terminalLiveChunks} + terminalScrollBySessionId={terminalScrollBySessionId} + attachedTerminalId={attachedTerminalId} onFocusTile={focusMultiViewTile} onRemoveTile={removeMultiViewTile} /> ) : activeTerminalSession ? ( diff --git a/apps/ade-cli/src/tuiClient/cli.tsx b/apps/ade-cli/src/tuiClient/cli.tsx index 234a888b1..36b69cc4c 100644 --- a/apps/ade-cli/src/tuiClient/cli.tsx +++ b/apps/ade-cli/src/tuiClient/cli.tsx @@ -8,9 +8,11 @@ type CliOptions = { printState: boolean; forceEmbedded: boolean; requireSocket: boolean; + remote: boolean; projectRoot: string | null; workspaceRoot: string | null; laneHint: string | null; + sessionHint: string | null; socketPath: string | null; preferServiceRepair: boolean; }; @@ -29,9 +31,11 @@ export function parseArgs(argv: string[]): CliOptions { printState: false, forceEmbedded: false, requireSocket: false, + remote: false, projectRoot: null, workspaceRoot: null, laneHint: null, + sessionHint: null, socketPath: null, preferServiceRepair: false, }; @@ -41,6 +45,7 @@ export function parseArgs(argv: string[]): CliOptions { else if (arg === "--print-state") options.printState = true; else if (arg === "--embedded") options.forceEmbedded = true; else if (arg === "--require-socket") options.requireSocket = true; + else if (arg === "--remote") options.remote = true; else if (arg === "--prefer-service-repair") options.preferServiceRepair = true; else if (arg === "--project-root") { options.projectRoot = readRequiredFlagValue(argv, i, arg); @@ -51,6 +56,9 @@ export function parseArgs(argv: string[]): CliOptions { } else if (arg === "--lane") { options.laneHint = readRequiredFlagValue(argv, i, arg); i += 1; + } else if (arg === "--session" || arg === "--chat") { + options.sessionHint = readRequiredFlagValue(argv, i, arg); + i += 1; } else if (arg === "--socket") { options.socketPath = readRequiredFlagValue(argv, i, arg); i += 1; @@ -68,6 +76,7 @@ Terminal-native ADE Work chat. Usage: ade code [--project-root ] [--workspace-root ] [--lane ] [--socket ] + ade code remote [project|session] ade code --embedded ade code --require-socket ade code --print-state @@ -117,6 +126,8 @@ async function printState(options: CliOptions): Promise { projectRoot: options.projectRoot, workspaceRoot: options.workspaceRoot, laneHint: options.laneHint, + sessionHint: options.sessionHint, + remote: options.remote, }); const connection = await connectToAde({ project, @@ -124,6 +135,7 @@ async function printState(options: CliOptions): Promise { requireSocket: options.requireSocket, socketPath: options.socketPath, preferServiceRepair: options.preferServiceRepair, + remote: options.remote, }); try { const lanes = await listLanes(connection); @@ -158,6 +170,8 @@ export async function runAdeCodeCli(argv: string[] = process.argv.slice(2)): Pro projectRoot: options.projectRoot, workspaceRoot: options.workspaceRoot, laneHint: options.laneHint, + sessionHint: options.sessionHint, + remote: options.remote, }); const instance = render( , { exitOnCtrlC: false }, ); diff --git a/apps/ade-cli/src/tuiClient/components/ChatView.tsx b/apps/ade-cli/src/tuiClient/components/ChatView.tsx index bf0979ffc..96ce6ca79 100644 --- a/apps/ade-cli/src/tuiClient/components/ChatView.tsx +++ b/apps/ade-cli/src/tuiClient/components/ChatView.tsx @@ -42,6 +42,9 @@ const BLANK_ROW_TEXT = " "; // 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 = "◐"; +// Shared empty set so callers that don't track group expansion (plain-text +// export, scroll-height probe) don't each allocate one per call. +const EMPTY_EXPANDED_GROUP_IDS: Set = new Set(); type RenderedChatRow = { id: string; @@ -54,6 +57,12 @@ type RenderedChatRow = { rail?: string | null; runs?: InlineRun[]; sourceRowIndex?: number | null; + /** + * When set, this row is a clickable work-group header (tool calls / file + * changes). The transcript click handler maps a click on this row back to + * this id and toggles it in `expandedLineIds` to collapse/expand the group. + */ + expandableGroupId?: string; }; export type ChatTextSelection = { @@ -66,8 +75,20 @@ export type ChatTextSelection = { export type ChatVisibleSelectionRow = { sourceRow: number | null; text: string; + /** Set when this visible row is a clickable work-group header (see + * RenderedChatRow.expandableGroupId). Lets the click handler toggle the + * group without re-deriving block layout. */ + expandableId?: string | null; }; +// Expansion keys for collapsible work-log groups (tool calls / file changes) +// live in the same `expandedLineIds` set as failure expansions. The prefix +// keeps a group key from colliding with a raw event line id used elsewhere. +export const WORK_GROUP_EXPAND_PREFIX = "workgroup:"; +export function workGroupExpandKey(blockId: string): string { + return `${WORK_GROUP_EXPAND_PREFIX}${blockId}`; +} + function textWidth(value: string): number { return [...value].length; } @@ -686,36 +707,73 @@ function visibleEntries(entries: T[]): { shown: T[]; remaining: number } { return { shown, remaining: entries.length - shown.length }; } -// No group header row: the desktop work log just stacks the per-call lines, -// and the bottom-of-transcript "model working" indicator already conveys live -// state — repeated "Tool calls (N)" headers only added clutter between the -// assistant-text fragments of a turn. +// Build the indented per-call line shown when a tool group is expanded. ChatRow +// truncates to the pane width so a long arg can never wrap onto a second row. +function toolCallEntryRow( + blockId: string, + entry: ToolCallEntry, + spinFrame: string, +): RenderedChatRow { + const glyph = statusGlyph(entry.status, spinFrame); + const dur = formatDurationMs(entry.durationMs); + const arg = entry.arg ? truncateLongLine(entry.arg) : ""; + const runs: InlineRun[] = [ + { text: " " }, + { text: glyph, color: WORK_STATUS_COLOR[entry.status] }, + { text: ` ${entry.tool}`, color: theme.color.t1 }, + ]; + if (arg) runs.push({ text: ` ${arg}`, color: theme.color.t3 }); + if (dur) runs.push({ text: ` ${dur}`, color: theme.color.t4 }); + return { + id: `${blockId}:${entry.itemId}`, + tone: "work", + text: runsPlainText(runs), + runs, + rail: null, + }; +} + +// Tool calls collapse to ONE clickable header row by default — desktop/mobile +// work-log parity: `▸ Tool calls (N) ✓ shell `. The collapsed +// preview shows the most recent call so live progress stays visible without the +// turn's calls stacking into a tall block. Clicking the header (see the +// transcript click handler in app.tsx, keyed off `expandableGroupId`) flips its +// id in `expandedLineIds`, stacking every call one line each under a `▾` header. function toolCallsGroupRows( block: Extract, spinFrame: string, + expandedGroupIds: Set, ): RenderedChatRow[] { if (!block.entries.length) return []; - const out: RenderedChatRow[] = []; - // Every tool call renders as one stacked line (desktop work-log parity); - // ChatRow truncates to the pane width so a long arg can never wrap. + const expandKey = workGroupExpandKey(block.id); + const expanded = expandedGroupIds.has(expandKey); + const total = block.entries.length; + + const headerRuns: InlineRun[] = [ + { text: expanded ? "▾ " : "▸ ", color: theme.color.t3 }, + { text: "Tool calls", color: theme.color.t2, bold: true }, + { text: ` (${total})`, color: theme.color.t4 }, + ]; + if (!expanded) { + const latest = block.entries[block.entries.length - 1]!; + const glyph = statusGlyph(latest.status, spinFrame); + const arg = latest.arg ? truncateLongLine(latest.arg) : ""; + headerRuns.push({ text: " " }); + headerRuns.push({ text: glyph, color: WORK_STATUS_COLOR[latest.status] }); + headerRuns.push({ text: ` ${latest.tool}`, color: theme.color.t1 }); + if (arg) headerRuns.push({ text: ` ${arg}`, color: theme.color.t3 }); + } + const out: RenderedChatRow[] = [{ + id: block.id, + tone: "work", + text: runsPlainText(headerRuns), + runs: headerRuns, + rail: null, + expandableGroupId: expandKey, + }]; + if (!expanded) return out; for (const entry of block.entries) { - const glyph = statusGlyph(entry.status, spinFrame); - const dur = formatDurationMs(entry.durationMs); - const arg = entry.arg ? truncateLongLine(entry.arg) : ""; - const runs: InlineRun[] = [ - { text: " " }, - { text: glyph, color: WORK_STATUS_COLOR[entry.status] }, - { text: ` ${entry.tool}`, color: theme.color.t1 }, - ]; - if (arg) runs.push({ text: ` ${arg}`, color: theme.color.t3 }); - if (dur) runs.push({ text: ` ${dur}`, color: theme.color.t4 }); - out.push({ - id: `${block.id}:${entry.itemId}`, - tone: "work", - text: ` ${glyph} ${entry.tool}${arg ? ` ${arg}` : ""}${dur ? ` ${dur}` : ""}`, - runs, - rail: null, - }); + out.push(toolCallEntryRow(block.id, entry, spinFrame)); } return out; } @@ -730,41 +788,85 @@ function fileBadgeFor(entry: FileChangeEntry): { label: string; color: string } return { label, color }; } -// Header-less for the same reason as toolCallsGroupRows — the badge/stats -// file rows carry all the signal on their own. +function fileChangeEntryRow( + blockId: string, + entry: FileChangeEntry, + pathWidth: number, + spinFrame: string, + indent: string, +): RenderedChatRow { + const badge = fileBadgeFor(entry); + const glyph = statusGlyph(entry.status, spinFrame); + const trimmedPath = compactPath(entry.path, pathWidth); + const stats = entry.deleted ? "Deleted" : `+${entry.additions} −${entry.deletions}`; + const statsColor = entry.deleted ? theme.color.error : theme.color.t4; + const runs: InlineRun[] = [ + { text: indent }, + { text: glyph, color: WORK_STATUS_COLOR[entry.status] }, + { text: " " }, + { text: badge.label.padEnd(3, " "), color: badge.color, bold: true }, + { text: " " }, + { text: trimmedPath, color: theme.color.t1 }, + { text: " " }, + { text: stats, color: statsColor }, + ]; + return { + id: `${blockId}:${entry.itemId}`, + tone: "work", + text: runsPlainText(runs), + runs, + rail: null, + }; +} + +// File changes collapse to ONE clickable header row by default, exactly like +// toolCallsGroupRows — `▸ Files changed (N) M src/foo.ts +12 −3` previewing +// the most recent edit. Clicking the header expands every edit one line each. function filesChangedGroupRows( block: Extract, width: number, spinFrame: string, + expandedGroupIds: Set, ): RenderedChatRow[] { if (!block.entries.length) return []; - const out: RenderedChatRow[] = []; - const pathWidth = Math.max(20, width - 18); + const expandKey = workGroupExpandKey(block.id); + const expanded = expandedGroupIds.has(expandKey); + const total = block.entries.length; + const collapsedPathWidth = Math.max(20, width - 26); + const expandedPathWidth = Math.max(20, width - 18); + + const headerRuns: InlineRun[] = [ + { text: expanded ? "▾ " : "▸ ", color: theme.color.t3 }, + { text: "Files changed", color: theme.color.t2, bold: true }, + { text: ` (${total})`, color: theme.color.t4 }, + ]; + if (!expanded) { + const latest = block.entries[block.entries.length - 1]!; + const badge = fileBadgeFor(latest); + const glyph = statusGlyph(latest.status, spinFrame); + const trimmedPath = compactPath(latest.path, collapsedPathWidth); + const stats = latest.deleted ? "Deleted" : `+${latest.additions} −${latest.deletions}`; + const statsColor = latest.deleted ? theme.color.error : theme.color.t4; + headerRuns.push({ text: " " }); + headerRuns.push({ text: glyph, color: WORK_STATUS_COLOR[latest.status] }); + headerRuns.push({ text: " " }); + headerRuns.push({ text: badge.label.padEnd(3, " "), color: badge.color, bold: true }); + headerRuns.push({ text: " " }); + headerRuns.push({ text: trimmedPath, color: theme.color.t1 }); + headerRuns.push({ text: " " }); + headerRuns.push({ text: stats, color: statsColor }); + } + const out: RenderedChatRow[] = [{ + id: block.id, + tone: "work", + text: runsPlainText(headerRuns), + runs: headerRuns, + rail: null, + expandableGroupId: expandKey, + }]; + if (!expanded) return out; for (const entry of block.entries) { - const badge = fileBadgeFor(entry); - const glyph = statusGlyph(entry.status, spinFrame); - const trimmedPath = compactPath(entry.path, pathWidth); - const stats = entry.deleted - ? "Deleted" - : `+${entry.additions} −${entry.deletions}`; - const statsColor = entry.deleted ? theme.color.error : theme.color.t4; - const runs: InlineRun[] = [ - { text: " " }, - { text: glyph, color: WORK_STATUS_COLOR[entry.status] }, - { text: " " }, - { text: badge.label.padEnd(3, " "), color: badge.color, bold: true }, - { text: " " }, - { text: trimmedPath, color: theme.color.t1 }, - { text: " " }, - { text: stats, color: statsColor }, - ]; - out.push({ - id: `${block.id}:${entry.itemId}`, - tone: "work", - text: ` ${glyph} ${badge.label} ${trimmedPath} ${stats}`, - runs, - rail: null, - }); + out.push(fileChangeEntryRow(block.id, entry, expandedPathWidth, spinFrame, " ")); } return out; } @@ -937,7 +1039,7 @@ function modelInterruptedRows(): RenderedChatRow[] { id: "model-interrupted", tone: "work", text: "Interrupted · chat to continue", - color: theme.color.mutedFg, + color: theme.color.error, bold: true, rail: null, }]; @@ -1000,6 +1102,7 @@ function rowsForBlock( width: number, brailleFrame: string, spinFrame: string, + expandedGroupIds: Set, ): RenderedChatRow[] { switch (block.kind) { case "user-bubble": @@ -1010,9 +1113,9 @@ function rowsForBlock( case "assistant-text": return passthroughRows(block.line, width); case "tool-calls-group": - return toolCallsGroupRows(block, spinFrame); + return toolCallsGroupRows(block, spinFrame, expandedGroupIds); case "files-changed-group": - return filesChangedGroupRows(block, width, spinFrame); + return filesChangedGroupRows(block, width, spinFrame, expandedGroupIds); case "runtime-activity": return runtimeActivityRows(block, spinFrame); case "reasoning": @@ -1033,6 +1136,7 @@ function rowsForBlocks( width: number, brailleFrame: string, spinFrame: string, + expandedGroupIds: Set = EMPTY_EXPANDED_GROUP_IDS, ): RenderedChatRow[] { const rows: RenderedChatRow[] = []; let prevKind: AggregatedBlock["kind"] | null = null; @@ -1040,7 +1144,7 @@ function rowsForBlocks( if (prevKind && shouldInsertSpacer(prevKind, block.kind)) { rows.push(spacerRow(`${block.id}:spacer`)); } - rows.push(...rowsForBlock(block, width, brailleFrame, spinFrame)); + rows.push(...rowsForBlock(block, width, brailleFrame, spinFrame, expandedGroupIds)); prevKind = block.kind; } return rows; @@ -1277,6 +1381,7 @@ function selectableRowsForBlocks({ spinFrame = "◐", dotPulse = "", showWorkingIndicator = true, + expandedGroupIds = EMPTY_EXPANDED_GROUP_IDS, }: { blocks: AggregatedBlock[]; width?: number; @@ -1286,9 +1391,10 @@ function selectableRowsForBlocks({ spinFrame?: string; dotPulse?: string; showWorkingIndicator?: boolean; + expandedGroupIds?: Set; }): RenderedChatRow[] { const innerWidth = Math.max(24, width - 4); - const baseRows = rowsForBlocks(blocks, innerWidth, brailleFrame, spinFrame); + const baseRows = rowsForBlocks(blocks, innerWidth, brailleFrame, spinFrame, expandedGroupIds); if (streaming) return [...baseRows, ...activeTurnRows(dotPulse, showWorkingIndicator)]; if (interrupted) return [...baseRows, ...modelInterruptedRows()]; return baseRows; @@ -1306,6 +1412,7 @@ function visibleRowsForBlocks({ spinFrame = "◐", dotPulse = "", showWorkingIndicator = true, + expandedGroupIds = EMPTY_EXPANDED_GROUP_IDS, }: { blocks: AggregatedBlock[]; maxRows?: number; @@ -1318,6 +1425,7 @@ function visibleRowsForBlocks({ spinFrame?: string; dotPulse?: string; showWorkingIndicator?: boolean; + expandedGroupIds?: Set; }): RenderedChatRow[] { return sliceRows( selectableRowsForBlocks({ @@ -1329,6 +1437,7 @@ function visibleRowsForBlocks({ spinFrame, dotPulse, showWorkingIndicator, + expandedGroupIds, }), maxRows, scrollOffsetRows, @@ -1376,6 +1485,7 @@ export function renderChatVisibleRowTexts({ streaming, interrupted, showWorkingIndicator, + expandedGroupIds: expandedLineIds, }).map(renderedRowText); } @@ -1421,9 +1531,11 @@ export function renderChatVisibleSelectionRows({ streaming, interrupted, showWorkingIndicator, + expandedGroupIds: expandedLineIds, }).map((row) => ({ sourceRow: typeof row.sourceRowIndex === "number" ? row.sourceRowIndex : null, text: renderedRowText(row), + expandableId: row.expandableGroupId ?? null, })); } @@ -1460,6 +1572,7 @@ export function renderChatSelectableRowTexts({ streaming, interrupted, showWorkingIndicator, + expandedGroupIds: expandedLineIds, }).map(renderedRowText); } @@ -1503,7 +1616,7 @@ export function renderChatTranscriptPlainText({ expandedLineIds, }); const innerWidth = Math.max(24, width - 4); - return sliceRows(rowsForBlocks(blocks, innerWidth, "·", "◐"), maxRows, scrollOffsetRows) + return sliceRows(rowsForBlocks(blocks, innerWidth, "·", "◐", expandedLineIds), maxRows, scrollOffsetRows) .filter((row) => !isPaginationIndicatorRow(row)) .map(renderedRowText) .join("\n") @@ -1553,7 +1666,7 @@ export function computeChatScrollMaxOffset({ let statusRows = 0; if (streaming) statusRows = activeTurnRows("", showWorkingIndicator).length; else if (interrupted) statusRows = modelInterruptedRows().length; - const rowCount = rowsForBlocks(blocks, innerWidth, "·", "◐").length + statusRows; + const rowCount = rowsForBlocks(blocks, innerWidth, "·", "◐", expandedLineIds).length + statusRows; return maxScrollOffsetForRows(rowCount, maxRows); } @@ -1643,13 +1756,13 @@ export function ChatView({ // 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) + () => rowsForBlocks(historicalBlocks, rowInnerWidth, STATIC_SPIN_FRAME, STATIC_SPIN_FRAME, expandedLineIds) .map((row, index) => ({ ...row, sourceRowIndex: index })), - [historicalBlocks, rowInnerWidth], + [historicalBlocks, rowInnerWidth, expandedLineIds], ); const rows = useMemo(() => { const tailRows = tailBlocks.length - ? rowsForBlocks(tailBlocks, rowInnerWidth, brailleFrame, spinFrame) + ? rowsForBlocks(tailBlocks, rowInnerWidth, brailleFrame, spinFrame, expandedLineIds) : []; let baseRows: RenderedChatRow[]; if (historicalBlocks.length && tailBlocks.length) { @@ -1677,7 +1790,7 @@ export function ChatView({ withSuffix = [...baseRows, ...modelInterruptedRows()]; } return sliceRows(withSuffix, bodyRows, scrollOffsetRows, unseenMessageCount, olderHistory === "loading"); - }, [historicalRows, historicalBlocks, tailBlocks, rowInnerWidth, brailleFrame, spinFrame, dotPulse, shimmerTick, streaming, interrupted, showWorkingIndicator, bodyRows, scrollOffsetRows, unseenMessageCount, olderHistory]); + }, [historicalRows, historicalBlocks, tailBlocks, rowInnerWidth, brailleFrame, spinFrame, dotPulse, shimmerTick, streaming, interrupted, showWorkingIndicator, bodyRows, scrollOffsetRows, unseenMessageCount, olderHistory, expandedLineIds]); const isEmpty = !hasConversationContent(blocks) && !streaming && !interrupted; let content: React.ReactNode; if (isEmpty && tileMode) { diff --git a/apps/ade-cli/src/tuiClient/components/FooterControls.tsx b/apps/ade-cli/src/tuiClient/components/FooterControls.tsx index 871b3d521..00748fd85 100644 --- a/apps/ade-cli/src/tuiClient/components/FooterControls.tsx +++ b/apps/ade-cli/src/tuiClient/components/FooterControls.tsx @@ -137,6 +137,7 @@ export function FooterControls({ planMode, terminalControlAvailable, terminalControlActive, + gridTerminalControlHint, multiViewActive, multiViewMap, }: { @@ -158,6 +159,8 @@ export function FooterControls({ planMode?: boolean; terminalControlAvailable?: boolean; terminalControlActive?: boolean; + /** Grid view, focused tile is a running Claude terminal: Ctrl+T opens it in single view to control. */ + gridTerminalControlHint?: boolean; multiViewActive?: boolean; multiViewMap?: { count: number; focusedIndex: number; notice?: string | null } | null; }) { @@ -356,6 +359,12 @@ export function FooterControls({ {" "} + {gridTerminalControlHint ? ( + <> + {" "} + + + ) : null} ) : null} {" "} diff --git a/apps/ade-cli/src/tuiClient/components/ModelPicker/ModelPickerPane.tsx b/apps/ade-cli/src/tuiClient/components/ModelPicker/ModelPickerPane.tsx index 3d3011b57..617705bd4 100644 --- a/apps/ade-cli/src/tuiClient/components/ModelPicker/ModelPickerPane.tsx +++ b/apps/ade-cli/src/tuiClient/components/ModelPicker/ModelPickerPane.tsx @@ -16,6 +16,7 @@ import { isSearching, modelEntryHeightForState, modelListRowsForState, + providerTabSegments, RAIL_WIDTH, RAIL_TO_LIST_GAP, rowWindow, @@ -304,48 +305,22 @@ function SubProviderTabs({ if (tabs.length <= 1) return null; const safe = Math.max(0, Math.min(selectedIndex, tabs.length - 1)); if (!tabs[safe]) return null; - const tabMax = Math.max(8, Math.min(16, Math.floor(width / 2))); - const segments = tabs.map((tab, index) => { - const activeTab = index === safe; - const label = endTruncate(titleCaseProvider(tab.label), tabMax); - return activeTab ? `[${label}]` : ` ${label} `; - }); - let start = 0; - if (segments.join("").length > width) { - start = safe; - while (start > 0 && segments.slice(start - 1, safe + 1).join("").length < Math.floor(width * 0.7)) { - start -= 1; - } - } - let used = 0; - const visible: Array<{ text: string; active: boolean; index: number }> = []; - if (start > 0 && width > 2) { - visible.push({ text: "‹ ", active: false, index: -1 }); - used += 2; - } - for (let index = start; index < segments.length; index += 1) { - const text = segments[index] ?? ""; - if (!text) continue; - const nextUsed = used + text.length + (visible.length ? 1 : 0); - if (nextUsed > width - (index < segments.length - 1 ? 1 : 0)) { - if (used < width) visible.push({ text: "…", active: false, index: -2 }); - break; - } - if (visible.length && used < width) { - visible.push({ text: " ", active: false, index: -3 }); - used += 1; - } - visible.push({ text, active: index === safe, index }); - used += text.length; - } + const hoveredId = useHoveredHitId(); + const visible = providerTabSegments(tabs, safe, width); return ( {visible.map((segment, index) => ( {segment.text} @@ -706,8 +681,8 @@ export function ModelPickerPane({ ["←→", "rail / list"], ["↑↓", "move"], ["↵", "pick"], - ["[ ]", "tabs"], - ["tab", "rail"], + ...(state.providerTabs.length > 1 ? ([["[ ]", "tabs"]] as Array<[string, string]>) : []), + ["tab", state.providerTabs.length > 1 ? "tabs" : "rail"], ["f", "fav"], ["/", "search"], ["esc", "close"], diff --git a/apps/ade-cli/src/tuiClient/components/ModelPicker/modelPickerGeometry.test.ts b/apps/ade-cli/src/tuiClient/components/ModelPicker/modelPickerGeometry.test.ts index 14f46142c..327c3ed7a 100644 --- a/apps/ade-cli/src/tuiClient/components/ModelPicker/modelPickerGeometry.test.ts +++ b/apps/ade-cli/src/tuiClient/components/ModelPicker/modelPickerGeometry.test.ts @@ -11,6 +11,7 @@ import { hasSubProviderSelector, isSearching, modelPickerGeometry, + providerTabSegments, rowWindow, settingsChipWidth, } from "./modelPickerGeometry"; @@ -229,6 +230,20 @@ describe("modelPickerGeometry — rail vs search mode", () => { }); describe("modelPickerGeometry — sub-provider selector", () => { + it("computes visible tab segments with stable offsets", () => { + const tabs: ModelPickerProviderTab[] = [ + { key: "alpha", label: "Alpha" }, + { key: "beta", label: "Beta" }, + { key: "gamma", label: "Gamma" }, + ]; + + expect(providerTabSegments(tabs, 1, 24).filter((segment) => segment.tabKey)).toEqual([ + { index: 0, tabKey: "alpha", text: " Alpha ", active: false, x: 0, w: 7 }, + { index: 1, tabKey: "beta", text: "[Beta]", active: true, x: 8, w: 6 }, + { index: 2, tabKey: "gamma", text: " Gamma ", active: false, x: 15, w: 7 }, + ]); + }); + 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" })] }); @@ -251,6 +266,31 @@ describe("modelPickerGeometry — sub-provider selector", () => { const listTopWithSelector = regionTop + 2; expect(at(g.entries, 0).rect.y).toBe(listTopWithSelector); }); + + it("registers clickable rects for visible provider tabs", () => { + const tabs: ModelPickerProviderTab[] = [ + { key: "alpha", label: "Alpha" }, + { key: "beta", label: "Beta" }, + ]; + const state = makeState({ providerTabs: tabs, providerTabIndex: 0, entries: [entry({ modelId: "a" })] }); + const g = geo(state); + const listLeft = PANE_LEFT + RAIL_WIDTH + RAIL_TO_LIST_GAP; + + expect(g.providerTabs).toEqual([ + { + id: "right:model-picker:provider-tab:0", + index: 0, + tabKey: "alpha", + rect: { x: listLeft, y: PANE_TOP + 1 + 3, w: 7, h: 1 }, + }, + { + id: "right:model-picker:provider-tab:1", + index: 1, + tabKey: "beta", + rect: { x: listLeft + 8, y: PANE_TOP + 1 + 3, w: 6, h: 1 }, + }, + ]); + }); }); describe("modelPickerGeometry — settings footer + apply", () => { diff --git a/apps/ade-cli/src/tuiClient/components/ModelPicker/modelPickerGeometry.ts b/apps/ade-cli/src/tuiClient/components/ModelPicker/modelPickerGeometry.ts index af8931bf8..111e7a8e7 100644 --- a/apps/ade-cli/src/tuiClient/components/ModelPicker/modelPickerGeometry.ts +++ b/apps/ade-cli/src/tuiClient/components/ModelPicker/modelPickerGeometry.ts @@ -1,4 +1,4 @@ -import type { ModelPickerState } from "./types"; +import type { ModelPickerProviderTab, ModelPickerState } from "./types"; /** * Screen rectangle in 1-based terminal cells. Structurally identical to @@ -79,6 +79,111 @@ export function hasSubProviderSelector(state: ModelPickerState): boolean { return !isSearching(state) && state.providerTabs.length > 1; } +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 normalizeProviderToken(value: string | null | undefined): string { + return (value ?? "") + .trim() + .toLowerCase() + .replace(/[^a-z0-9]+/g, ""); +} + +const PROVIDER_TAB_LABELS: Record = { + anthropic: "Anthropic", + claude: "Anthropic", + openai: "OpenAI", + codex: "OpenAI", + google: "Google", + gemini: "Google", + deepseek: "DeepSeek", + mistral: "Mistral", + xai: "xAI", + grok: "xAI", + groq: "Groq", + together: "Together", + openrouter: "OpenRouter", + opencode: "OpenCode", + droid: "Droid", + factory: "Droid", + cursor: "Cursor", + kimi: "Kimi", + moonshot: "Kimi", + ollama: "Ollama", + lmstudio: "LM Studio", +}; + +export function formatProviderTabLabel(value: string): string { + const trimmed = value.trim(); + if (!trimmed) return ""; + const known = PROVIDER_TAB_LABELS[normalizeProviderToken(trimmed)]; + if (known) return known; + return trimmed + .replace(/\b\w/g, (ch) => ch.toUpperCase()) + .replace(/\bAi\b/g, "AI"); +} + +export type ProviderTabSegment = { + index: number; + tabKey: string | null; + text: string; + active: boolean; + x: number; + w: number; +}; + +export function providerTabSegments( + tabs: ModelPickerProviderTab[], + selectedIndex: number, + width: number, + formatLabel: (label: string) => string = formatProviderTabLabel, +): ProviderTabSegment[] { + if (tabs.length <= 1) return []; + const safe = Math.max(0, Math.min(selectedIndex, tabs.length - 1)); + if (!tabs[safe]) return []; + const tabMax = Math.max(8, Math.min(16, Math.floor(width / 2))); + const segments = tabs.map((tab, index) => { + const activeTab = index === safe; + const label = endTruncate(formatLabel(tab.label), tabMax); + return activeTab ? `[${label}]` : ` ${label} `; + }); + let start = 0; + if (segments.join("").length > width) { + start = safe; + while (start > 0 && segments.slice(start - 1, safe + 1).join("").length < Math.floor(width * 0.7)) { + start -= 1; + } + } + + let used = 0; + const visible: ProviderTabSegment[] = []; + const pushSegment = (segment: Omit) => { + visible.push({ ...segment, x: used, w: segment.text.length }); + used += segment.text.length; + }; + + if (start > 0 && width > 2) { + pushSegment({ text: "‹ ", active: false, index: -1, tabKey: null }); + } + for (let index = start; index < segments.length; index += 1) { + const text = segments[index] ?? ""; + if (!text) continue; + const nextUsed = used + text.length + (visible.length ? 1 : 0); + if (nextUsed > width - (index < segments.length - 1 ? 1 : 0)) { + if (used < width) pushSegment({ text: "…", active: false, index: -2, tabKey: null }); + break; + } + if (visible.length && used < width) { + pushSegment({ text: " ", active: false, index: -3, tabKey: null }); + } + pushSegment({ text, active: index === safe, index, tabKey: tabs[index]?.key ?? null }); + } + return visible; +} + export function usesCompactProviderRows(state: ModelPickerState): boolean { if (isSearching(state)) return false; return state.railEntries[state.railIndex]?.kind === "provider"; @@ -117,6 +222,8 @@ export type ModelPickerGeometry = { search: HitRect; /** One rect per rail entry (empty while searching — rail is hidden). */ rail: GeometryRect[]; + /** One rect per visible sub-provider tab. */ + providerTabs: Array<{ id: string; index: number; tabKey: string; rect: HitRect }>; /** 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). */ @@ -190,6 +297,17 @@ export function modelPickerGeometry(input: GeometryInput): ModelPickerGeometry { }); } + const providerTabs = hasSubProviderSelector(state) + ? providerTabSegments(state.providerTabs, state.providerTabIndex, listWidth) + .filter((segment): segment is ProviderTabSegment & { tabKey: string } => Boolean(segment.tabKey)) + .map((segment) => ({ + id: `right:model-picker:provider-tab:${segment.index}`, + index: segment.index, + tabKey: segment.tabKey, + rect: { x: listLeft + segment.x, y: modelRegionTop, w: segment.w, h: 1 }, + })) + : []; + // Model entries: each is EXACTLY entryHeight lines (matches ModelListRow), // windowed. const entries: ModelPickerGeometry["entries"] = []; @@ -252,6 +370,7 @@ export function modelPickerGeometry(input: GeometryInput): ModelPickerGeometry { window, search, rail, + providerTabs, entries, favorites, settings, diff --git a/apps/ade-cli/src/tuiClient/components/MultiChatGrid.tsx b/apps/ade-cli/src/tuiClient/components/MultiChatGrid.tsx index 2874d687b..1494b3bac 100644 --- a/apps/ade-cli/src/tuiClient/components/MultiChatGrid.tsx +++ b/apps/ade-cli/src/tuiClient/components/MultiChatGrid.tsx @@ -2,9 +2,12 @@ import React, { useMemo } from "react"; import { Box, Text } from "ink"; import type { AgentChatEventEnvelope, AgentChatSessionSummary } from "../../../../desktop/src/shared/types/chat"; import type { LaneSummary } from "../../../../desktop/src/shared/types/lanes"; +import type { ChatTerminalPreviewResult, ChatTerminalSession } from "../../../../desktop/src/shared/types"; import type { LocalNotice, AdeCodeProvider } from "../types"; import type { ChatTextSelection } from "./ChatView"; import { ChatView } from "./ChatView"; +import { TerminalPane } from "./TerminalPane"; +import { readTerminalScroll, type TerminalScrollBySessionId } from "./TerminalScrollState"; import { asTileCount, canRenderMultiChatGrid, @@ -20,9 +23,98 @@ type TileData = { lane: LaneSummary | null; }; -// Local notices are session-agnostic UI feedback (e.g. "Created lane x."), so -// they must not be spliced into every tile's transcript — only the focused -// tile shows them. Stable identity keeps ChatView's aggregation memo intact. +// Mirrors app.tsx isClaudePlaceholderTitle: a Claude session still on a generic +// title is awaiting the background auto-naming job (shown as "naming…"). +const CLAUDE_PLACEHOLDER_TILE_TITLES = new Set(["claude", "claude cli", "claude session", "claude code"]); +function isPlaceholderTerminalTitle(title: string | null | undefined): boolean { + const normalized = String(title ?? "").trim().toLowerCase(); + return normalized.length === 0 || CLAUDE_PLACEHOLDER_TILE_TITLES.has(normalized); +} + +// Mirrors ChatView's tile frame so terminal tiles read the same as chat tiles: +// border accents the focused tile (violet/double), lane identity lives inside via +// a colored rail + title, and a × remove affordance sits top-right. +function TerminalChatTile({ + terminal, + laneName, + laneAccent, + rect, + focused, + hovered, + removeHovered, + preview, + liveChunks, + attached, + scrollOffset, + pendingNewCount, + onRemove, +}: { + terminal: ChatTerminalSession; + laneName: string; + laneAccent: string | null; + rect: { w: number; h: number }; + focused: boolean; + hovered: boolean; + removeHovered: boolean; + preview: ChatTerminalPreviewResult | null; + liveChunks: string[]; + attached: boolean; + scrollOffset: number; + pendingNewCount: number; + onRemove: () => void; +}) { + const running = terminal.status === "running"; + const statusGlyph = running ? "●" : "○"; + const statusColor = running ? theme.color.running : theme.color.t5; + const tileBorderColor = focused + ? theme.color.violet + : hovered + ? theme.color.borderActive + : theme.color.border; + const headerColor = focused ? theme.color.violet : running ? theme.color.t2 : theme.color.t4; + const naming = running && isPlaceholderTerminalTitle(terminal.title); + const railSlot = laneAccent ? 2 : 0; + const innerWidth = Math.max(4, rect.w - 2); + const header = `${laneName} / ${terminal.title}`.slice(0, Math.max(4, innerWidth - railSlot - 4)); + return ( + + + + {laneAccent ? {"▎ "} : null} + {header} + {naming ? {" · naming…"} : null} + + + {statusGlyph} + + {" ×"} + + + + + + ); +} + +// Stable empty array for tiles with no notices, so ChatView's aggregation memo +// stays intact instead of re-running on a fresh [] every render. const NO_NOTICES: LocalNotice[] = []; function groupRows(entries: T[]): T[][] { @@ -68,6 +160,11 @@ function MultiChatTile({ expandedLineIds, scrollOffsetRows, selection, + terminalSession, + terminalPreview, + terminalLiveChunks, + terminalScroll, + terminalAttached, onFocusTile, onRemoveTile, }: { @@ -87,6 +184,11 @@ function MultiChatTile({ expandedLineIds?: Set; scrollOffsetRows: number; selection: ChatTextSelection | null; + terminalSession?: ChatTerminalSession | null; + terminalPreview?: ChatTerminalPreviewResult | null; + terminalLiveChunks?: string[]; + terminalScroll?: { scrollOffset: number; pendingNewCount: number }; + terminalAttached?: boolean; onFocusTile: (index: number) => void; onRemoveTile: (index: number) => void; }) { @@ -114,6 +216,25 @@ function MultiChatTile({ onClick: () => onRemoveTile(index), zIndex: 10, }); + if (terminalSession) { + return ( + onRemoveTile(index)} + /> + ); + } return ( ; selectionBySessionId: Record; expandedLineIds?: Set; + terminalSessionById?: Record; + terminalPreviewById?: Record; + terminalLiveChunksById?: Record; + terminalScrollBySessionId?: TerminalScrollBySessionId; + attachedTerminalId?: string | null; onFocusTile: (index: number) => void; onRemoveTile: (index: number) => void; }) { @@ -190,6 +321,33 @@ export function MultiChatGrid({ index, rect: rects[index] ?? rects[0]!, }))), [rects, safeTiles]); + // Each notice is tagged with the chat that fired it, so a tile only shows its own + // session's notices. Session-less notices attach to the focused tile so global + // feedback stays visible. Resolved once per (notices, tiles, focus) change so token + // streaming keeps stable array identities and ChatView's aggregation memo intact. + const noticesByTileSession = useMemo(() => { + const bySession = new Map(); + const global: LocalNotice[] = []; + for (const notice of notices) { + if (notice.sessionId) { + const bucket = bySession.get(notice.sessionId); + if (bucket) bucket.push(notice); + else bySession.set(notice.sessionId, [notice]); + } else { + global.push(notice); + } + } + const focusedSessionId = safeTiles[Math.max(0, Math.min(focusedIndex, safeTiles.length - 1))]?.sessionId; + const resolved = new Map(); + for (const tile of safeTiles) { + const own = bySession.get(tile.sessionId) ?? NO_NOTICES; + resolved.set( + tile.sessionId, + tile.sessionId === focusedSessionId && global.length ? [...own, ...global] : own, + ); + } + return resolved; + }, [notices, safeTiles, focusedIndex]); if (!safeTiles.length) { return ( @@ -215,12 +373,17 @@ export function MultiChatGrid({ modelDisplay={modelDisplay} focused events={eventsBySessionId[tile.sessionId] ?? []} - notices={notices} + notices={noticesByTileSession.get(tile.sessionId) ?? NO_NOTICES} streaming={!!streamingBySessionId[tile.sessionId]} interrupted={!!interruptedBySessionId[tile.sessionId]} expandedLineIds={expandedLineIds} scrollOffsetRows={scrollBySessionId[tile.sessionId] ?? 0} selection={selectionBySessionId[tile.sessionId] ?? null} + terminalSession={terminalSessionById?.[tile.sessionId] ?? null} + terminalPreview={terminalPreviewById?.[tile.sessionId] ?? null} + terminalLiveChunks={terminalLiveChunksById?.[tile.sessionId] ?? []} + terminalScroll={readTerminalScroll(terminalScrollBySessionId ?? {}, tile.sessionId)} + terminalAttached={attachedTerminalId === tile.sessionId} onFocusTile={onFocusTile} onRemoveTile={onRemoveTile} /> @@ -251,12 +414,17 @@ export function MultiChatGrid({ modelDisplay={modelDisplay} focused={index === focusedIndex} events={eventsBySessionId[tile.sessionId] ?? []} - notices={index === focusedIndex ? notices : NO_NOTICES} + notices={noticesByTileSession.get(tile.sessionId) ?? NO_NOTICES} streaming={!!streamingBySessionId[tile.sessionId]} interrupted={!!interruptedBySessionId[tile.sessionId]} expandedLineIds={expandedLineIds} scrollOffsetRows={scrollBySessionId[tile.sessionId] ?? 0} selection={selectionBySessionId[tile.sessionId] ?? null} + terminalSession={terminalSessionById?.[tile.sessionId] ?? null} + terminalPreview={terminalPreviewById?.[tile.sessionId] ?? null} + terminalLiveChunks={terminalLiveChunksById?.[tile.sessionId] ?? []} + terminalScroll={readTerminalScroll(terminalScrollBySessionId ?? {}, tile.sessionId)} + terminalAttached={attachedTerminalId === tile.sessionId} onFocusTile={onFocusTile} onRemoveTile={onRemoveTile} /> diff --git a/apps/ade-cli/src/tuiClient/components/TerminalPane.tsx b/apps/ade-cli/src/tuiClient/components/TerminalPane.tsx index b246249e8..5c78d6e23 100644 --- a/apps/ade-cli/src/tuiClient/components/TerminalPane.tsx +++ b/apps/ade-cli/src/tuiClient/components/TerminalPane.tsx @@ -17,12 +17,20 @@ const { Terminal: HeadlessTerminal } = headlessXtermModule; type TerminalPaneProps = { title: string; + terminalId?: string | null; preview: ChatTerminalPreviewResult | null; liveChunks: string[]; attached: boolean; width: number; height: number; hiddenBottomRows?: number; + /** + * Render only the terminal content rows (no header line, no outer box). Used by + * grid tiles, which supply their own bordered chrome + title via MultiChatGrid. + */ + bodyOnly?: boolean; + /** Claude session still on a placeholder title: show a subtle "naming…" hint. */ + namingHint?: boolean; /** 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. */ @@ -368,12 +376,15 @@ function terminalControlBorderColor(frame: string): string { export function TerminalPane({ title, + terminalId, preview, liveChunks, attached, width, height, hiddenBottomRows = 0, + bodyOnly = false, + namingHint = false, scrollOffset = 0, pendingNewCount = 0, onViewportMetrics, @@ -403,11 +414,20 @@ export function TerminalPane({ : null, [cols, preview?.snapshot, rows, useSnapshotRows], ); - const seed = useSnapshotRows ? "" : preview?.transcript ?? ""; + const terminalKey = terminalId ?? preview?.terminalId ?? title; + const seed = (useSnapshotRows ? preview?.snapshot?.serialized : preview?.transcript) ?? ""; + const seedKind = preview?.session.status === "running" ? "running" : "static"; + const seedKey = `${seedKind}:${terminalKey}:${seed}`; + const seedEagerly = !useSnapshotRows; + const terminalInstanceSeedKey = liveChunks.length === 0 && seedEagerly ? seedKey : ""; + const latestSeedRef = useRef({ key: terminalKey, kind: seedKind, seed, seedKey, eager: seedEagerly }); const terminalRef = useRef(null); const chunkIndexRef = useRef(0); + const seededKeyRef = useRef(null); const [renderTick, setRenderTick] = useState(0); + latestSeedRef.current = { key: terminalKey, kind: seedKind, seed, seedKey, eager: seedEagerly }; + useEffect(() => { const terminal = new HeadlessTerminal({ allowProposedApi: true, @@ -417,8 +437,11 @@ export function TerminalPane({ }); terminalRef.current = terminal; chunkIndexRef.current = 0; - if (seed) { - terminal.write(seed, () => setRenderTick((tick) => tick + 1)); + seededKeyRef.current = null; + const seedState = latestSeedRef.current; + if (seedState.eager && seedState.seed) { + seededKeyRef.current = seedState.seedKey; + terminal.write(seedState.seed, () => setRenderTick((tick) => tick + 1)); } else { setRenderTick((tick) => tick + 1); } @@ -426,7 +449,23 @@ export function TerminalPane({ terminal.dispose(); if (terminalRef.current === terminal) terminalRef.current = null; }; - }, [cols, emulatedRows, preview?.terminalId, seed]); + }, [cols, emulatedRows, terminalInstanceSeedKey, terminalKey]); + + useEffect(() => { + const terminal = terminalRef.current; + if (!terminal || liveChunks.length > 0) return; + const seedState = latestSeedRef.current; + if (!seedState.eager) return; + if (!seedState.seed) return; + if (seededKeyRef.current === seedState.seedKey) return; + if (seedState.kind === "running" && seededKeyRef.current?.startsWith(`running:${seedState.key}:`)) { + return; + } + terminal.reset(); + chunkIndexRef.current = 0; + seededKeyRef.current = seedState.seedKey; + terminal.write(`\x1bc${seedState.seed}`, () => setRenderTick((tick) => tick + 1)); + }, [liveChunks.length, seed, seedEagerly, seedKey, seedKind, terminalKey]); useEffect(() => { const terminal = terminalRef.current; @@ -446,18 +485,30 @@ export function TerminalPane({ // 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. + let resetBeforeWrite = false; if (liveChunks.length < chunkIndexRef.current) { terminal.reset(); chunkIndexRef.current = 0; + resetBeforeWrite = true; } - for (let index = chunkIndexRef.current; index < liveChunks.length; index += 1) { - terminal.write(liveChunks[index] ?? "", () => setRenderTick((tick) => tick + 1)); + const nextChunks = liveChunks.slice(chunkIndexRef.current); + const data = nextChunks.join(""); + if (data) { + const seedState = latestSeedRef.current; + let writeData = data; + if (chunkIndexRef.current === 0 && seedState.seed && seededKeyRef.current !== seedState.seedKey) { + terminal.reset(); + seededKeyRef.current = seedState.seedKey; + resetBeforeWrite = true; + writeData = `${seedState.seed}${data}`; + } + terminal.write(`${resetBeforeWrite ? "\x1bc" : ""}${writeData}`, () => setRenderTick((tick) => tick + 1)); } chunkIndexRef.current = liveChunks.length; }, [liveChunks]); const lines = useMemo(() => { - if (snapshotRows?.length) return snapshotRows.slice(0, rows); + if (snapshotRows?.length && liveChunks.length === 0) return snapshotRows.slice(0, rows); if (preview?.transcript && preview.session.status !== "running" && !liveChunks.length) { return transcriptPreviewRows(preview.transcript, rows); } @@ -488,6 +539,36 @@ export function TerminalPane({ const scrolledBack = !attached && effectiveScrollOffset > 0; const showNewChip = scrolledBack && pendingNewCount > 0; + const bodyContent = ( + + {lines.slice(0, visibleHeight).map((line, index) => ( + + {line.runs.map((run, runIndex) => ( + + {run.text || " "} + + ))} + + ))} + + ); + + // Grid tiles draw their own border + title chrome (MultiChatGrid), so they + // render the terminal body only — no header line, no outer box height padding. + if (bodyOnly) { + return bodyContent; + } + const content = ( <> @@ -495,6 +576,9 @@ export function TerminalPane({ {attached ? `${spinFrame} ${title}` : title} {status} + {namingHint && !attached ? ( + {` ${spinFrame} naming…`} + ) : null} {showNewChip ? ( // Amber "attention" chip: new output landed while the user is reading // scrollback. Press End / Shift+Down-to-bottom to jump and clear it. @@ -503,27 +587,7 @@ export function TerminalPane({ {` ↑ scrollback · End to follow`} ) : null} - - {lines.slice(0, visibleHeight).map((line, index) => ( - - {line.runs.map((run, runIndex) => ( - - {run.text || " "} - - ))} - - ))} - + {bodyContent} ); diff --git a/apps/ade-cli/src/tuiClient/connection.ts b/apps/ade-cli/src/tuiClient/connection.ts index 4b66aa072..254fe3050 100644 --- a/apps/ade-cli/src/tuiClient/connection.ts +++ b/apps/ade-cli/src/tuiClient/connection.ts @@ -432,6 +432,7 @@ function computeCliEntrypointBuildHash(): string | null { function attachedRuntimeMismatchReason( result: InitializeResult, project: ProjectLaunchContext, + options: { skipBuildHash?: boolean; skipProjectRoot?: boolean } = {}, ): { reason: string; pid: number | null } | null { const info = readAttachedRuntimeInfo(result); if (info.defaultRole !== "cto") { @@ -442,14 +443,14 @@ function attachedRuntimeMismatchReason( } const expectedBuildHash = computeCliEntrypointBuildHash(); - if (expectedBuildHash && info.buildHash !== expectedBuildHash) { + if (!options.skipBuildHash && expectedBuildHash && info.buildHash !== expectedBuildHash) { return { reason: info.buildHash ? "build hash changed" : "build hash missing", pid: info.pid, }; } - if (!isMultiProjectRuntime(result)) { + if (!options.skipProjectRoot && !isMultiProjectRuntime(result)) { const expectedProjectRoot = path.resolve(project.projectRoot); if (info.projectRoot !== expectedProjectRoot) { return { @@ -496,6 +497,7 @@ async function connectAttachedSocket(args: { socketPath: string; project: ProjectLaunchContext; shutdownOnStale?: boolean; + skipRuntimeCompatibilityCheck?: boolean; }): Promise { let client: JsonRpcClient | null = await JsonRpcClient.connect( args.socketPath, @@ -509,7 +511,10 @@ async function connectAttachedSocket(args: { 3000, "ADE RPC socket did not finish initialization.", ); - const runtimeMismatch = attachedRuntimeMismatchReason(initializeResult, args.project); + const runtimeMismatch = attachedRuntimeMismatchReason(initializeResult, args.project, { + skipBuildHash: args.skipRuntimeCompatibilityCheck, + skipProjectRoot: args.skipRuntimeCompatibilityCheck, + }); if (runtimeMismatch) { if (args.shutdownOnStale) { await connectedClient.request("shutdown").catch(() => null); @@ -647,6 +652,7 @@ async function connectAttachedSocketWithRetry(args: { attempts: number; delayMs: number; shutdownOnStale?: boolean; + skipRuntimeCompatibilityCheck?: boolean; }): Promise { let lastError: unknown = null; for (let attempt = 0; attempt < Math.max(1, args.attempts); attempt += 1) { @@ -655,6 +661,7 @@ async function connectAttachedSocketWithRetry(args: { socketPath: args.socketPath, project: args.project, shutdownOnStale: args.shutdownOnStale, + skipRuntimeCompatibilityCheck: args.skipRuntimeCompatibilityCheck, }); } catch (error) { lastError = error; @@ -671,6 +678,7 @@ export async function connectToAde(args: { requireSocket?: boolean; socketPath?: string | null; preferServiceRepair?: boolean; + remote?: boolean; }): Promise { const layout = resolveAdeLayout(args.project.projectRoot); const explicitSocketPath = @@ -693,6 +701,7 @@ export async function connectToAde(args: { project: args.project, attempts: 1, delayMs: 0, + skipRuntimeCompatibilityCheck: args.remote === true, }); } catch (error) { const message = errorMessage(error); diff --git a/apps/ade-cli/src/tuiClient/format.ts b/apps/ade-cli/src/tuiClient/format.ts index 2816f2201..bddf04eda 100644 --- a/apps/ade-cli/src/tuiClient/format.ts +++ b/apps/ade-cli/src/tuiClient/format.ts @@ -621,9 +621,12 @@ export function renderChatLines(args: { continue; } if (event.type === "status") { - const tone: "error" | "notice" = event.turnStatus === "failed" || event.turnStatus === "interrupted" - ? "error" - : "notice"; + // The interrupted state is conveyed by the dedicated "Interrupted · chat to continue" + // suffix row, so suppress the redundant [status] interrupted transcript line. + if (event.turnStatus === "interrupted") { + continue; + } + const tone: "error" | "notice" = event.turnStatus === "failed" ? "error" : "notice"; lines.push({ id, tone, body: `[status] ${event.turnStatus}${event.message ? ` · ${singleLine(event.message, 120)}` : ""}` }); continue; } diff --git a/apps/ade-cli/src/tuiClient/project.ts b/apps/ade-cli/src/tuiClient/project.ts index 53d314917..cf6dce69c 100644 --- a/apps/ade-cli/src/tuiClient/project.ts +++ b/apps/ade-cli/src/tuiClient/project.ts @@ -46,30 +46,37 @@ export function detectProjectLaunchContext(args: { projectRoot?: string | null; workspaceRoot?: string | null; laneHint?: string | null; + sessionHint?: string | null; + remote?: boolean; } = {}): ProjectLaunchContext { const launchCwd = normalizeRoot(args.cwd ?? process.cwd()); const explicitProjectRoot = args.projectRoot?.trim(); const explicitWorkspaceRoot = args.workspaceRoot?.trim(); + const remote = args.remote === true; const worktree = findAdeWorktreeContext(launchCwd); const gitRoot = findGitRoot(launchCwd); - const projectRoot = normalizeRoot( - explicitProjectRoot - ?? worktree?.projectRoot - ?? gitRoot - ?? launchCwd, - ); - const workspaceRoot = normalizeRoot( - explicitWorkspaceRoot - ?? worktree?.workspaceRoot - ?? gitRoot - ?? projectRoot, - ); + const projectRoot = remote + ? (explicitProjectRoot ?? worktree?.projectRoot ?? gitRoot ?? launchCwd) + : normalizeRoot( + explicitProjectRoot + ?? worktree?.projectRoot + ?? gitRoot + ?? launchCwd, + ); + const workspaceRoot = remote + ? (explicitWorkspaceRoot ?? worktree?.workspaceRoot ?? gitRoot ?? projectRoot) + : normalizeRoot( + explicitWorkspaceRoot + ?? worktree?.workspaceRoot + ?? gitRoot + ?? projectRoot, + ); - if (!fs.existsSync(projectRoot)) { + if (!remote && !fs.existsSync(projectRoot)) { throw new Error(`Project root does not exist: ${projectRoot}`); } - if (!fs.existsSync(workspaceRoot)) { + if (!remote && !fs.existsSync(workspaceRoot)) { throw new Error(`Workspace root does not exist: ${workspaceRoot}`); } @@ -78,6 +85,8 @@ export function detectProjectLaunchContext(args: { projectRoot, workspaceRoot, laneHint: args.laneHint?.trim() || worktree?.laneHint || null, + sessionHint: args.sessionHint?.trim() || null, + remote, }; } @@ -204,9 +213,16 @@ export function resolveTuiChatRefreshTarget(args: { const previewLane = args.newChatPreviewLaneId ? args.lanes.find((lane) => lane.id === args.newChatPreviewLaneId) ?? null : null; + const activeSession = args.activeSessionId + ? args.sessions.find((session) => session.sessionId === args.activeSessionId) ?? null + : null; + const activeSessionLane = activeSession + ? args.lanes.find((lane) => lane.id === activeSession.laneId) ?? null + : null; const activeLane = args.lanes.find((entry) => entry.id === args.activeLaneId) ?? null; const contextLaunchLane = chooseTuiLaunchLane(args.lanes, args.context, args.lastLaneId); - const fallbackLane = activeLane + const fallbackLane = activeSessionLane + ?? activeLane ?? socketRecentLane ?? previewLane ?? contextLaunchLane; @@ -226,8 +242,7 @@ export function resolveTuiChatRefreshTarget(args: { const seedSession = args.draftChatActive || previewMode ? newestChatSession(laneSessions) : null; const session = args.draftChatActive || previewMode ? null - : args.sessions.find((entry) => entry.sessionId === args.activeSessionId) - ?? newestChatSession(laneSessions); + : activeSession ?? newestChatSession(laneSessions); return { lane, laneId, diff --git a/apps/ade-cli/src/tuiClient/remoteLauncher.ts b/apps/ade-cli/src/tuiClient/remoteLauncher.ts new file mode 100644 index 000000000..cf70cd79f --- /dev/null +++ b/apps/ade-cli/src/tuiClient/remoteLauncher.ts @@ -0,0 +1,997 @@ +import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process"; +import net, { type AddressInfo } from "node:net"; +import readline from "node:readline/promises"; +import { RemoteTargetRegistry, normalizeRemoteTargetRoutes } from "../../../desktop/src/main/services/remoteRuntime/remoteTargetRegistry"; +import type { + RemoteRuntimeProjectRecord, + RemoteRuntimeTarget, + RemoteRuntimeTargetRoute, +} from "../../../desktop/src/shared/types/remoteRuntime"; +import type { AgentChatSessionSummary } from "../../../desktop/src/shared/types/chat"; +import type { ChatTerminalSession } from "../../../desktop/src/shared/types/sessions"; + +type JsonRpcResponse = { + jsonrpc: "2.0"; + id?: number | string | null; + result?: unknown; + error?: { + code: number; + message: string; + data?: unknown; + }; + method?: string; + params?: unknown; +}; + +type PendingRequest = { + resolve: (value: unknown) => void; + reject: (reason: unknown) => void; +}; + +type RemoteRuntimeLayout = { + channel: "alpha" | "beta" | null; + homeDirName: string; + homeDirExpr: string; + binDirExpr: string; + runtimeDirExpr: string; + socketExpr: string; + binaryExpr: string; +}; + +type RemoteRpcAttempt = { + target: RemoteRuntimeTarget; + route: RemoteRuntimeTargetRoute; + layout: RemoteRuntimeLayout; + command: string; + sshArgs: string[]; + label: string; +}; + +type RemoteRpcSession = { + client: ProcessJsonRpcClient; + attempt: RemoteRpcAttempt; +}; + +type RemoteBridge = { + socketUrl: string; + close: () => Promise; +}; + +type RemoteLaunchScope = "project" | "session"; + +type RemoteCliOptions = { + help: boolean; + scope: RemoteLaunchScope | null; + targetQuery: string | null; + projectQuery: string | null; + sessionQuery: string | null; + listTargets: boolean; + listProjects: boolean; + listSessions: boolean; +}; + +type RemoteSessionChoice = { + sessionId: string; + laneId: string; + title: string; + detail: string; + status: string; + lastActivityAt: string; + kind: "chat" | "terminal"; +}; + +export type RunAdeCodeCli = (argv: string[]) => Promise; + +const REMOTE_RPC_TIMEOUT_MS = 20_000; + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +function isRecord(value: unknown): value is Record { + return Boolean(value) && typeof value === "object" && !Array.isArray(value); +} + +function trimString(value: unknown): string | null { + return typeof value === "string" && value.trim() ? value.trim() : null; +} + +function readFlagValue(argv: string[], index: number, flag: string): string { + const value = argv[index + 1]; + if (!value || value.startsWith("-")) { + throw new Error(`${flag} requires a value.`); + } + return value; +} + +export function parseRemoteAdeCodeArgs(argv: string[]): RemoteCliOptions { + const options: RemoteCliOptions = { + help: false, + scope: null, + targetQuery: null, + projectQuery: null, + sessionQuery: null, + listTargets: false, + listProjects: false, + listSessions: false, + }; + + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index]!; + if (arg === "--help" || arg === "-h") { + options.help = true; + continue; + } + if (arg === "--list-targets" || arg === "--targets") { + options.listTargets = true; + continue; + } + if (arg === "--list-projects" || arg === "--projects") { + options.listProjects = true; + continue; + } + if (arg === "--list-sessions" || arg === "--sessions") { + options.listSessions = true; + options.scope = "session"; + continue; + } + if (arg === "--target" || arg === "--machine") { + options.targetQuery = readFlagValue(argv, index, arg); + index += 1; + continue; + } + if (arg === "--project" || arg === "--project-root") { + options.projectQuery = readFlagValue(argv, index, arg); + index += 1; + continue; + } + if (arg === "--session" || arg === "--chat") { + options.sessionQuery = readFlagValue(argv, index, arg); + options.scope = "session"; + index += 1; + continue; + } + if (arg === "project" || arg === "session") { + options.scope = arg; + continue; + } + throw new Error(`Unknown ade code remote option: ${arg}`); + } + + return options; +} + +function printRemoteHelp(): void { + process.stdout.write(`ade code remote + +Launch ADE Code against a saved desktop remote machine. + +Usage: + ade code remote [project|session] + ade code remote --target --project + ade code remote session --target --project --session + ade code remote --list-targets + +Flags: + --target, --machine Select a saved remote machine. + --project, --project-root Select or register a remote project. + --session, --chat Open a specific remote chat/session. + --list-projects Print projects for the selected machine. + --list-sessions Print sessions for the selected project. +`); +} + +function canPrompt(): boolean { + return process.stdin.isTTY === true && process.stderr.isTTY === true; +} + +async function promptChoice(title: string, entries: T[], describe: (entry: T, index: number) => string): Promise { + if (!entries.length) throw new Error(`${title}: no choices available.`); + if (!canPrompt()) { + if (entries.length === 1) return entries[0]!; + throw new Error(`${title}: pass a flag to choose non-interactively.`); + } + + process.stderr.write(`\n${title}\n`); + entries.forEach((entry, index) => { + process.stderr.write(` ${index + 1}. ${describe(entry, index)}\n`); + }); + + const rl = readline.createInterface({ + input: process.stdin, + output: process.stderr, + }); + try { + while (true) { + const answer = (await rl.question("Choose: ")).trim(); + const selected = Number.parseInt(answer, 10); + if (Number.isInteger(selected) && selected >= 1 && selected <= entries.length) { + return entries[selected - 1]!; + } + process.stderr.write(`Enter a number from 1 to ${entries.length}.\n`); + } + } finally { + rl.close(); + } +} + +async function promptText(title: string): Promise { + if (!canPrompt()) throw new Error(`${title}: pass --project non-interactively.`); + const rl = readline.createInterface({ + input: process.stdin, + output: process.stderr, + }); + try { + while (true) { + const answer = (await rl.question(`${title}: `)).trim(); + if (answer) return answer; + } + } finally { + rl.close(); + } +} + +function normalizeMatch(value: string): string { + return value.trim().toLowerCase(); +} + +function findByQuery( + entries: T[], + query: string, + fields: (entry: T) => Array, + label: string, +): T { + const needle = normalizeMatch(query); + const exact = entries.filter((entry) => + fields(entry).some((field) => field && normalizeMatch(field) === needle), + ); + if (exact.length === 1) return exact[0]!; + if (exact.length > 1) { + throw new Error(`${label} '${query}' matches multiple entries.`); + } + const fuzzy = entries.filter((entry) => + fields(entry).some((field) => field && normalizeMatch(field).includes(needle)), + ); + if (fuzzy.length === 1) return fuzzy[0]!; + if (fuzzy.length > 1) { + throw new Error(`${label} '${query}' matches multiple entries.`); + } + throw new Error(`${label} not found: ${query}`); +} + +function routeKey(route: RemoteRuntimeTargetRoute): string { + return `${route.hostname.toLowerCase().replace(/\.$/, "")}:${route.port ?? ""}`; +} + +function targetRoutes(target: RemoteRuntimeTarget): RemoteRuntimeTargetRoute[] { + const primary = normalizeRemoteTargetRoutes({ + hostname: target.hostname, + port: target.port, + routes: target.routes, + }); + const primaryKey = `${target.hostname.toLowerCase().replace(/\.$/, "")}:${target.port ?? ""}`; + return [...primary].sort((left, right) => { + const rightSeen = right.lastSucceededAt ?? 0; + const leftSeen = left.lastSucceededAt ?? 0; + if (rightSeen !== leftSeen) return rightSeen - leftSeen; + if (routeKey(left) === primaryKey) return -1; + if (routeKey(right) === primaryKey) return 1; + return left.hostname.localeCompare(right.hostname); + }); +} + +function normalizeRemoteRuntimeChannel(value: unknown): "alpha" | "beta" | null { + const normalized = typeof value === "string" ? value.trim().toLowerCase() : ""; + if (normalized === "alpha" || normalized === "beta") return normalized; + return null; +} + +function remoteRuntimeLayoutForChannel(channel: "alpha" | "beta" | null): RemoteRuntimeLayout { + const homeDirName = channel === "alpha" + ? ".ade-alpha" + : channel === "beta" + ? ".ade-beta" + : ".ade"; + const homeDirExpr = `$HOME/${homeDirName}`; + return { + channel, + homeDirName, + homeDirExpr, + binDirExpr: `${homeDirExpr}/bin`, + runtimeDirExpr: `${homeDirExpr}/runtime`, + socketExpr: `${homeDirExpr}/sock/ade.sock`, + binaryExpr: `${homeDirExpr}/bin/ade`, + }; +} + +function remoteRuntimeLayoutCandidates(env: NodeJS.ProcessEnv = process.env): RemoteRuntimeLayout[] { + const channels = [ + normalizeRemoteRuntimeChannel(env.ADE_PACKAGE_CHANNEL), + null, + "beta" as const, + "alpha" as const, + ]; + const seen = new Set(); + return channels + .map((channel) => remoteRuntimeLayoutForChannel(channel)) + .filter((layout) => { + if (seen.has(layout.homeDirName)) return false; + seen.add(layout.homeDirName); + return true; + }); +} + +export function buildRemoteRuntimeRpcCommand(layout: RemoteRuntimeLayout, binaryExpr = layout.binaryExpr): string { + const exports = [ + `export ADE_HOME="${layout.homeDirExpr}"`, + `export PATH="${layout.binDirExpr}:$HOME/.local/bin:$HOME/.npm-global/bin\${PATH:+:$PATH}"`, + `export ADE_DEFAULT_ROLE="cto"`, + `export ADE_PTY_HOST_WORKER_COMMAND="${binaryExpr}"`, + ]; + if (layout.channel) { + exports.push(`export ADE_PACKAGE_CHANNEL="${layout.channel}"`); + exports.push("export ADE_DISABLE_RUNTIME_SERVICE_INSTALL=1"); + } + return [ + ...exports, + "ADE_RUNTIME_ARCH=\"$(node -p 'process.platform + \"-\" + process.arch' 2>/dev/null || true)\"", + `if [ -n "$ADE_RUNTIME_ARCH" ] && [ -d "${layout.runtimeDirExpr}/$ADE_RUNTIME_ARCH/node_modules" ]; then export NODE_PATH="${layout.runtimeDirExpr}/$ADE_RUNTIME_ARCH/node_modules\${NODE_PATH:+:$NODE_PATH}"; fi`, + `exec ${binaryExpr} --socket ${layout.socketExpr} rpc --stdio`, + ].join("; "); +} + +function buildSshArgs(target: RemoteRuntimeTarget, route: RemoteRuntimeTargetRoute, command: string): string[] { + const destination = target.sshUser?.trim() + ? `${target.sshUser.trim()}@${route.hostname}` + : route.hostname; + const args = [ + "-T", + "-o", + "BatchMode=yes", + "-o", + "ConnectTimeout=10", + "-o", + "ServerAliveInterval=15", + "-o", + "ServerAliveCountMax=3", + "-o", + "StrictHostKeyChecking=yes", + ]; + const port = route.port ?? target.port; + if (port) args.push("-p", String(port)); + if (target.sshKeyPath) args.push("-i", target.sshKeyPath); + args.push(destination, command); + return args; +} + +function remoteRpcAttempts(target: RemoteRuntimeTarget): RemoteRpcAttempt[] { + const attempts: RemoteRpcAttempt[] = []; + const seen = new Set(); + for (const route of targetRoutes(target)) { + for (const layout of remoteRuntimeLayoutCandidates()) { + const commands = [ + buildRemoteRuntimeRpcCommand(layout, layout.binaryExpr), + buildRemoteRuntimeRpcCommand(layout, "ade"), + ]; + for (const command of commands) { + const key = `${routeKey(route)}\0${layout.homeDirName}\0${command}`; + if (seen.has(key)) continue; + seen.add(key); + const sshArgs = buildSshArgs(target, route, command); + attempts.push({ + target, + route, + layout, + command, + sshArgs, + label: `${route.hostname}${route.port ? `:${route.port}` : ""} ${layout.homeDirName}`, + }); + } + } + } + return attempts; +} + +function spawnRemoteRpcProcess(attempt: RemoteRpcAttempt): ChildProcessWithoutNullStreams { + return spawn("ssh", attempt.sshArgs, { + stdio: ["pipe", "pipe", "pipe"], + }); +} + +function withTimeout(promise: Promise, timeoutMs: number, message: string): Promise { + let timer: ReturnType | null = null; + return new Promise((resolve, reject) => { + timer = setTimeout(() => reject(new Error(message)), timeoutMs); + promise.then(resolve, reject).finally(() => { + if (timer) clearTimeout(timer); + }); + }); +} + +class ProcessJsonRpcClient { + private nextId = 1; + private buffer = Buffer.alloc(0); + private readonly pending = new Map(); + private readonly stderrChunks: Buffer[] = []; + private closed = false; + + constructor(private readonly child: ChildProcessWithoutNullStreams) { + child.stdout.on("data", (chunk: Buffer | string) => this.handleData(chunk)); + child.stderr.on("data", (chunk: Buffer | string) => { + this.stderrChunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk, "utf8")); + }); + child.on("error", (error) => this.handleClosed(error)); + child.on("close", (code, signal) => { + this.handleClosed(new Error(this.closeMessage(code, signal))); + }); + } + + request(method: string, params?: unknown): Promise { + if (this.closed) return Promise.reject(new Error("Remote ADE RPC connection is closed.")); + const id = this.nextId++; + const payload = { + jsonrpc: "2.0", + id, + method, + ...(params !== undefined ? { params } : {}), + }; + return new Promise((resolve, reject) => { + this.pending.set(id, { + resolve: (value) => resolve(value as T), + reject, + }); + this.child.stdin.write(`${JSON.stringify(payload)}\n`, "utf8", (error) => { + if (!error) return; + this.pending.delete(id); + reject(error); + }); + }); + } + + close(): void { + if (this.closed) return; + this.closed = true; + this.rejectAll(new Error("Remote ADE RPC connection closed.")); + this.child.stdin.end(); + this.child.kill(); + } + + private closeMessage(code: number | null, signal: NodeJS.Signals | null): string { + const detail = this.stderrChunks.length + ? Buffer.concat(this.stderrChunks).toString("utf8").trim().split("\n").slice(-4).join("\n") + : ""; + const status = signal ? `signal ${signal}` : `code ${code ?? "unknown"}`; + return detail + ? `Remote ADE RPC exited with ${status}: ${detail}` + : `Remote ADE RPC exited with ${status}.`; + } + + private handleClosed(error: Error): void { + if (this.closed) return; + this.closed = true; + this.rejectAll(error); + } + + private handleData(chunk: Buffer | string): void { + this.buffer = Buffer.concat([ + this.buffer, + Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk, "utf8"), + ]); + while (true) { + const next = this.takeNextPayload(); + if (!next) return; + const payload = next.trim(); + if (!payload) continue; + let parsed: JsonRpcResponse | JsonRpcResponse[] | null = null; + try { + parsed = JSON.parse(payload) as JsonRpcResponse | JsonRpcResponse[]; + } catch { + continue; + } + const responses = Array.isArray(parsed) ? parsed : [parsed]; + for (const response of responses) this.handleResponse(response); + } + } + + private takeNextPayload(): string | null { + while (this.buffer.length && /\s/.test(String.fromCharCode(this.buffer[0]!))) { + this.buffer = this.buffer.subarray(1); + } + if (!this.buffer.length) return null; + const first = String.fromCharCode(this.buffer[0]!); + if (first === "{" || first === "[") { + const idx = this.buffer.indexOf(0x0a); + if (idx < 0) return null; + const payload = this.buffer.subarray(0, idx).toString("utf8"); + this.buffer = this.buffer.subarray(idx + 1); + return payload; + } + + const crlfBoundary = this.buffer.indexOf("\r\n\r\n"); + const lfBoundary = this.buffer.indexOf("\n\n"); + let boundary: { index: number; length: number } | null = null; + if (crlfBoundary >= 0) boundary = { index: crlfBoundary, length: 4 }; + else if (lfBoundary >= 0) boundary = { index: lfBoundary, length: 2 }; + if (!boundary) return null; + const header = this.buffer.subarray(0, boundary.index).toString("ascii"); + const match = /^content-length\s*:\s*(\d+)\s*$/im.exec(header); + if (!match) { + this.buffer = this.buffer.subarray(boundary.index + boundary.length); + return ""; + } + const length = Number.parseInt(match[1]!, 10); + const bodyStart = boundary.index + boundary.length; + const bodyEnd = bodyStart + length; + if (this.buffer.length < bodyEnd) return null; + const payload = this.buffer.subarray(bodyStart, bodyEnd).toString("utf8"); + this.buffer = this.buffer.subarray(bodyEnd); + return payload; + } + + private handleResponse(response: JsonRpcResponse): void { + if (response.id == null) return; + const pending = this.pending.get(response.id); + if (!pending) return; + this.pending.delete(response.id); + if (response.error) { + pending.reject(new Error(response.error.message)); + return; + } + pending.resolve(response.result); + } + + private rejectAll(error: Error): void { + for (const pending of this.pending.values()) { + pending.reject(error); + } + this.pending.clear(); + } +} + +async function initializeRemoteRpc(client: ProcessJsonRpcClient): Promise { + await client.request("ade/initialize", { + protocolVersion: "2025-06-18", + clientName: "ade-code-remote", + identity: { + role: "cto", + callerId: `ade-code-remote:${process.pid}`, + }, + }); + await client.request("ade/initialized"); +} + +async function openRemoteRpcSession(target: RemoteRuntimeTarget): Promise { + const errors: string[] = []; + for (const attempt of remoteRpcAttempts(target)) { + const client = new ProcessJsonRpcClient(spawnRemoteRpcProcess(attempt)); + try { + await withTimeout( + initializeRemoteRpc(client), + REMOTE_RPC_TIMEOUT_MS, + `Remote ADE RPC did not initialize within ${REMOTE_RPC_TIMEOUT_MS / 1000}s.`, + ); + return { client, attempt }; + } catch (error) { + client.close(); + errors.push(`${attempt.label}: ${errorMessage(error)}`); + } + } + throw new Error( + `Could not connect to remote ADE on ${target.name}. ` + + `Tried ${errors.length} route/runtime combinations. ${errors.slice(0, 4).join(" | ")}`, + ); +} + +function coerceProjects(value: unknown): RemoteRuntimeProjectRecord[] { + if (!Array.isArray(value)) return []; + return value.flatMap((entry) => { + if (!isRecord(entry)) return []; + const projectId = trimString(entry.projectId); + const rootPath = trimString(entry.rootPath); + if (!projectId || !rootPath) return []; + return [{ + projectId, + rootPath, + displayName: trimString(entry.displayName) ?? rootPath.split("/").filter(Boolean).at(-1) ?? rootPath, + addedAt: typeof entry.addedAt === "number" ? entry.addedAt : 0, + lastOpenedAt: typeof entry.lastOpenedAt === "number" ? entry.lastOpenedAt : 0, + gitOriginUrl: typeof entry.gitOriginUrl === "string" ? entry.gitOriginUrl : null, + }]; + }); +} + +function sortProjects(projects: RemoteRuntimeProjectRecord[]): RemoteRuntimeProjectRecord[] { + return [...projects].sort((left, right) => { + const activity = (right.lastOpenedAt ?? 0) - (left.lastOpenedAt ?? 0); + if (activity !== 0) return activity; + return left.displayName.localeCompare(right.displayName, undefined, { sensitivity: "base" }); + }); +} + +async function listProjects(client: ProcessJsonRpcClient): Promise { + const raw = await withTimeout( + client.request("projects.list", {}), + REMOTE_RPC_TIMEOUT_MS, + "Timed out listing remote projects.", + ); + return sortProjects(coerceProjects(raw)); +} + +async function ensureProject(client: ProcessJsonRpcClient, query: string): Promise { + const raw = await withTimeout( + client.request("projects.add", { rootPath: query }), + REMOTE_RPC_TIMEOUT_MS, + `Timed out registering remote project ${query}.`, + ); + const project = coerceProjects([raw])[0] ?? null; + if (!project) throw new Error("Remote ADE did not return a project record."); + return project; +} + +async function callProjectAction( + client: ProcessJsonRpcClient, + projectId: string, + domain: string, + action: string, + args: Record = {}, +): Promise { + const payload = await withTimeout( + client.request("ade/actions/call", { + projectId, + name: "run_ade_action", + arguments: { domain, action, args }, + }), + REMOTE_RPC_TIMEOUT_MS, + `Timed out running ${domain}.${action} on the remote project.`, + ); + if (isRecord(payload) && payload.ok === false) { + const message = isRecord(payload.error) ? trimString(payload.error.message) : null; + throw new Error(message ?? `Remote action failed: ${domain}.${action}`); + } + return (isRecord(payload) && "result" in payload ? payload.result : payload) as T; +} + +function coerceChatSessions(value: unknown): AgentChatSessionSummary[] { + if (!Array.isArray(value)) return []; + return value.flatMap((entry) => { + if (!isRecord(entry)) return []; + const sessionId = trimString(entry.sessionId); + const laneId = trimString(entry.laneId); + const provider = trimString(entry.provider); + const model = trimString(entry.model); + const status = trimString(entry.status); + const startedAt = trimString(entry.startedAt); + const lastActivityAt = trimString(entry.lastActivityAt); + if (!sessionId || !laneId || !provider || !model || !status || !startedAt || !lastActivityAt) return []; + return [{ + ...(entry as AgentChatSessionSummary), + sessionId, + laneId, + provider: provider as AgentChatSessionSummary["provider"], + model, + status: status as AgentChatSessionSummary["status"], + startedAt, + endedAt: typeof entry.endedAt === "string" ? entry.endedAt : null, + lastActivityAt, + lastOutputPreview: typeof entry.lastOutputPreview === "string" ? entry.lastOutputPreview : null, + summary: typeof entry.summary === "string" ? entry.summary : null, + }]; + }); +} + +function coerceTerminalSessions(value: unknown): ChatTerminalSession[] { + if (!Array.isArray(value)) return []; + return value.flatMap((entry) => { + if (!isRecord(entry)) return []; + const terminalId = trimString(entry.terminalId); + const laneId = trimString(entry.laneId); + if (!terminalId || !laneId) return []; + return [{ + ...(entry as ChatTerminalSession), + terminalId, + laneId, + title: trimString(entry.title) ?? terminalId, + goal: trimString(entry.goal), + toolType: trimString(entry.toolType) as ChatTerminalSession["toolType"], + status: (trimString(entry.status) ?? "ended") as ChatTerminalSession["status"], + runtimeState: (trimString(entry.runtimeState) ?? "idle") as ChatTerminalSession["runtimeState"], + startedAt: trimString(entry.startedAt) ?? new Date(0).toISOString(), + endedAt: trimString(entry.endedAt), + lastOutputPreview: trimString(entry.lastOutputPreview), + summary: trimString(entry.summary), + }]; + }); +} + +function terminalToChoice(session: ChatTerminalSession): RemoteSessionChoice { + const status = session.status === "running" + ? session.runtimeState === "idle" ? "idle" : "active" + : "ended"; + return { + sessionId: session.terminalId, + laneId: session.laneId, + title: session.title || session.goal || session.terminalId, + detail: session.goal ?? session.summary ?? session.lastOutputPreview ?? "", + status, + lastActivityAt: session.endedAt ?? session.startedAt, + kind: "terminal", + }; +} + +function isTerminalSessionLaunchable(session: ChatTerminalSession): boolean { + const toolType = session.toolType ?? ""; + if (toolType === "codex-chat" || toolType === "claude-chat" || toolType === "opencode-chat" || toolType === "cursor" || toolType === "droid-chat") { + return false; + } + if (toolType === "claude" || toolType === "claude-orchestrated") return true; + return isRecord(session.resumeMetadata) && session.resumeMetadata.provider === "claude"; +} + +function chatToChoice(session: AgentChatSessionSummary): RemoteSessionChoice { + return { + sessionId: session.sessionId, + laneId: session.laneId, + title: session.title ?? session.goal ?? session.sessionId, + detail: session.goal ?? session.summary ?? session.lastOutputPreview ?? `${session.provider} ${session.model}`, + status: session.status, + lastActivityAt: session.lastActivityAt, + kind: "chat", + }; +} + +async function listRemoteSessions(client: ProcessJsonRpcClient, projectId: string): Promise { + const chats = coerceChatSessions(await callProjectAction(client, projectId, "chat", "listSessions", { + includeArchived: false, + }).catch(() => [])); + const terminals = coerceTerminalSessions(await callProjectAction(client, projectId, "terminal", "list", { + limit: 200, + }).catch(() => [])); + return [ + ...chats.map(chatToChoice), + ...terminals.filter(isTerminalSessionLaunchable).map(terminalToChoice), + ].sort((left, right) => { + const statusRank = (status: string): number => status === "active" || status === "running" ? 0 : status === "idle" ? 1 : 2; + const rankDelta = statusRank(left.status) - statusRank(right.status); + if (rankDelta !== 0) return rankDelta; + const rightMs = Date.parse(right.lastActivityAt); + const leftMs = Date.parse(left.lastActivityAt); + return (Number.isFinite(rightMs) ? rightMs : 0) - (Number.isFinite(leftMs) ? leftMs : 0); + }); +} + +async function selectTarget(targets: RemoteRuntimeTarget[], query: string | null): Promise { + if (!targets.length) { + throw new Error("No saved remote machines found. Add a remote connection in the ADE desktop app first."); + } + if (query) { + return findByQuery( + targets, + query, + (target) => [target.id, target.name, target.hostname], + "Remote machine", + ); + } + if (targets.length === 1 && !canPrompt()) return targets[0]!; + return await promptChoice( + "Remote machines", + targets, + (target) => `${target.name} (${target.sshUser ? `${target.sshUser}@` : ""}${target.hostname}${target.port ? `:${target.port}` : ""})`, + ); +} + +async function selectScope(options: RemoteCliOptions): Promise { + if (options.scope) return options.scope; + if (!canPrompt()) return "project"; + return await promptChoice( + "Open", + ["project", "session"] as const, + (scope) => scope === "project" + ? "Project - full ADE Code workspace" + : "Session - choose a running/recent chat first", + ); +} + +async function selectProject( + client: ProcessJsonRpcClient, + projects: RemoteRuntimeProjectRecord[], + query: string | null, +): Promise { + if (query) { + try { + return findByQuery( + projects, + query, + (project) => [project.projectId, project.displayName, project.rootPath, project.gitOriginUrl], + "Remote project", + ); + } catch (error) { + if (query.startsWith("/") || query.startsWith("~")) { + return await ensureProject(client, query); + } + throw error; + } + } + if (!projects.length) { + return await ensureProject(client, await promptText("Remote project root")); + } + if (projects.length === 1 && !canPrompt()) return projects[0]!; + return await promptChoice( + "Remote projects", + projects, + (project) => `${project.displayName} (${project.rootPath})`, + ); +} + +async function selectSession(sessions: RemoteSessionChoice[], query: string | null): Promise { + if (!sessions.length) throw new Error("No remote sessions found for this project."); + if (query) { + return findByQuery( + sessions, + query, + (session) => [session.sessionId, session.title, session.detail], + "Remote session", + ); + } + if (sessions.length === 1 && !canPrompt()) return sessions[0]!; + return await promptChoice( + "Remote sessions", + sessions, + (session) => `${session.title} [${session.kind}, ${session.status}] (${session.sessionId})`, + ); +} + +function printTargets(targets: RemoteRuntimeTarget[]): void { + for (const target of targets) { + process.stdout.write(`${target.id}\t${target.name}\t${target.sshUser ? `${target.sshUser}@` : ""}${target.hostname}${target.port ? `:${target.port}` : ""}\n`); + } +} + +function printProjects(projects: RemoteRuntimeProjectRecord[]): void { + for (const project of projects) { + process.stdout.write(`${project.projectId}\t${project.displayName}\t${project.rootPath}\n`); + } +} + +function printSessions(sessions: RemoteSessionChoice[]): void { + for (const session of sessions) { + process.stdout.write(`${session.sessionId}\t${session.kind}\t${session.status}\t${session.title}\n`); + } +} + +async function startRemoteBridge(attempt: RemoteRpcAttempt): Promise { + const server = net.createServer((socket) => { + const child = spawnRemoteRpcProcess(attempt); + let settled = false; + const teardown = (): void => { + if (settled) return; + settled = true; + socket.destroy(); + child.stdin.destroy(); + child.stdout.destroy(); + child.stderr.destroy(); + child.kill(); + }; + socket.on("error", teardown); + socket.on("close", teardown); + child.on("error", teardown); + child.on("close", teardown); + child.stderr.resume(); + socket.pipe(child.stdin); + child.stdout.pipe(socket); + }); + + await new Promise((resolve, reject) => { + const cleanup = (): void => { + server.off("listening", onListening); + server.off("error", onError); + }; + const onListening = (): void => { + cleanup(); + resolve(); + }; + const onError = (error: Error): void => { + cleanup(); + reject(error); + }; + server.once("listening", onListening); + server.once("error", onError); + server.listen(0, "127.0.0.1"); + }); + + const address = server.address() as AddressInfo | null; + if (!address) { + throw new Error("Remote bridge did not bind a local port."); + } + return { + socketUrl: `tcp://127.0.0.1:${address.port}`, + close: async () => { + await new Promise((resolve) => server.close(() => resolve())); + }, + }; +} + +export function takeAdeCodeRemoteArgs(rest: string[]): string[] | null { + const valueFlags = new Set([ + "--project-root", + "--workspace-root", + "--lane", + "--socket", + "--session", + "--chat", + ]); + let skipNext = false; + for (let index = 0; index < rest.length; index += 1) { + const arg = rest[index]!; + if (skipNext) { + skipNext = false; + continue; + } + if (arg === "--") return null; + if (arg.startsWith("-")) { + const flag = arg.includes("=") ? arg.slice(0, arg.indexOf("=")) : arg; + skipNext = !arg.includes("=") && valueFlags.has(flag); + continue; + } + if (arg !== "remote") return null; + return [...rest.slice(0, index), ...rest.slice(index + 1)]; + } + return null; +} + +export async function runAdeCodeRemote(argv: string[], runAdeCodeCli: RunAdeCodeCli): Promise { + const options = parseRemoteAdeCodeArgs(argv); + if (options.help) { + printRemoteHelp(); + return 0; + } + + const targets = new RemoteTargetRegistry().list(); + if (options.listTargets) { + printTargets(targets); + return 0; + } + + const target = await selectTarget(targets, options.targetQuery); + const remote = await openRemoteRpcSession(target); + let bridge: RemoteBridge | null = null; + try { + const projects = await listProjects(remote.client); + if (options.listProjects) { + printProjects(projects); + return 0; + } + + const scope = await selectScope(options); + const project = await selectProject(remote.client, projects, options.projectQuery); + let session: RemoteSessionChoice | null = null; + if (scope === "session" || options.listSessions) { + const sessions = await listRemoteSessions(remote.client, project.projectId); + if (options.listSessions) { + printSessions(sessions); + return 0; + } + session = await selectSession(sessions, options.sessionQuery); + } + + remote.client.close(); + bridge = await startRemoteBridge(remote.attempt); + process.stderr.write( + `Connecting ADE Code to ${target.name} · ${project.displayName}${session ? ` · ${session.title}` : ""}\n`, + ); + return await runAdeCodeCli([ + "--project-root", + project.rootPath, + "--workspace-root", + project.rootPath, + "--socket", + bridge.socketUrl, + "--require-socket", + "--remote", + ...(session?.laneId ? ["--lane", session.laneId] : []), + ...(session?.sessionId ? ["--session", session.sessionId] : []), + ]); + } finally { + remote.client.close(); + if (bridge) await bridge.close(); + } +} diff --git a/apps/ade-cli/src/tuiClient/types.ts b/apps/ade-cli/src/tuiClient/types.ts index ef37920ef..67d2af833 100644 --- a/apps/ade-cli/src/tuiClient/types.ts +++ b/apps/ade-cli/src/tuiClient/types.ts @@ -31,6 +31,8 @@ export type ProjectLaunchContext = { projectRoot: string; workspaceRoot: string; laneHint: string | null; + sessionHint: string | null; + remote: boolean; }; export type ChatHistorySnapshot = AgentChatEventHistorySnapshot; @@ -320,6 +322,11 @@ export type LocalNotice = { timestamp: string; tone: "info" | "error" | "success"; text: string; + // Session the notice belongs to, captured at creation. Notices are global UI + // feedback, but most ("Model set to…", "Attached clipboard image.") describe an + // action in a specific chat, so they are only rendered in that chat's transcript. + // null = session-less feedback that falls back to the active chat. + sessionId?: string | null; }; export type MentionSuggestion = { From 7fb48a5db0ec44007074cd34dc477b94daecae23 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Mon, 15 Jun 2026 12:34:25 -0400 Subject: [PATCH 02/10] fix(ade-code): trust remote lane worktree availability --- .../tuiClient/__tests__/appPolling.test.tsx | 45 +++++++++++++++++- apps/ade-cli/src/tuiClient/app.tsx | 47 ++++++++++++------- .../main/services/lanes/laneService.test.ts | 46 ++++++++++++++++++ .../src/main/services/lanes/laneService.ts | 24 ++++++++++ apps/desktop/src/shared/types/lanes.ts | 1 + 5 files changed, 146 insertions(+), 17 deletions(-) diff --git a/apps/ade-cli/src/tuiClient/__tests__/appPolling.test.tsx b/apps/ade-cli/src/tuiClient/__tests__/appPolling.test.tsx index d1408b922..c5377e200 100644 --- a/apps/ade-cli/src/tuiClient/__tests__/appPolling.test.tsx +++ b/apps/ade-cli/src/tuiClient/__tests__/appPolling.test.tsx @@ -52,7 +52,7 @@ vi.mock("../adeApi", async () => { }; }); -import { AdeCodeApp, shouldHydrateRefreshHistory } from "../app"; +import { AdeCodeApp, isLaneWorktreeAvailable, shouldHydrateRefreshHistory } from "../app"; function lane(overrides: Partial = {}): LaneSummary { return { @@ -197,6 +197,49 @@ describe("AdeCodeApp polling", () => { instance.unmount(); }); + + it("starts remote project launches from remote context instead of local saved lane state", async () => { + mocks.listLanes.mockResolvedValue([ + lane({ + id: "lane-1", + name: "saved lane", + laneType: "worktree", + branchRef: "saved/client-lane", + worktreePath: "/remote/repo/.ade/worktrees/saved-client-lane", + }), + lane({ + id: "main", + name: "main", + laneType: "primary", + branchRef: "main", + worktreePath: "/remote/repo", + }), + ]); + mocks.listChatSessions.mockResolvedValue([]); + + const instance = render(); + await flushAsyncEffects(); + + const frame = instance.lastFrame() ?? ""; + expect(frame).toContain("branch"); + expect(frame).toContain("main"); + expect(frame).not.toContain("saved/client-lane"); + expect(mocks.startTuiHeartbeat).not.toHaveBeenCalled(); + + instance.unmount(); + }); +}); + +describe("isLaneWorktreeAvailable", () => { + it("does not mark a remote-only path missing just because it is absent locally", () => { + const remoteLane = lane({ + worktreePath: `/tmp/ade-remote-only-${Date.now()}`, + }); + + expect(isLaneWorktreeAvailable(remoteLane)).toBe(false); + expect(isLaneWorktreeAvailable(remoteLane, { remote: true })).toBe(true); + expect(isLaneWorktreeAvailable({ ...remoteLane, worktreeAvailable: false }, { remote: true })).toBe(false); + }); }); describe("shouldHydrateRefreshHistory", () => { diff --git a/apps/ade-cli/src/tuiClient/app.tsx b/apps/ade-cli/src/tuiClient/app.tsx index 4223e7be2..3e9f484f2 100644 --- a/apps/ade-cli/src/tuiClient/app.tsx +++ b/apps/ade-cli/src/tuiClient/app.tsx @@ -1111,9 +1111,16 @@ function normalizeWorktreePath(root: string): string { } } -export function isLaneWorktreeAvailable(lane: LaneSummary | null | undefined): boolean { +export function isLaneWorktreeAvailable( + lane: LaneSummary | null | undefined, + options: { remote?: boolean } = {}, +): boolean { const root = lane?.worktreePath?.trim(); if (!root) return false; + if (typeof lane?.worktreeAvailable === "boolean") { + return lane.worktreeAvailable; + } + if (options.remote) return true; const resolvedRoot = normalizeWorktreePath(root); let stat: fs.Stats; try { @@ -2601,6 +2608,7 @@ function resolveCenterPaneWidth(columns: number, drawerOpen: boolean, rightPaneW } export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath, preferServiceRepair, remote }: AdeCodeAppProps) { + const remoteLaunch = remote === true || project.remote === true; const { exit } = useApp(); const [columns, rows] = useTerminalDimensions(); useTerminalAlternateScreen(); @@ -2817,7 +2825,11 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath, const draftSeededFromHistoryRef = useRef(false); const initialNewChatPreviewRef = useRef(true); const attachProbeInFlightRef = useRef(false); - const [initialAdeCodeState] = useState(() => scopedAdeCodeState(loadAdeCodeState(), project.projectRoot)); + const [initialAdeCodeState] = useState(() => ( + remoteLaunch + ? { lastChatByLane: {}, lastLaneId: null } + : scopedAdeCodeState(loadAdeCodeState(), project.projectRoot) + )); const lastChatByLaneRef = useRef>(new Map(Object.entries(initialAdeCodeState.lastChatByLane))); const lastLaneIdRef = useRef(initialAdeCodeState.lastLaneId); const lastChatByLaneWriteTimerRef = useRef(null); @@ -2938,6 +2950,7 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath, const streaming = activeSessionId ? !!streamingBySessionId[activeSessionId] : false; const persistAdeCodeState = useCallback(() => { + if (remoteLaunch) return; if (lastChatByLaneWriteTimerRef.current) { clearTimeout(lastChatByLaneWriteTimerRef.current); } @@ -2949,7 +2962,7 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath, } saveAdeCodeProjectState(project.projectRoot, { lastChatByLane, lastLaneId: lastLaneIdRef.current }); }, 500); - }, [project.projectRoot]); + }, [project.projectRoot, remoteLaunch]); const setChatScrollOffset = useCallback((value: number | ((previous: number) => number)) => { const multiSessionId = (gridViewActiveRef.current ? focusedSessionIdForMultiView(multiViewRef.current) : null); @@ -3345,10 +3358,10 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath, const unavailableLaneIds = useMemo(() => { const ids = new Set(); for (const lane of lanes) { - if (!isLaneWorktreeAvailable(lane)) ids.add(lane.id); + if (!isLaneWorktreeAvailable(lane, { remote: remoteLaunch })) ids.add(lane.id); } return ids; - }, [lanes]); + }, [lanes, remoteLaunch]); const drawerLane = useMemo( () => lanes.find((lane) => lane.id === drawerLaneId) ?? null, [drawerLaneId, lanes], @@ -6433,12 +6446,12 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath, let cancelled = false; void (async () => { try { - const conn = await connectToAde({ project, forceEmbedded, requireSocket, socketPath, preferServiceRepair, remote }); + const conn = await connectToAde({ project, forceEmbedded, requireSocket, socketPath, preferServiceRepair, remote: remoteLaunch }); if (cancelled) { await conn.close(); return; } - heartbeatRef.current = remote + heartbeatRef.current = remoteLaunch ? null : startTuiHeartbeat(project.projectRoot, { beforeSignalExit: () => { @@ -6471,11 +6484,13 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath, if (lastChatByLaneWriteTimerRef.current) { clearTimeout(lastChatByLaneWriteTimerRef.current); lastChatByLaneWriteTimerRef.current = null; - const lastChatByLane: Record = {}; - for (const [laneId, sessionId] of lastChatByLaneRef.current) { - lastChatByLane[laneId] = sessionId; + if (!remoteLaunch) { + const lastChatByLane: Record = {}; + for (const [laneId, sessionId] of lastChatByLaneRef.current) { + lastChatByLane[laneId] = sessionId; + } + saveAdeCodeProjectState(project.projectRoot, { lastChatByLane, lastLaneId: lastLaneIdRef.current }); } - saveAdeCodeProjectState(project.projectRoot, { lastChatByLane, lastLaneId: lastLaneIdRef.current }); } if (pendingModelCommitTimerRef.current) { clearTimeout(pendingModelCommitTimerRef.current); @@ -6490,7 +6505,7 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath, connectionRef.current = null; void conn?.close().catch(() => {}); }; - }, [forceEmbedded, preferServiceRepair, project, remote, requireSocket, signalActiveTerminalForExit, signalActiveTerminalForExitSync, socketPath]); + }, [forceEmbedded, preferServiceRepair, project, remoteLaunch, 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 @@ -6886,7 +6901,7 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath, requireSocket: true, socketPath, preferServiceRepair, - remote, + remote: remoteLaunch, }); if (attached.mode !== "attached") { await attached.close().catch(() => {}); @@ -6911,7 +6926,7 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath, })(); }, 3_000); return () => clearInterval(timer); - }, [addNotice, connection, connectionLost, forceEmbedded, mode, preferServiceRepair, project, refreshState, socketPath, streaming]); + }, [addNotice, connection, connectionLost, forceEmbedded, mode, preferServiceRepair, project, refreshState, remoteLaunch, socketPath, streaming]); // First-send draft commit → Chat Info. While a draft new chat is being set // up, the right pane shows the new-chat setup surface (model-picker, surface @@ -8503,7 +8518,7 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath, addNotice(result.message ?? "Desktop route unavailable; launched ADE.", "info"); for (let attempt = 0; attempt < 8; attempt += 1) { await delay(750); - const attached = await connectToAde({ project, forceEmbedded: false, socketPath, preferServiceRepair, remote }).catch(() => null); + const attached = await connectToAde({ project, forceEmbedded: false, socketPath, preferServiceRepair, remote: remoteLaunch }).catch(() => null); if (!attached || attached.mode !== "attached") { await attached?.close().catch(() => {}); continue; @@ -8526,7 +8541,7 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath, addNotice(result.message ?? "Desktop route unavailable from this runtime.", "error"); } } - }, [activeSession?.provider, addNotice, applyLocalModelArg, clearOlderHistoryCursor, displaySessions, loadProviderModels, modelState.provider, pendingSteers, preferServiceRepair, project, refreshAiSetupStatus, refreshState, requestAppExit, scheduleModelStateCommit, sendClaudeModelCommandToTerminal, setChatScrollOffset, socketPath]); + }, [activeSession?.provider, addNotice, applyLocalModelArg, clearOlderHistoryCursor, displaySessions, loadProviderModels, modelState.provider, pendingSteers, preferServiceRepair, project, refreshAiSetupStatus, refreshState, remoteLaunch, requestAppExit, scheduleModelStateCommit, sendClaudeModelCommandToTerminal, setChatScrollOffset, socketPath]); const submitRightForm = useCallback(async ( form: Extract, diff --git a/apps/desktop/src/main/services/lanes/laneService.test.ts b/apps/desktop/src/main/services/lanes/laneService.test.ts index de1d8dacb..4c93267f9 100644 --- a/apps/desktop/src/main/services/lanes/laneService.test.ts +++ b/apps/desktop/src/main/services/lanes/laneService.test.ts @@ -108,6 +108,52 @@ describe("laneService createFromUnstaged", () => { vi.mocked(runGitOrThrow).mockReset(); }); + it("includes worktree availability in lane summaries", async () => { + const repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-lane-service-worktree-available-")); + const db = await openKvDb(path.join(repoRoot, "kv.sqlite"), createLogger()); + await seedProjectAndStack(db, { projectId: "proj-worktree-available", repoRoot }); + const missingChildPath = path.join(repoRoot, "child"); + + vi.mocked(runGit).mockImplementation(async (args: string[], opts?: { cwd?: string }) => { + const laneBranchGitStub = defaultLaneBranchGitStub(args); + if (laneBranchGitStub) return laneBranchGitStub; + const cwd = opts?.cwd ?? repoRoot; + if (args[0] === "worktree" && args[1] === "list") return { exitCode: 0, stdout: "", stderr: "" }; + if (args[0] === "rev-parse" && args[1] === "--abbrev-ref" && args[2] === "HEAD") { + return { exitCode: 0, stdout: "main\n", stderr: "" }; + } + if (args[0] === "rev-parse" && args[1] === "--path-format=absolute" && args[2] === "--show-toplevel") { + return cwd === missingChildPath + ? { exitCode: 128, stdout: "", stderr: "missing worktree" } + : { exitCode: 0, stdout: `${cwd}\n`, stderr: "" }; + } + if (args[0] === "rev-parse" && args[1] === "--verify") return { exitCode: 1, stdout: "", stderr: "" }; + if (args[0] === "status") return { exitCode: 0, stdout: "", stderr: "" }; + if (args[0] === "rev-list" && args[1] === "--left-right") return { exitCode: 0, stdout: "0\t0\n", stderr: "" }; + if (args[0] === "rev-parse" && args[1] === "--abbrev-ref" && args.includes("@{upstream}")) { + return { exitCode: 1, stdout: "", stderr: "" }; + } + if (args[0] === "rev-parse" && args[1] === "--path-format=absolute" && args[2] === "--git-dir") { + return { exitCode: 1, stdout: "", stderr: "" }; + } + throw new Error(`Unexpected git call: ${args.join(" ")}`); + }); + + const service = createLaneService({ + db, + projectRoot: repoRoot, + projectId: "proj-worktree-available", + defaultBaseRef: "main", + worktreesDir: path.join(repoRoot, "worktrees"), + }); + + const lanes = await service.list({ includeStatus: true }); + + expect(lanes.find((lane) => lane.id === "lane-main")?.worktreeAvailable).toBe(true); + expect(lanes.find((lane) => lane.id === "lane-parent")?.worktreeAvailable).toBe(true); + expect(lanes.find((lane) => lane.id === "lane-child")?.worktreeAvailable).toBe(false); + }); + it("recreates the primary lane when the only stored primary lane is archived", async () => { const repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-lane-service-primary-archived-")); const db = await openKvDb(path.join(repoRoot, "kv.sqlite"), createLogger()); diff --git a/apps/desktop/src/main/services/lanes/laneService.ts b/apps/desktop/src/main/services/lanes/laneService.ts index a2b302aa1..f46606429 100644 --- a/apps/desktop/src/main/services/lanes/laneService.ts +++ b/apps/desktop/src/main/services/lanes/laneService.ts @@ -506,6 +506,7 @@ function toLaneSummary(args: { parentStatus: LaneStatus | null; childCount: number; stackDepth: number; + worktreeAvailable?: boolean; activeBranchProfile?: LaneBranchProfile | null; linearIssue?: LaneLinearIssue | null; linearIssueLinks?: LaneLinearIssueLink[]; @@ -520,6 +521,7 @@ function toLaneSummary(args: { baseRef: row.base_ref, branchRef: row.branch_ref, worktreePath: row.worktree_path, + ...(typeof args.worktreeAvailable === "boolean" ? { worktreeAvailable: args.worktreeAvailable } : {}), attachedRootPath: row.attached_root_path, parentLaneId: row.parent_lane_id, childCount, @@ -2335,6 +2337,7 @@ export function createLaneService({ const rowsById = new Map(contextRows.map((row) => [row.id, row] as const)); const depthMemo = new Map(); const statusCache = new Map(); + const worktreeAvailabilityCache = new Map(); const childCountMap = new Map(); // Fetch all lane_linear_issues in a single query and build a map keyed by @@ -2419,11 +2422,25 @@ export function createLaneService({ ); } + const resolveWorktreeAvailable = async (row: LaneRow): Promise => { + const cached = worktreeAvailabilityCache.get(row.id); + if (cached != null) return cached; + const available = await isExpectedGitWorktreeRoot(row.worktree_path); + worktreeAvailabilityCache.set(row.id, available); + return available; + }; + const resolveStatus = async (laneId: string): Promise => { const cached = statusCache.get(laneId); if (cached) return cached; const row = rowsById.get(laneId); if (!row) return DEFAULT_LANE_STATUS; + const worktreeAvailable = await resolveWorktreeAvailable(row); + if (!worktreeAvailable) { + const status = cloneLaneStatus(DEFAULT_LANE_STATUS); + statusCache.set(laneId, status); + return status; + } const parent = row.parent_lane_id ? rowsById.get(row.parent_lane_id) : null; const queueOverride = queueOverrideCache.get(row.id) ?? null; let baseRef = queueOverride?.comparisonRef ?? (rowTracksParent(row, parent) ? parent?.branch_ref ?? row.base_ref : row.base_ref); @@ -2460,8 +2477,14 @@ export function createLaneService({ try { let status: LaneStatus = cloneLaneStatus(DEFAULT_LANE_STATUS); let parentStatus: LaneStatus | null = row.parent_lane_id ? cloneLaneStatus(DEFAULT_LANE_STATUS) : null; + let worktreeAvailable: boolean | undefined; if (includeStatus) { + try { + worktreeAvailable = await resolveWorktreeAvailable(row); + } catch { + worktreeAvailable = false; + } try { status = await resolveStatus(row.id); } catch { @@ -2491,6 +2514,7 @@ export function createLaneService({ parentStatus, childCount: childCountMap.get(row.id) ?? 0, stackDepth, + worktreeAvailable, activeBranchProfile: ensureBranchProfileForRow(row), linearIssue: linearIssueByLaneId.get(row.id) ?? null, linearIssueLinks: linearIssueLinksByLaneId.get(row.id) ?? [], diff --git a/apps/desktop/src/shared/types/lanes.ts b/apps/desktop/src/shared/types/lanes.ts index 526c8bad8..a6eadbc3d 100644 --- a/apps/desktop/src/shared/types/lanes.ts +++ b/apps/desktop/src/shared/types/lanes.ts @@ -42,6 +42,7 @@ export type LaneSummary = { baseRef: string; branchRef: string; worktreePath: string; + worktreeAvailable?: boolean; attachedRootPath?: string | null; parentLaneId: string | null; childCount: number; From a7a7adeb7e6000349befbe1e3fcf5c5a583766df Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Mon, 15 Jun 2026 12:52:36 -0400 Subject: [PATCH 03/10] follow up fix --- .../src/tuiClient/__tests__/ChatView.test.tsx | 14 ++++ .../src/tuiClient/__tests__/aggregate.test.ts | 28 ++++++++ apps/ade-cli/src/tuiClient/aggregate.ts | 12 +++- apps/ade-cli/src/tuiClient/format.ts | 4 ++ .../services/chat/agentChatService.test.ts | 53 +++++++++++++++ .../main/services/chat/agentChatService.ts | 50 ++++++++++++-- .../components/chat/AgentChatMessageList.tsx | 6 +- apps/desktop/src/shared/types/chat.ts | 6 ++ apps/ios/ADE/Models/RemoteModels.swift | 21 +++++- .../Views/Work/WorkChatRichCardViews.swift | 2 +- .../Work/WorkContextCompactDivider.swift | 68 +++++++++++++------ .../Work/WorkErrorAndMessageHelpers.swift | 4 +- .../ios/ADE/Views/Work/WorkEventMapping.swift | 12 +++- apps/ios/ADE/Views/Work/WorkModels.swift | 11 ++- .../ADE/Views/Work/WorkTimelineHelpers.swift | 32 +++++++-- .../ADE/Views/Work/WorkTranscriptParser.swift | 8 ++- 16 files changed, 288 insertions(+), 43 deletions(-) diff --git a/apps/ade-cli/src/tuiClient/__tests__/ChatView.test.tsx b/apps/ade-cli/src/tuiClient/__tests__/ChatView.test.tsx index 4ae897f2e..4901940d4 100644 --- a/apps/ade-cli/src/tuiClient/__tests__/ChatView.test.tsx +++ b/apps/ade-cli/src/tuiClient/__tests__/ChatView.test.tsx @@ -306,6 +306,20 @@ describe("ChatView", () => { expect(frame).not.toContain("waiting for runtime events"); }); + it("renders a generic context_compact begin (Claude/OpenCode) as an active state", () => { + const frame = renderEvents([ + { + sessionId: "s1", + timestamp: "2026-01-01T12:00:00.000Z", + sequence: 1, + event: { type: "context_compact", state: "started", trigger: "auto", turnId: "turn-active" }, + }, + ], { width: 80 }); + + expect(frame).toContain("compacting context"); + expect(frame).not.toContain("model working"); + }); + it("renders queued steer messages as staged instead of normal sent bubbles", () => { const frame = renderEvents([ { diff --git a/apps/ade-cli/src/tuiClient/__tests__/aggregate.test.ts b/apps/ade-cli/src/tuiClient/__tests__/aggregate.test.ts index 8524a9281..5c62a3586 100644 --- a/apps/ade-cli/src/tuiClient/__tests__/aggregate.test.ts +++ b/apps/ade-cli/src/tuiClient/__tests__/aggregate.test.ts @@ -177,6 +177,34 @@ describe("aggregateChatBlocks typed groups", () => { expect(activity!.entries[0]).toMatchObject({ label: "compacting memory", detail: "trimming context" }); }); + it("collapses a context_compact begin→end into one block that flips live→done", () => { + const events: AgentChatEventEnvelope[] = [ + env("2026-01-01T12:00:00.000Z", { type: "context_compact", trigger: "auto", state: "started", turnId: "turn-1" }), + env("2026-01-01T12:00:02.000Z", { type: "context_compact", trigger: "auto", state: "completed", preTokens: 120_000, turnId: "turn-1" }), + ]; + const blocks = aggregate(events).filter((b) => b.kind === "compaction"); + expect(blocks).toHaveLength(1); + expect(blocks[0]).toMatchObject({ kind: "compaction", live: false, trigger: "auto", preTokens: 120_000 }); + }); + + it("renders a context_compact begin as a live (in-progress) compaction block", () => { + const events: AgentChatEventEnvelope[] = [ + env("2026-01-01T12:00:00.000Z", { type: "context_compact", trigger: "manual", state: "started", turnId: "turn-1" }), + ]; + const blocks = aggregate(events).filter((b) => b.kind === "compaction"); + expect(blocks).toHaveLength(1); + expect(blocks[0]).toMatchObject({ kind: "compaction", live: true, trigger: "manual" }); + }); + + it("treats a stateless context_compact as a completed (done) block", () => { + const events: AgentChatEventEnvelope[] = [ + env("2026-01-01T12:00:00.000Z", { type: "context_compact", trigger: "auto", turnId: "turn-1" }), + ]; + const blocks = aggregate(events).filter((b) => b.kind === "compaction"); + expect(blocks).toHaveLength(1); + expect(blocks[0]).toMatchObject({ kind: "compaction", live: false }); + }); + it("keeps one tool-calls-group when activity status events are interleaved", () => { const events: AgentChatEventEnvelope[] = [ env("2026-01-01T12:00:00.000Z", { type: "activity", activity: "tool_calling", detail: "Processing tool input", turnId: "turn-1" }), diff --git a/apps/ade-cli/src/tuiClient/aggregate.ts b/apps/ade-cli/src/tuiClient/aggregate.ts index 4201f60cc..32c646a62 100644 --- a/apps/ade-cli/src/tuiClient/aggregate.ts +++ b/apps/ade-cli/src/tuiClient/aggregate.ts @@ -792,12 +792,22 @@ export function aggregateChatBlocks(args: { continue; } if (event.type === "context_compact") { + // Runtimes that expose a begin signal send state:"started" then "completed"; + // flip the existing block live→done on completion instead of stacking a second + // block. Legacy/completion-only sources omit state and render as done. + const existing = event.state ? findLastBlock(blocks, "compaction", turnId) : null; + if (existing && event.state === "completed") { + existing.live = false; + existing.trigger = event.trigger; + if (event.preTokens !== undefined) existing.preTokens = event.preTokens; + continue; + } blocks.push({ kind: "compaction", id, turnId, trigger: event.trigger, - live: false, + live: event.state === "started", preTokens: event.preTokens, }); continue; diff --git a/apps/ade-cli/src/tuiClient/format.ts b/apps/ade-cli/src/tuiClient/format.ts index bddf04eda..340cbb69e 100644 --- a/apps/ade-cli/src/tuiClient/format.ts +++ b/apps/ade-cli/src/tuiClient/format.ts @@ -603,6 +603,10 @@ export function renderChatLines(args: { continue; } if (event.type === "context_compact") { + if (event.state === "started") { + lines.push({ id, tone: "notice", body: `⟳ compacting · ${event.trigger}` }); + continue; + } const preTokens = typeof event.preTokens === "number" ? ` · before ${event.preTokens.toLocaleString()} tokens` : ""; lines.push({ id, diff --git a/apps/desktop/src/main/services/chat/agentChatService.test.ts b/apps/desktop/src/main/services/chat/agentChatService.test.ts index 20ca83dda..19fec45e2 100644 --- a/apps/desktop/src/main/services/chat/agentChatService.test.ts +++ b/apps/desktop/src/main/services/chat/agentChatService.test.ts @@ -4738,6 +4738,59 @@ describe("createAgentChatService", () => { expect(flushedSends).toHaveLength(0); }); + it("emits a context_compact begin (started) when Claude reports compacting status", async () => { + const send = vi.fn().mockResolvedValue(undefined); + const setPermissionMode = vi.fn().mockResolvedValue(undefined); + let streamCall = 0; + const stream = vi.fn(() => (async function* () { + streamCall += 1; + if (streamCall === 1) { + yield { type: "system", subtype: "init", session_id: "sdk-session-compacting", slash_commands: [] }; + yield { type: "result", usage: { input_tokens: 1, output_tokens: 1 } }; + return; + } + // Begin: SDK status flips to "compacting" before the boundary lands. + yield { type: "system", subtype: "status", session_id: "sdk-session-compacting", status: "compacting" }; + // End: the compact boundary marks completion with the real trigger/tokens. + yield { + type: "system", + subtype: "compact_boundary", + session_id: "sdk-session-compacting", + compact_metadata: { trigger: "manual", pre_tokens: 120_000 }, + }; + yield { type: "result", usage: { input_tokens: 1, output_tokens: 1 } }; + })()); + vi.mocked(claudeSdkCreateSessionCompat).mockReturnValue({ + send, + stream, + close: vi.fn(), + sessionId: "sdk-session-compacting", + setPermissionMode, + } as any); + + const onEvent = vi.fn(); + const { service } = createService({ onEvent }); + const session = await service.createSession({ laneId: "lane-1", provider: "claude", model: "sonnet" }); + await service.runSessionTurn({ sessionId: session.id, text: "keep going", timeoutMs: 15_000 }); + await new Promise((resolve) => setTimeout(resolve, 25)); + + const compactEvents = onEvent.mock.calls + .map((call) => call[0]) + .filter((env: any) => env?.event?.type === "context_compact") + .map((env: any) => env.event); + // A live begin, then a completed end — no longer a plain gray "Compacting..." notice. + expect(compactEvents).toEqual([ + expect.objectContaining({ type: "context_compact", state: "started" }), + expect.objectContaining({ type: "context_compact", state: "completed", trigger: "manual", preTokens: 120_000 }), + ]); + const compactingNotices = onEvent.mock.calls + .map((call) => call[0]) + .filter((env: any) => env?.event?.type === "system_notice" + && typeof env.event.message === "string" + && env.event.message.includes("Compacting conversation context")); + expect(compactingNotices).toHaveLength(0); + }); + it("emits a rate-limit notice when the Claude SDK reports usage pressure", async () => { vi.useFakeTimers(); const send = vi.fn().mockResolvedValue(undefined); diff --git a/apps/desktop/src/main/services/chat/agentChatService.ts b/apps/desktop/src/main/services/chat/agentChatService.ts index d25dfd4da..4934a13c0 100644 --- a/apps/desktop/src/main/services/chat/agentChatService.ts +++ b/apps/desktop/src/main/services/chat/agentChatService.ts @@ -831,6 +831,12 @@ type OpenCodeRuntime = { toolStateByPartId: Map; /** IDs of OpenCode child sessions already announced as subagents this run. */ subagentSessionIds: Set; + /** + * Trigger (manual/auto) captured from the most recent compaction "begin" part, so + * the matching session.compacted end event can report the same trigger. Cleared + * once consumed. + */ + lastCompactionTrigger: "manual" | "auto" | null; }; type CursorPermissionWaiter = @@ -7778,6 +7784,7 @@ export function createAgentChatService(args: { reasoningByPartId: new Map(), toolStateByPartId: new Map(), subagentSessionIds: new Set(), + lastCompactionTrigger: null, }; handle.setEvictionHandler((reason) => { if (managed.runtime?.kind === "opencode" && managed.runtime.handle === handle) { @@ -11485,23 +11492,27 @@ export function createAgentChatService(args: { } } if (statusMsg.status === "compacting") { + // Begin marker so the UI shows a live "compacting…" indicator instead of + // feeling stuck until the compact_boundary lands. The real trigger arrives + // with the boundary (completed) event, so default to "auto" here. emitChatEvent(managed, { - type: "system_notice", - noticeKind: "info", - message: "Compacting conversation context...", + type: "context_compact", + trigger: "auto", + state: "started", turnId, }); } continue; } - // system:compact_boundary — context window compaction + // system:compact_boundary — context window compaction (end marker) if (msg.type === "system" && (msg as any).subtype === "compact_boundary") { const compactMsg = msg as any; emitChatEvent(managed, { type: "context_compact", trigger: compactMsg.compact_metadata?.trigger === "manual" ? "manual" : "auto", preTokens: typeof compactMsg.compact_metadata?.pre_tokens === "number" ? compactMsg.compact_metadata.pre_tokens : undefined, + state: "completed", turnId, }); // Re-inject identity context after compaction so identity-backed @@ -13253,11 +13264,15 @@ export function createAgentChatService(args: { } if (event.type === "session.compacted") { + // End marker. The begin is emitted from the "compaction" message part below + // (which also carries the real manual/auto trigger). emitChatEvent(managed, { type: "context_compact", - trigger: "auto", + trigger: runtime.lastCompactionTrigger ?? "auto", + state: "completed", turnId, }); + runtime.lastCompactionTrigger = null; continue; } @@ -13265,6 +13280,22 @@ export function createAgentChatService(args: { const { part, delta } = event.properties; markFirstStreamEvent(part.type); + // Compaction begin marker. OpenCode has no dedicated "started" event, but it + // streams a compaction part as soon as it begins summarizing; the matching + // session.compacted lands when it finishes. Surface this as a live begin so + // the chat shows "compacting…" instead of feeling stuck. + if (part.type === "compaction") { + const trigger = (part as { auto?: boolean }).auto === false ? "manual" : "auto"; + runtime.lastCompactionTrigger = trigger; + emitChatEvent(managed, { + type: "context_compact", + trigger, + state: "started", + turnId, + }); + continue; + } + if (part.type === "step-start") { stepNumber += 1; emitChatEvent(managed, { @@ -15675,10 +15706,19 @@ export function createAgentChatService(args: { { markStarted: false }, ); } + // Context-compaction items can arrive at a turn boundary (e.g. auto-compaction as + // a turn winds down) carrying a turnId that no longer matches the active lifecycle + // turn. Never drop them — otherwise the "compacting…" begin (and its matching end) + // never reaches the UI and a long compaction looks like the agent is stuck. + const isContextCompactionItem = + (method === "item/started" || method === "item/completed" + || method === "codex/event/item_started" || method === "codex/event/item_completed") + && stringOrNull((asRecord(params.item) ?? params).type) === "contextCompaction"; if ( turnIdFromParams && !isExpectedTurnStart && !isResumedInProgressTurnStart + && !isContextCompactionItem && !isCurrentCodexLifecycleTurn(runtime, turnIdFromParams) ) { logger.warn(`[codex] ignoring ${method} for inactive turn ${turnIdFromParams} in session ${managed.session.id}`); diff --git a/apps/desktop/src/renderer/components/chat/AgentChatMessageList.tsx b/apps/desktop/src/renderer/components/chat/AgentChatMessageList.tsx index 231753cd8..7c1baca4c 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatMessageList.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatMessageList.tsx @@ -2485,14 +2485,16 @@ function renderEvent( return ; } - /* ── Legacy Context Compact (kept for any pre-A.3 transcripts) ── */ + /* ── Generic Context Compact (Claude/OpenCode; legacy pre-A.3 transcripts) ── */ if (event.type === "context_compact") { + // Honor the begin/end lifecycle when the runtime provides it so a live + // "compacting…" chip shows; sources without state render as completed. return ( String { return ["tokens", turnId, itemId ?? "", workUsageSummaryMergeKey(usage)].joined(separator: "|") case .promptSuggestion(let text, let turnId): return ["prompt_suggestion", turnId ?? "", text].joined(separator: "|") - case .contextCompact(let summary, let turnId): - return ["context_compact", turnId ?? "", summary].joined(separator: "|") + case .contextCompact(let summary, let isInProgress, let turnId): + return ["context_compact", turnId ?? "", isInProgress ? "started" : "completed", summary].joined(separator: "|") case .autoApprovalReview(let summary, let turnId): return ["auto_approval_review", turnId ?? "", summary].joined(separator: "|") case .webSearch(let query, let action, let status, let itemId, let turnId): diff --git a/apps/ios/ADE/Views/Work/WorkEventMapping.swift b/apps/ios/ADE/Views/Work/WorkEventMapping.swift index c437c5b78..1a5cb6b0e 100644 --- a/apps/ios/ADE/Views/Work/WorkEventMapping.swift +++ b/apps/ios/ADE/Views/Work/WorkEventMapping.swift @@ -195,9 +195,17 @@ func makeWorkChatEvent(from event: AgentChatEvent) -> WorkChatEvent { ) case .promptSuggestion(let suggestion, let turnId): return .promptSuggestion(text: suggestion, turnId: turnId) - case .contextCompact(let trigger, let preTokens, let turnId): + case .contextCompact(let trigger, let preTokens, let state, let turnId): + // A missing state is a legacy end-only event; treat it as completed so the + // existing "Context compacted" divider renders unchanged. + let isInProgress = state == .started let summary = [trigger.rawValue.capitalized, preTokens.map { "Pre-compact tokens: \($0)" }].compactMap { $0 }.joined(separator: "\n") - return .contextCompact(summary: summary, turnId: turnId) + return .contextCompact(summary: summary, isInProgress: isInProgress, turnId: turnId) + case .codexContextCompaction(let state, let trigger, let turnId): + // Codex's compaction shares the generic divider: begin shows a live + // "Compacting context…" indicator, completed settles to "Context compacted". + let summary = trigger.rawValue.capitalized + return .contextCompact(summary: summary, isInProgress: state == .started, turnId: turnId) case .autoApprovalReview(_, let reviewStatus, let action, let review, let turnId): let summary = [reviewStatus.rawValue.capitalized, action, review].compactMap { $0 }.joined(separator: "\n") return .autoApprovalReview(summary: summary, turnId: turnId) diff --git a/apps/ios/ADE/Views/Work/WorkModels.swift b/apps/ios/ADE/Views/Work/WorkModels.swift index 77caf8249..c8772b5f4 100644 --- a/apps/ios/ADE/Views/Work/WorkModels.swift +++ b/apps/ios/ADE/Views/Work/WorkModels.swift @@ -407,6 +407,11 @@ struct WorkEventCardModel: Identifiable, Equatable { /// Populated for `kind == "plan"`. Each step keeps its status so the rich plan /// card can paint per-step checkmarks/colors instead of prefixed bullets. let planSteps: [WorkPlanStep] + /// Set for lifecycle-style cards (e.g. `kind == "contextCompact"`) that begin + /// in a live state and later settle. When true the card renders its + /// in-progress affordance (spinner + "Compacting context…"); once the host + /// emits the completed event the merged card flips this back to false. + let isInProgress: Bool init( id: String, @@ -418,7 +423,8 @@ struct WorkEventCardModel: Identifiable, Equatable { body: String?, bullets: [String], metadata: [String], - planSteps: [WorkPlanStep] = [] + planSteps: [WorkPlanStep] = [], + isInProgress: Bool = false ) { self.id = id self.kind = kind @@ -430,6 +436,7 @@ struct WorkEventCardModel: Identifiable, Equatable { self.bullets = bullets self.metadata = metadata self.planSteps = planSteps + self.isInProgress = isInProgress } } @@ -518,7 +525,7 @@ enum WorkChatEvent: Equatable { case done(status: String, summary: String, usage: WorkUsageSummary?, turnId: String, model: String?, modelId: String?) case tokens(usage: WorkUsageSummary, turnId: String, itemId: String?) case promptSuggestion(text: String, turnId: String?) - case contextCompact(summary: String, turnId: String?) + case contextCompact(summary: String, isInProgress: Bool, turnId: String?) case autoApprovalReview(summary: String, turnId: String?) case webSearch(query: String, action: String?, status: WorkToolCardStatus, itemId: String, turnId: String?) case planText(text: String, turnId: String?) diff --git a/apps/ios/ADE/Views/Work/WorkTimelineHelpers.swift b/apps/ios/ADE/Views/Work/WorkTimelineHelpers.swift index a4b9d0d98..27f2cc509 100644 --- a/apps/ios/ADE/Views/Work/WorkTimelineHelpers.swift +++ b/apps/ios/ADE/Views/Work/WorkTimelineHelpers.swift @@ -683,6 +683,23 @@ private func workReasoningCardId( return fallback } +/// Stable identity for a context-compaction divider. Both the `started` and +/// `completed` events for one compaction share this id (keyed on the turn) so +/// `buildWorkEventCards` merges them into a single card that flips from the +/// live "Compacting context…" state to "Context compacted" in place. Without a +/// turnId — legacy end-only `context_compact` events — we fall back to the +/// envelope id, which preserves the prior one-card-per-event behavior. +private func workContextCompactCardId( + sessionId: String, + turnId: String?, + fallback: String +) -> String { + if let turnId, !turnId.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + return ["context-compact", sessionId, "turn", turnId].joined(separator: ":") + } + return fallback +} + private func mergeWorkInlineText(_ existing: String, _ incoming: String) -> String { if existing.isEmpty { return incoming } if incoming.isEmpty { return existing } @@ -1008,17 +1025,22 @@ private func eventCard(for envelope: WorkChatEnvelope) -> WorkEventCardModel? { bullets: [], metadata: [] ) - case .contextCompact(let summary, _): + case .contextCompact(let summary, let isInProgress, let turnId): return WorkEventCardModel( - id: envelope.id, + // Use a turn-scoped merge key so the `started` card UPDATES in place to + // `completed` (the later event wins via `mergedWorkEventCard`) instead + // of stacking two dividers. Falls back to the envelope id when the host + // omits a turnId (legacy end-only events have nothing to merge against). + id: workContextCompactCardId(sessionId: envelope.sessionId, turnId: turnId, fallback: envelope.id), kind: "contextCompact", - title: "Context compacted", + title: isInProgress ? "Compacting context…" : "Context compacted", icon: "rectangle.compress.vertical", tint: .secondary, timestamp: envelope.timestamp, body: summary, bullets: [], - metadata: [] + metadata: [], + isInProgress: isInProgress ) case .autoApprovalReview(let summary, _): return WorkEventCardModel( @@ -1263,7 +1285,7 @@ private func workTurnId(for event: WorkChatEvent) -> String? { .systemNotice(_, _, _, let turnId, _), .error(_, _, _, let turnId), .promptSuggestion(_, let turnId), - .contextCompact(_, let turnId), + .contextCompact(_, _, let turnId), .autoApprovalReview(_, let turnId), .webSearch(_, _, _, _, let turnId), .planText(_, let turnId), diff --git a/apps/ios/ADE/Views/Work/WorkTranscriptParser.swift b/apps/ios/ADE/Views/Work/WorkTranscriptParser.swift index 1e0c15eed..f33b02b89 100644 --- a/apps/ios/ADE/Views/Work/WorkTranscriptParser.swift +++ b/apps/ios/ADE/Views/Work/WorkTranscriptParser.swift @@ -325,7 +325,13 @@ func parseWorkChatTranscript(_ raw: String) -> [WorkChatEnvelope] { case "context_compact": let trigger = stringValue(eventDict["trigger"]).replacingOccurrences(of: "_", with: " ").capitalized let preTokens = optionalString(eventDict["preTokens"]) - event = .contextCompact(summary: [trigger, preTokens.map { "Pre-compact tokens: \($0)" }].compactMap { $0 }.joined(separator: "\n"), turnId: turnId) + // Missing state = legacy end-only event; render as completed. + let isInProgress = optionalString(eventDict["state"]) == "started" + event = .contextCompact(summary: [trigger, preTokens.map { "Pre-compact tokens: \($0)" }].compactMap { $0 }.joined(separator: "\n"), isInProgress: isInProgress, turnId: turnId) + case "codex_context_compaction": + let trigger = stringValue(eventDict["trigger"]).replacingOccurrences(of: "_", with: " ").capitalized + let isInProgress = optionalString(eventDict["state"]) == "started" + event = .contextCompact(summary: trigger, isInProgress: isInProgress, turnId: turnId) case "auto_approval_review": let action = optionalString(eventDict["action"]) let review = optionalString(eventDict["review"]) From 830256207d654bff84a5120e78c09f4dffae3f9c Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Mon, 15 Jun 2026 14:52:57 -0400 Subject: [PATCH 04/10] fix(ade-code): complete remote launch and bundled skills --- apps/ade-cli/README.md | 11 + apps/ade-cli/src/cli.ts | 5 + .../tuiClient/__tests__/HeaderFooter.test.tsx | 36 ++++ .../src/tuiClient/__tests__/adeApi.test.ts | 2 + .../tuiClient/__tests__/appPolling.test.tsx | 1 + .../src/tuiClient/__tests__/cli.test.tsx | 4 + .../tuiClient/__tests__/connection.test.ts | 1 + .../src/tuiClient/__tests__/feedback.test.ts | 1 + .../src/tuiClient/__tests__/project.test.ts | 2 + .../__tests__/remoteLauncher.test.ts | 126 +++++++++++ apps/ade-cli/src/tuiClient/adeApi.ts | 11 +- apps/ade-cli/src/tuiClient/app.tsx | 1 + apps/ade-cli/src/tuiClient/cli.tsx | 7 + .../tuiClient/components/FooterControls.tsx | 33 ++- .../src/tuiClient/components/Header.tsx | 8 + apps/ade-cli/src/tuiClient/project.ts | 2 + apps/ade-cli/src/tuiClient/remoteLauncher.ts | 198 +++++++++++++++--- apps/ade-cli/src/tuiClient/types.ts | 1 + apps/desktop/resources/ade-cli-help.txt | 192 ++++++++++++----- apps/desktop/scripts/regen-ade-cli-help.cjs | 2 + .../remoteRuntime/remoteBootstrap.test.ts | 89 +++++++- .../services/remoteRuntime/remoteBootstrap.ts | 158 +++++++++++++- docs/features/ade-code/README.md | 18 ++ docs/features/remote-runtime/README.md | 9 +- .../remote-runtime/internal-architecture.md | 11 +- 25 files changed, 837 insertions(+), 92 deletions(-) diff --git a/apps/ade-cli/README.md b/apps/ade-cli/README.md index e76c7674f..e535d4fd7 100644 --- a/apps/ade-cli/README.md +++ b/apps/ade-cli/README.md @@ -25,6 +25,7 @@ Default routing for typed commands: prefer the machine brain endpoint if reachab | `$ADE_HOME/projects.json` | Project catalog. | | `~/.ade/secrets/` | Machine credential store (`credentials.safe.enc` for desktop safeStorage, `credentials.json.enc` plus `.machine-key` for headless fallback storage, and per-store `*.lock` files). | | `~/.ade/bin/ade` | Bundled static runtime binary (release installs / remote uploads). | +| `~/.ade/agent-skills/` | Bundled, version-locked ADE agent skills. Desktop remote bootstrap uploads this beside the remote runtime; CLI launch then re-seeds ADE-managed skills into runtime-native home skill directories. | | `~/.ade/runtime//` | Native node modules for that runtime binary. | | `~/.ade/runtime/launchd.{out,err}.log` | Runtime stdout/stderr when running as a login service on macOS. | @@ -211,10 +212,20 @@ The `sync.connectToBrain`, `sync.disconnectFromBrain`, and `sync.transferBrainTo ade code # attach to the machine brain, auto-spawn it if missing ade code --embedded # force the in-process embedded runtime ade code --print-state # smoke-test the connection and exit +ade code remote --target mac --project ADE + # attach to a saved desktop remote machine +ade code remote session --target mac --project ADE --session chat-1 + # open a remote chat or Claude terminal session ade --socket /path/to/ade.sock code # attach to a specific local endpoint ade --project-root /repo code # bind to a specific project root ``` +`ade code remote` reads the same saved remote-machine registry as desktop ADE, +starts `ade rpc --stdio` over SSH, and bridges it back into the normal TUI with +`--remote`, `--remote-label`, `--require-socket`, remote project roots, and an +optional `--session` hint. Use `--list-targets`, `--list-projects`, and +`--list-sessions` for non-interactive discovery. + **Browser mirror (dev):** from the repo root, `npm run dev:code:web` runs **one** `ade code` in a **single PTY** and mirrors that TTY to the browser (xterm). Use Cursor’s browser tools against that page like any other local URL. This is not the same as running `ade code` in a terminal app **and** in the browser at once—that would be two separate processes. See `docs/features/ade-code/README.md` for the full attach/embedded handshake, slash command catalog, and right-pane drawers. diff --git a/apps/ade-cli/src/cli.ts b/apps/ade-cli/src/cli.ts index a60bf6d70..d25d5d835 100644 --- a/apps/ade-cli/src/cli.ts +++ b/apps/ade-cli/src/cli.ts @@ -1142,6 +1142,11 @@ const HELP_BY_COMMAND: Record = { $ ade code --require-socket Fail instead of starting an embedded runtime when no runtime endpoint exists $ ade code --socket /tmp/ade.sock Attach to a specific local endpoint $ ade code --lane Launch focused on a specific lane + $ ade code remote --target --project + Launch against a saved desktop remote machine + $ ade code remote session --target --project --session + Open a specific remote chat or Claude terminal session + $ ade code remote --list-targets List saved remote machines $ ade --project-root code Launch against a specific ADE project Keys: diff --git a/apps/ade-cli/src/tuiClient/__tests__/HeaderFooter.test.tsx b/apps/ade-cli/src/tuiClient/__tests__/HeaderFooter.test.tsx index 82936e368..39e871826 100644 --- a/apps/ade-cli/src/tuiClient/__tests__/HeaderFooter.test.tsx +++ b/apps/ade-cli/src/tuiClient/__tests__/HeaderFooter.test.tsx @@ -58,6 +58,13 @@ describe("Header", () => { expect(frame).not.toContain("GPT"); }); + it("shows the connected remote machine when launched remotely", () => { + const result = render(
); + const frame = stripAnsi(result.lastFrame() ?? ""); + + expect(frame).toContain("connected to Mac Studio"); + }); + it("suppresses the project label when it repeats the branch basename", () => { const result = render(
{ expect(frame).toContain("chat info · 2"); }); + it("renders context usage as a single circle dial, not the 10-cell bar", () => { + const result = render( + , + ); + const frame = stripAnsi(result.lastFrame() ?? ""); + + expect(frame).toContain("50%"); + // A half-filled pie-circle glyph (50% → ◑) stands in for the meter… + expect(frame).toContain("◑"); + // …and the old multi-cell bar fill is no longer in the footer. + expect(frame).not.toContain("▓▓"); + }); + + it("fills the context dial toward a solid circle as usage climbs", () => { + const frameAt = (percent: number) => { + const result = render( + , + ); + return stripAnsi(result.lastFrame() ?? ""); + }; + expect(frameAt(0)).toContain("○"); + expect(frameAt(100)).toContain("●"); + }); + it("renders the approval prompt hints when an approval is active", () => { const result = render( { { terminalId: "claude-1", toolType: "claude" }, { terminalId: "claude-orch-1", toolType: "claude-orchestrated" }, { terminalId: "legacy-claude-1", toolType: "shell", resumeMetadata: { provider: "claude" } }, + { terminalId: "legacy-claude-command-1", toolType: "shell", resumeCommand: "claude --resume session-1" }, { terminalId: "codex-1", toolType: "codex" }, { terminalId: "codex-orch-1", toolType: "codex-orchestrated" }, { terminalId: "legacy-codex-1", toolType: "shell", resumeMetadata: { provider: "codex" } }, @@ -646,6 +647,7 @@ describe("listTerminalSessions", () => { "claude-1", "claude-orch-1", "legacy-claude-1", + "legacy-claude-command-1", ]); }); }); diff --git a/apps/ade-cli/src/tuiClient/__tests__/appPolling.test.tsx b/apps/ade-cli/src/tuiClient/__tests__/appPolling.test.tsx index c5377e200..2ec80460d 100644 --- a/apps/ade-cli/src/tuiClient/__tests__/appPolling.test.tsx +++ b/apps/ade-cli/src/tuiClient/__tests__/appPolling.test.tsx @@ -120,6 +120,7 @@ describe("AdeCodeApp polling", () => { laneHint: null, sessionHint: null, remote: false, + remoteLabel: null, }; beforeEach(() => { diff --git a/apps/ade-cli/src/tuiClient/__tests__/cli.test.tsx b/apps/ade-cli/src/tuiClient/__tests__/cli.test.tsx index 14ae010c7..4feb87de5 100644 --- a/apps/ade-cli/src/tuiClient/__tests__/cli.test.tsx +++ b/apps/ade-cli/src/tuiClient/__tests__/cli.test.tsx @@ -14,6 +14,7 @@ const detectProjectLaunchContextMock = vi.hoisted(() => laneHint: null, sessionHint: null, remote: false, + remoteLabel: null, })), ); @@ -61,6 +62,8 @@ describe("ade code CLI entrypoint", () => { "lane-1", "--session", "session-1", + "--remote-label", + "Mac Studio", "--socket", "tcp://127.0.0.1:43333", "--require-socket", @@ -70,6 +73,7 @@ describe("ade code CLI entrypoint", () => { workspaceRoot: "/remote/project", laneHint: "lane-1", sessionHint: "session-1", + remoteLabel: "Mac Studio", socketPath: "tcp://127.0.0.1:43333", requireSocket: true, }); diff --git a/apps/ade-cli/src/tuiClient/__tests__/connection.test.ts b/apps/ade-cli/src/tuiClient/__tests__/connection.test.ts index 974b6b8b1..197668ebf 100644 --- a/apps/ade-cli/src/tuiClient/__tests__/connection.test.ts +++ b/apps/ade-cli/src/tuiClient/__tests__/connection.test.ts @@ -84,6 +84,7 @@ const project: ProjectLaunchContext = { laneHint: null, sessionHint: null, remote: false, + remoteLabel: null, }; const originalArgv1 = process.argv[1]; diff --git a/apps/ade-cli/src/tuiClient/__tests__/feedback.test.ts b/apps/ade-cli/src/tuiClient/__tests__/feedback.test.ts index 3180cad3b..d2170651e 100644 --- a/apps/ade-cli/src/tuiClient/__tests__/feedback.test.ts +++ b/apps/ade-cli/src/tuiClient/__tests__/feedback.test.ts @@ -88,6 +88,7 @@ describe("ADE Code feedback helpers", () => { laneHint: null, sessionHint: null, remote: false, + remoteLabel: null, }, lane()); const fields = feedbackFormFields(environment); diff --git a/apps/ade-cli/src/tuiClient/__tests__/project.test.ts b/apps/ade-cli/src/tuiClient/__tests__/project.test.ts index 1d076b102..5191a13cc 100644 --- a/apps/ade-cli/src/tuiClient/__tests__/project.test.ts +++ b/apps/ade-cli/src/tuiClient/__tests__/project.test.ts @@ -61,6 +61,7 @@ describe("chooseInitialLane", () => { laneHint: "work", sessionHint: "session-remote", remote: true, + remoteLabel: "Mac Studio", }); expect(context.projectRoot).toBe("/remote/project"); @@ -68,6 +69,7 @@ describe("chooseInitialLane", () => { expect(context.laneHint).toBe("work"); expect(context.sessionHint).toBe("session-remote"); expect(context.remote).toBe(true); + expect(context.remoteLabel).toBe("Mac Studio"); }); it("prefers the ADE worktree lane hint", () => { diff --git a/apps/ade-cli/src/tuiClient/__tests__/remoteLauncher.test.ts b/apps/ade-cli/src/tuiClient/__tests__/remoteLauncher.test.ts index 93f4e2afc..9873337d2 100644 --- a/apps/ade-cli/src/tuiClient/__tests__/remoteLauncher.test.ts +++ b/apps/ade-cli/src/tuiClient/__tests__/remoteLauncher.test.ts @@ -1,7 +1,9 @@ import { describe, expect, it } from "vitest"; import { buildRemoteRuntimeRpcCommand, + listRemoteSessions, parseRemoteAdeCodeArgs, + remoteRuntimeLayoutCandidates, takeAdeCodeRemoteArgs, } from "../remoteLauncher"; @@ -46,7 +48,131 @@ describe("ade code remote launcher", () => { expect(buildRemoteRuntimeRpcCommand(layout)).toContain('export ADE_HOME="$HOME/.ade-beta"'); expect(buildRemoteRuntimeRpcCommand(layout)).toContain('export ADE_PACKAGE_CHANNEL="beta"'); + expect(buildRemoteRuntimeRpcCommand(layout)).toContain("export ADE_DISABLE_RUNTIME_SERVICE_INSTALL=1"); expect(buildRemoteRuntimeRpcCommand(layout)).toContain('export ADE_PTY_HOST_WORKER_COMMAND="$HOME/.ade-beta/bin/ade"'); expect(buildRemoteRuntimeRpcCommand(layout)).toContain("exec $HOME/.ade-beta/bin/ade --socket $HOME/.ade-beta/sock/ade.sock rpc --stdio"); }); + + it("attaches to stable remote runtimes without repairing or installing services", () => { + const layout: Parameters[0] = { + channel: null, + homeDirName: ".ade", + homeDirExpr: "$HOME/.ade", + binDirExpr: "$HOME/.ade/bin", + runtimeDirExpr: "$HOME/.ade/runtime", + socketExpr: "$HOME/.ade/sock/ade.sock", + binaryExpr: "$HOME/.ade/bin/ade", + }; + + expect(buildRemoteRuntimeRpcCommand(layout)).toContain("export ADE_DISABLE_RUNTIME_SERVICE_INSTALL=1"); + expect(buildRemoteRuntimeRpcCommand(layout)).not.toContain("ADE_PACKAGE_CHANNEL"); + }); + + it("checks the saved remote target channel before the shared runtime home", () => { + expect(remoteRuntimeLayoutCandidates({}, "beta").map((layout) => layout.homeDirName)).toEqual([ + ".ade-beta", + ".ade", + ".ade-alpha", + ]); + expect(remoteRuntimeLayoutCandidates({}, "alpha").map((layout) => layout.homeDirName)).toEqual([ + ".ade-alpha", + ".ade", + ".ade-beta", + ]); + }); + + it("falls back to positional chat list args for older remote action adapters", async () => { + const calls: unknown[] = []; + const client = { + request: async (_method: string, params: unknown) => { + calls.push(params); + const args = (params as { arguments?: { domain?: string; action?: string; argsList?: unknown[] } }).arguments; + if (args?.domain === "chat" && args.action === "listSessions" && !args.argsList) { + return { ok: false, error: { message: "invalid lane id" } }; + } + if (args?.domain === "chat" && args.action === "listSessions" && args.argsList) { + return { + result: [{ + sessionId: "chat-1", + laneId: "lane-1", + provider: "codex", + model: "gpt-5.5", + status: "idle", + startedAt: "2026-06-15T00:00:00.000Z", + endedAt: null, + lastActivityAt: "2026-06-15T00:01:00.000Z", + lastOutputPreview: null, + summary: null, + }], + }; + } + if (args?.domain === "terminal" && args.action === "list") { + return { result: [] }; + } + throw new Error("unexpected request"); + }, + }; + + await expect(listRemoteSessions(client as never, "project-1")).resolves.toMatchObject([ + { sessionId: "chat-1", kind: "chat", title: "chat-1" }, + ]); + expect(calls).toEqual([ + expect.objectContaining({ + projectId: "project-1", + arguments: expect.objectContaining({ + domain: "chat", + action: "listSessions", + args: { includeArchived: false, includeAutomation: true }, + }), + }), + expect.objectContaining({ + projectId: "project-1", + arguments: expect.objectContaining({ + domain: "chat", + action: "listSessions", + argsList: [null, { includeArchived: false, includeAutomation: true }], + }), + }), + expect.objectContaining({ + projectId: "project-1", + arguments: expect.objectContaining({ + domain: "terminal", + action: "list", + args: { limit: 200 }, + }), + }), + ]); + }); + + it("includes legacy Claude terminals when only the resume command identifies them", async () => { + const client = { + request: async (_method: string, params: unknown) => { + const args = (params as { arguments?: { domain?: string; action?: string } }).arguments; + if (args?.domain === "chat" && args.action === "listSessions") { + return { result: [] }; + } + if (args?.domain === "terminal" && args.action === "list") { + return { + result: [ + { + terminalId: "claude-command-1", + laneId: "lane-1", + title: "Claude terminal", + status: "running", + runtimeState: "idle", + startedAt: "2026-06-15T00:00:00.000Z", + toolType: "shell", + resumeCommand: "claude --resume claude-command-1", + }, + ], + }; + } + throw new Error("unexpected request"); + }, + }; + + await expect(listRemoteSessions(client as never, "project-1")).resolves.toMatchObject([ + { sessionId: "claude-command-1", kind: "terminal", title: "Claude terminal" }, + ]); + }); }); diff --git a/apps/ade-cli/src/tuiClient/adeApi.ts b/apps/ade-cli/src/tuiClient/adeApi.ts index 82363d752..57eadd8c7 100644 --- a/apps/ade-cli/src/tuiClient/adeApi.ts +++ b/apps/ade-cli/src/tuiClient/adeApi.ts @@ -131,6 +131,14 @@ const RESUMABLE_TERMINAL_TOOL_TYPES = new Set([ "claude-orchestrated", ]); +function isClaudeTerminalSession(session: ChatTerminalSession): boolean { + const toolType = session.toolType ?? ""; + if (RESUMABLE_TERMINAL_TOOL_TYPES.has(toolType)) return true; + if (session.resumeMetadata?.provider === "claude") return true; + const resumeCommand = typeof session.resumeCommand === "string" ? session.resumeCommand.trim().toLowerCase() : ""; + return Boolean(resumeCommand && /\bclaude\b/.test(resumeCommand)); +} + export async function listTerminalSessions( connection: AdeCodeConnection, laneId?: string | null, @@ -142,8 +150,7 @@ export async function listTerminalSessions( return sessions.filter((session) => { const toolType = session.toolType ?? ""; if (CHAT_BACKED_TERMINAL_TOOL_TYPES.has(toolType)) return false; - return RESUMABLE_TERMINAL_TOOL_TYPES.has(toolType) - || session.resumeMetadata?.provider === "claude"; + return isClaudeTerminalSession(session); }); } diff --git a/apps/ade-cli/src/tuiClient/app.tsx b/apps/ade-cli/src/tuiClient/app.tsx index 3e9f484f2..0c827a8c0 100644 --- a/apps/ade-cli/src/tuiClient/app.tsx +++ b/apps/ade-cli/src/tuiClient/app.tsx @@ -13235,6 +13235,7 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath, projectName={projectName} lane={activeLane} chatTitle={draftChatActive ? "New chat" : activeTerminalSession?.title ?? activeSession?.title ?? activeSession?.goal ?? activeSession?.summary ?? null} + remoteLabel={remoteLaunch ? project.remoteLabel ?? "remote" : null} /> {goalBannerText ? ( diff --git a/apps/ade-cli/src/tuiClient/cli.tsx b/apps/ade-cli/src/tuiClient/cli.tsx index 36b69cc4c..a6eb034cd 100644 --- a/apps/ade-cli/src/tuiClient/cli.tsx +++ b/apps/ade-cli/src/tuiClient/cli.tsx @@ -14,6 +14,7 @@ type CliOptions = { laneHint: string | null; sessionHint: string | null; socketPath: string | null; + remoteLabel: string | null; preferServiceRepair: boolean; }; @@ -37,6 +38,7 @@ export function parseArgs(argv: string[]): CliOptions { laneHint: null, sessionHint: null, socketPath: null, + remoteLabel: null, preferServiceRepair: false, }; for (let i = 0; i < argv.length; i += 1) { @@ -59,6 +61,9 @@ export function parseArgs(argv: string[]): CliOptions { } else if (arg === "--session" || arg === "--chat") { options.sessionHint = readRequiredFlagValue(argv, i, arg); i += 1; + } else if (arg === "--remote-label") { + options.remoteLabel = readRequiredFlagValue(argv, i, arg); + i += 1; } else if (arg === "--socket") { options.socketPath = readRequiredFlagValue(argv, i, arg); i += 1; @@ -128,6 +133,7 @@ async function printState(options: CliOptions): Promise { laneHint: options.laneHint, sessionHint: options.sessionHint, remote: options.remote, + remoteLabel: options.remoteLabel, }); const connection = await connectToAde({ project, @@ -172,6 +178,7 @@ export async function runAdeCodeCli(argv: string[] = process.argv.slice(2)): Pro laneHint: options.laneHint, sessionHint: options.sessionHint, remote: options.remote, + remoteLabel: options.remoteLabel, }); const instance = render( = 95; + const pulseDim = pulseDanger && tick % 2 === 1; + + return ( + + {CONTEXT_DIAL_GLYPHS[idx]} + + ); +} + /** * A single picker cell. When `focused` (and the parent row is focused), the * value is wrapped in `[brackets]` and tinted with the violet accent (or the @@ -280,7 +311,7 @@ export function FooterControls({ {" "} {contextPercent != null ? ( <> - + {` ${contextPercent}%`} ) : null} diff --git a/apps/ade-cli/src/tuiClient/components/Header.tsx b/apps/ade-cli/src/tuiClient/components/Header.tsx index 9340665bc..7dc4641b4 100644 --- a/apps/ade-cli/src/tuiClient/components/Header.tsx +++ b/apps/ade-cli/src/tuiClient/components/Header.tsx @@ -21,10 +21,12 @@ export function Header({ projectName, lane, chatTitle, + remoteLabel, }: { projectName: string; lane: LaneSummary | null; chatTitle?: string | null; + remoteLabel?: string | null; }) { const laneColor = theme.lane(lane); const normalizedProject = projectName.trim().toLowerCase(); @@ -40,6 +42,7 @@ export function Header({ ); const showProject = Boolean(normalizedProject && normalizedProject !== "ade" && !projectRepeatsBranch); const chatLabel = chatTitle?.trim() || null; + const remote = remoteLabel?.trim() || null; return ( ) : null} + {remote ? ( + + connected to {remote} + + ) : null} ); } diff --git a/apps/ade-cli/src/tuiClient/project.ts b/apps/ade-cli/src/tuiClient/project.ts index cf6dce69c..eaa75f214 100644 --- a/apps/ade-cli/src/tuiClient/project.ts +++ b/apps/ade-cli/src/tuiClient/project.ts @@ -48,6 +48,7 @@ export function detectProjectLaunchContext(args: { laneHint?: string | null; sessionHint?: string | null; remote?: boolean; + remoteLabel?: string | null; } = {}): ProjectLaunchContext { const launchCwd = normalizeRoot(args.cwd ?? process.cwd()); const explicitProjectRoot = args.projectRoot?.trim(); @@ -87,6 +88,7 @@ export function detectProjectLaunchContext(args: { laneHint: args.laneHint?.trim() || worktree?.laneHint || null, sessionHint: args.sessionHint?.trim() || null, remote, + remoteLabel: args.remoteLabel?.trim() || null, }; } diff --git a/apps/ade-cli/src/tuiClient/remoteLauncher.ts b/apps/ade-cli/src/tuiClient/remoteLauncher.ts index cf70cd79f..f49b69cf7 100644 --- a/apps/ade-cli/src/tuiClient/remoteLauncher.ts +++ b/apps/ade-cli/src/tuiClient/remoteLauncher.ts @@ -1,6 +1,7 @@ import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process"; import net, { type AddressInfo } from "node:net"; -import readline from "node:readline/promises"; +import readline from "node:readline"; +import readlinePromises from "node:readline/promises"; import { RemoteTargetRegistry, normalizeRemoteTargetRoutes } from "../../../desktop/src/main/services/remoteRuntime/remoteTargetRegistry"; import type { RemoteRuntimeProjectRecord, @@ -191,33 +192,110 @@ async function promptChoice(title: string, entries: T[], describe: (entry: T, if (entries.length === 1) return entries[0]!; throw new Error(`${title}: pass a flag to choose non-interactively.`); } + if (entries.length === 1) return entries[0]!; - process.stderr.write(`\n${title}\n`); - entries.forEach((entry, index) => { - process.stderr.write(` ${index + 1}. ${describe(entry, index)}\n`); - }); + return await promptInteractiveChoice(title, entries, describe); +} - const rl = readline.createInterface({ - input: process.stdin, - output: process.stderr, - }); - try { - while (true) { - const answer = (await rl.question("Choose: ")).trim(); - const selected = Number.parseInt(answer, 10); - if (Number.isInteger(selected) && selected >= 1 && selected <= entries.length) { - return entries[selected - 1]!; - } - process.stderr.write(`Enter a number from 1 to ${entries.length}.\n`); +function terminalColumns(): number { + return Math.max(40, process.stderr.columns || 80); +} + +function truncateLine(value: string, width: number): string { + if (value.length <= width) return value; + if (width <= 3) return value.slice(0, Math.max(0, width)); + return `${value.slice(0, width - 3)}...`; +} + +async function promptInteractiveChoice( + title: string, + entries: T[], + describe: (entry: T, index: number) => string, +): Promise { + const input = process.stdin; + const output = process.stderr; + const wasRaw = Boolean(input.isRaw); + let selectedIndex = 0; + let scrollOffset = 0; + let renderedLines = 0; + const visibleRows = Math.min(12, entries.length); + + const render = (): void => { + if (renderedLines > 0) { + output.write(`\x1b[${renderedLines}A`); } - } finally { - rl.close(); - } + const width = terminalColumns(); + if (selectedIndex < scrollOffset) scrollOffset = selectedIndex; + if (selectedIndex >= scrollOffset + visibleRows) scrollOffset = selectedIndex - visibleRows + 1; + const shown = entries.slice(scrollOffset, scrollOffset + visibleRows); + const lines = [ + `${title} (${selectedIndex + 1}/${entries.length})`, + ...shown.map((entry, visibleIndex) => { + const index = scrollOffset + visibleIndex; + const prefix = index === selectedIndex ? "> " : " "; + return `${prefix}${truncateLine(describe(entry, index), Math.max(8, width - prefix.length))}`; + }), + "up/down select | enter choose | esc cancel", + ]; + output.write(lines.map((line) => `\x1b[2K${line}`).join("\n")); + output.write("\n"); + renderedLines = lines.length; + }; + + return await new Promise((resolve, reject) => { + const cleanup = (): void => { + input.off("keypress", onKeypress); + if (input.isTTY) input.setRawMode(wasRaw); + input.pause(); + }; + + const finish = (entry: T): void => { + cleanup(); + output.write("\n"); + resolve(entry); + }; + + const fail = (error: Error): void => { + cleanup(); + output.write("\n"); + reject(error); + }; + + const onKeypress = (_value: string, key: { name?: string; ctrl?: boolean }): void => { + if (key.ctrl && key.name === "c") { + fail(new Error(`${title}: cancelled.`)); + return; + } + if (key.name === "escape" || key.name === "q") { + fail(new Error(`${title}: cancelled.`)); + return; + } + if (key.name === "return" || key.name === "enter") { + finish(entries[selectedIndex]!); + return; + } + if (key.name === "up") { + selectedIndex = (selectedIndex - 1 + entries.length) % entries.length; + render(); + return; + } + if (key.name === "down" || key.name === "tab") { + selectedIndex = (selectedIndex + 1) % entries.length; + render(); + } + }; + + readline.emitKeypressEvents(input); + if (input.isTTY) input.setRawMode(true); + input.resume(); + input.on("keypress", onKeypress); + render(); + }); } async function promptText(title: string): Promise { if (!canPrompt()) throw new Error(`${title}: pass --project non-interactively.`); - const rl = readline.createInterface({ + const rl = readlinePromises.createInterface({ input: process.stdin, output: process.stderr, }); @@ -286,6 +364,13 @@ function normalizeRemoteRuntimeChannel(value: unknown): "alpha" | "beta" | null return null; } +function inferRemoteRuntimeChannelFromVersion(version: string | null | undefined): "alpha" | "beta" | null { + const normalized = version?.trim().toLowerCase() ?? ""; + if (normalized.includes("alpha")) return "alpha"; + if (normalized.includes("beta")) return "beta"; + return null; +} + function remoteRuntimeLayoutForChannel(channel: "alpha" | "beta" | null): RemoteRuntimeLayout { const homeDirName = channel === "alpha" ? ".ade-alpha" @@ -304,9 +389,12 @@ function remoteRuntimeLayoutForChannel(channel: "alpha" | "beta" | null): Remote }; } -function remoteRuntimeLayoutCandidates(env: NodeJS.ProcessEnv = process.env): RemoteRuntimeLayout[] { +export function remoteRuntimeLayoutCandidates( + env: NodeJS.ProcessEnv = process.env, + preferredChannel: "alpha" | "beta" | null = normalizeRemoteRuntimeChannel(env.ADE_PACKAGE_CHANNEL), +): RemoteRuntimeLayout[] { const channels = [ - normalizeRemoteRuntimeChannel(env.ADE_PACKAGE_CHANNEL), + preferredChannel, null, "beta" as const, "alpha" as const, @@ -330,8 +418,8 @@ export function buildRemoteRuntimeRpcCommand(layout: RemoteRuntimeLayout, binary ]; if (layout.channel) { exports.push(`export ADE_PACKAGE_CHANNEL="${layout.channel}"`); - exports.push("export ADE_DISABLE_RUNTIME_SERVICE_INSTALL=1"); } + exports.push("export ADE_DISABLE_RUNTIME_SERVICE_INSTALL=1"); return [ ...exports, "ADE_RUNTIME_ARCH=\"$(node -p 'process.platform + \"-\" + process.arch' 2>/dev/null || true)\"", @@ -367,8 +455,11 @@ function buildSshArgs(target: RemoteRuntimeTarget, route: RemoteRuntimeTargetRou function remoteRpcAttempts(target: RemoteRuntimeTarget): RemoteRpcAttempt[] { const attempts: RemoteRpcAttempt[] = []; const seen = new Set(); + const preferredChannel = + inferRemoteRuntimeChannelFromVersion(target.runtimeBinaryVersion) ?? + normalizeRemoteRuntimeChannel(process.env.ADE_PACKAGE_CHANNEL); for (const route of targetRoutes(target)) { - for (const layout of remoteRuntimeLayoutCandidates()) { + for (const layout of remoteRuntimeLayoutCandidates(process.env, preferredChannel)) { const commands = [ buildRemoteRuntimeRpcCommand(layout, layout.binaryExpr), buildRemoteRuntimeRpcCommand(layout, "ade"), @@ -651,6 +742,29 @@ async function callProjectAction( return (isRecord(payload) && "result" in payload ? payload.result : payload) as T; } +async function callProjectActionArgsList( + client: ProcessJsonRpcClient, + projectId: string, + domain: string, + action: string, + argsList: unknown[], +): Promise { + const payload = await withTimeout( + client.request("ade/actions/call", { + projectId, + name: "run_ade_action", + arguments: { domain, action, argsList }, + }), + REMOTE_RPC_TIMEOUT_MS, + `Timed out running ${domain}.${action} on the remote project.`, + ); + if (isRecord(payload) && payload.ok === false) { + const message = isRecord(payload.error) ? trimString(payload.error.message) : null; + throw new Error(message ?? `Remote action failed: ${domain}.${action}`); + } + return (isRecord(payload) && "result" in payload ? payload.result : payload) as T; +} + function coerceChatSessions(value: unknown): AgentChatSessionSummary[] { if (!Array.isArray(value)) return []; return value.flatMap((entry) => { @@ -724,7 +838,9 @@ function isTerminalSessionLaunchable(session: ChatTerminalSession): boolean { return false; } if (toolType === "claude" || toolType === "claude-orchestrated") return true; - return isRecord(session.resumeMetadata) && session.resumeMetadata.provider === "claude"; + if (isRecord(session.resumeMetadata) && session.resumeMetadata.provider === "claude") return true; + const resumeCommand = typeof session.resumeCommand === "string" ? session.resumeCommand.trim().toLowerCase() : ""; + return Boolean(resumeCommand && /\bclaude\b/.test(resumeCommand)); } function chatToChoice(session: AgentChatSessionSummary): RemoteSessionChoice { @@ -739,10 +855,32 @@ function chatToChoice(session: AgentChatSessionSummary): RemoteSessionChoice { }; } -async function listRemoteSessions(client: ProcessJsonRpcClient, projectId: string): Promise { - const chats = coerceChatSessions(await callProjectAction(client, projectId, "chat", "listSessions", { +async function listRemoteChatSessions(client: ProcessJsonRpcClient, projectId: string): Promise { + const args = { includeArchived: false, - }).catch(() => [])); + includeAutomation: true, + }; + try { + return coerceChatSessions(await callProjectAction(client, projectId, "chat", "listSessions", args)); + } catch { + // Older remote action adapters called the positional chat service API with + // the object args as laneId. Retry through run_ade_action's positional + // argsList form so stale-but-compatible runtimes still expose ADE chats. + return coerceChatSessions(await callProjectActionArgsList(client, projectId, "chat", "listSessions", [ + null, + args, + ])); + } +} + +export async function listRemoteSessions(client: ProcessJsonRpcClient, projectId: string): Promise { + const chats = await listRemoteChatSessions(client, projectId).catch((error) => { + if (canPrompt()) { + const message = error instanceof Error ? error.message : String(error); + process.stderr.write(`Remote ADE chats unavailable: ${message}\n`); + } + return []; + }); const terminals = coerceTerminalSessions(await callProjectAction(client, projectId, "terminal", "list", { limit: 200, }).catch(() => [])); @@ -987,6 +1125,8 @@ export async function runAdeCodeRemote(argv: string[], runAdeCodeCli: RunAdeCode bridge.socketUrl, "--require-socket", "--remote", + "--remote-label", + target.name, ...(session?.laneId ? ["--lane", session.laneId] : []), ...(session?.sessionId ? ["--session", session.sessionId] : []), ]); diff --git a/apps/ade-cli/src/tuiClient/types.ts b/apps/ade-cli/src/tuiClient/types.ts index 67d2af833..498f444d8 100644 --- a/apps/ade-cli/src/tuiClient/types.ts +++ b/apps/ade-cli/src/tuiClient/types.ts @@ -33,6 +33,7 @@ export type ProjectLaunchContext = { laneHint: string | null; sessionHint: string | null; remote: boolean; + remoteLabel: string | null; }; export type ChatHistorySnapshot = AgentChatEventHistorySnapshot; diff --git a/apps/desktop/resources/ade-cli-help.txt b/apps/desktop/resources/ade-cli-help.txt index 8e6f06bfe..56c8fd2e6 100644 --- a/apps/desktop/resources/ade-cli-help.txt +++ b/apps/desktop/resources/ade-cli-help.txt @@ -10,9 +10,9 @@ _ ____ _____ Agent-focused command-line interface for ADE. - ADE CLI commands operate through the machine ADE runtime daemon by default. - If the daemon is not running, the CLI starts it, registers the selected - project, and routes project actions through that runtime. + ADE CLI commands operate through the machine ADE brain by default. + The brain is the always-on ADE process for this machine: it owns the project + catalog, sync endpoint, and execution authority for the channel. $ ade help Display help for a command $ ade auth status Check local ADE CLI readiness @@ -22,13 +22,14 @@ _ ____ _____ $ ade link lane | session | branch | pr | linear-issue Build a shareable deeplink (copies to clipboard) $ ade linear install Register ADE as Linear's "Open in coding tool" target - $ ade runtime start | stop | status Manage the machine runtime daemon - $ ade serve Run the ADE runtime daemon in foreground + $ ade skill list | show Browse ADE's bundled agent skills (local) + $ ade brain start | stop | status Manage the background ADE brain + $ ade runtime run --socket Run a manual runtime for dev/test work $ ade rpc --stdio Speak ADE JSON-RPC over stdin/stdout - $ ade init [path] Register a project with this machine runtime + $ ade init [path] Register a project with this machine brain $ ade projects list List projects registered on this machine $ ade sync status | pin generate Manage machine sync and phone pairing - $ ade doctor Inspect project, socket, runtime, and tool availability + $ ade doctor Inspect project, brain, runtime, and tool availability $ ade lanes list | show | create | child Work with lanes and lane stacks $ ade git status | commit | push | stash Run ADE-aware git operations $ ade operations status | wait Poll operation/test/chat/run status @@ -52,7 +53,8 @@ _ ____ _____ $ ade macos-vm status | start | restart | wipe | install-runtime | set-credentials | get-credentials | storage | display-session | detach Run ADE's singleton Apple silicon macOS VM $ ade browser open | tabs | screenshot Use ADE's built-in browser pane - $ ade usage snapshot | refresh | budget Read provider quota usage and edit automation guardrails + $ ade usage snapshot | refresh | budget Read provider quota usage and budget guardrails + $ ade settings pr-transcript-gists enable Attach ADE chat transcript links to new PRs $ ade settings action Call project config actions $ ade update status | check | install | dismiss Read auto-update state and drive install $ ade actions list | run | status | wait Escape hatch for every ADE service action @@ -62,8 +64,8 @@ _ ____ _____ Global options: --project-root ADE project root. Inside .ade/worktrees/, this resolves to the parent project. --workspace-root Lane/worktree to treat as the active workspace. - --headless Skip the runtime daemon and run an in-process ADE runtime. - --socket Require a live ADE socket; fail instead of falling back to headless. + --headless Skip the machine brain and run an in-process ADE runtime. + --socket Require a live ADE endpoint; fail instead of falling back to headless. --json Print machine-readable JSON. This is the default output mode. --text Print a compact human-readable summary when a formatter exists. --timeout-ms Per-request timeout. Long agent/PR workflows may need several minutes. @@ -78,6 +80,7 @@ _ ____ _____ $ ade git stage --lane src/index.ts $ ade git commit --lane -m "Fix login redirect" $ ade prs create --lane --base main --draft + $ ade prs checks --text $ ade proof record --seconds 20 $ ade ios-sim apps --text $ ade ios-sim launch --target --text @@ -110,9 +113,9 @@ _ ____ _____ Agent-focused command-line interface for ADE. - ADE CLI commands operate through the machine ADE runtime daemon by default. - If the daemon is not running, the CLI starts it, registers the selected - project, and routes project actions through that runtime. + ADE CLI commands operate through the machine ADE brain by default. + The brain is the always-on ADE process for this machine: it owns the project + catalog, sync endpoint, and execution authority for the channel. $ ade help Display help for a command $ ade auth status Check local ADE CLI readiness @@ -122,13 +125,14 @@ _ ____ _____ $ ade link lane | session | branch | pr | linear-issue Build a shareable deeplink (copies to clipboard) $ ade linear install Register ADE as Linear's "Open in coding tool" target - $ ade runtime start | stop | status Manage the machine runtime daemon - $ ade serve Run the ADE runtime daemon in foreground + $ ade skill list | show Browse ADE's bundled agent skills (local) + $ ade brain start | stop | status Manage the background ADE brain + $ ade runtime run --socket Run a manual runtime for dev/test work $ ade rpc --stdio Speak ADE JSON-RPC over stdin/stdout - $ ade init [path] Register a project with this machine runtime + $ ade init [path] Register a project with this machine brain $ ade projects list List projects registered on this machine $ ade sync status | pin generate Manage machine sync and phone pairing - $ ade doctor Inspect project, socket, runtime, and tool availability + $ ade doctor Inspect project, brain, runtime, and tool availability $ ade lanes list | show | create | child Work with lanes and lane stacks $ ade git status | commit | push | stash Run ADE-aware git operations $ ade operations status | wait Poll operation/test/chat/run status @@ -152,7 +156,8 @@ _ ____ _____ $ ade macos-vm status | start | restart | wipe | install-runtime | set-credentials | get-credentials | storage | display-session | detach Run ADE's singleton Apple silicon macOS VM $ ade browser open | tabs | screenshot Use ADE's built-in browser pane - $ ade usage snapshot | refresh | budget Read provider quota usage and edit automation guardrails + $ ade usage snapshot | refresh | budget Read provider quota usage and budget guardrails + $ ade settings pr-transcript-gists enable Attach ADE chat transcript links to new PRs $ ade settings action Call project config actions $ ade update status | check | install | dismiss Read auto-update state and drive install $ ade actions list | run | status | wait Escape hatch for every ADE service action @@ -162,8 +167,8 @@ _ ____ _____ Global options: --project-root ADE project root. Inside .ade/worktrees/, this resolves to the parent project. --workspace-root Lane/worktree to treat as the active workspace. - --headless Skip the runtime daemon and run an in-process ADE runtime. - --socket Require a live ADE socket; fail instead of falling back to headless. + --headless Skip the machine brain and run an in-process ADE runtime. + --socket Require a live ADE endpoint; fail instead of falling back to headless. --json Print machine-readable JSON. This is the default output mode. --text Print a compact human-readable summary when a formatter exists. --timeout-ms Per-request timeout. Long agent/PR workflows may need several minutes. @@ -178,6 +183,7 @@ _ ____ _____ $ ade git stage --lane src/index.ts $ ade git commit --lane -m "Fix login redirect" $ ade prs create --lane --base main --draft + $ ade prs checks --text $ ade proof record --seconds 20 $ ade ios-sim apps --text $ ade ios-sim launch --target --text @@ -210,9 +216,9 @@ _ ____ _____ Agent-focused command-line interface for ADE. - ADE CLI commands operate through the machine ADE runtime daemon by default. - If the daemon is not running, the CLI starts it, registers the selected - project, and routes project actions through that runtime. + ADE CLI commands operate through the machine ADE brain by default. + The brain is the always-on ADE process for this machine: it owns the project + catalog, sync endpoint, and execution authority for the channel. $ ade help Display help for a command $ ade auth status Check local ADE CLI readiness @@ -222,13 +228,14 @@ _ ____ _____ $ ade link lane | session | branch | pr | linear-issue Build a shareable deeplink (copies to clipboard) $ ade linear install Register ADE as Linear's "Open in coding tool" target - $ ade runtime start | stop | status Manage the machine runtime daemon - $ ade serve Run the ADE runtime daemon in foreground + $ ade skill list | show Browse ADE's bundled agent skills (local) + $ ade brain start | stop | status Manage the background ADE brain + $ ade runtime run --socket Run a manual runtime for dev/test work $ ade rpc --stdio Speak ADE JSON-RPC over stdin/stdout - $ ade init [path] Register a project with this machine runtime + $ ade init [path] Register a project with this machine brain $ ade projects list List projects registered on this machine $ ade sync status | pin generate Manage machine sync and phone pairing - $ ade doctor Inspect project, socket, runtime, and tool availability + $ ade doctor Inspect project, brain, runtime, and tool availability $ ade lanes list | show | create | child Work with lanes and lane stacks $ ade git status | commit | push | stash Run ADE-aware git operations $ ade operations status | wait Poll operation/test/chat/run status @@ -252,7 +259,8 @@ _ ____ _____ $ ade macos-vm status | start | restart | wipe | install-runtime | set-credentials | get-credentials | storage | display-session | detach Run ADE's singleton Apple silicon macOS VM $ ade browser open | tabs | screenshot Use ADE's built-in browser pane - $ ade usage snapshot | refresh | budget Read provider quota usage and edit automation guardrails + $ ade usage snapshot | refresh | budget Read provider quota usage and budget guardrails + $ ade settings pr-transcript-gists enable Attach ADE chat transcript links to new PRs $ ade settings action Call project config actions $ ade update status | check | install | dismiss Read auto-update state and drive install $ ade actions list | run | status | wait Escape hatch for every ADE service action @@ -262,8 +270,8 @@ _ ____ _____ Global options: --project-root ADE project root. Inside .ade/worktrees/, this resolves to the parent project. --workspace-root Lane/worktree to treat as the active workspace. - --headless Skip the runtime daemon and run an in-process ADE runtime. - --socket Require a live ADE socket; fail instead of falling back to headless. + --headless Skip the machine brain and run an in-process ADE runtime. + --socket Require a live ADE endpoint; fail instead of falling back to headless. --json Print machine-readable JSON. This is the default output mode. --text Print a compact human-readable summary when a formatter exists. --timeout-ms Per-request timeout. Long agent/PR workflows may need several minutes. @@ -278,6 +286,7 @@ _ ____ _____ $ ade git stage --lane src/index.ts $ ade git commit --lane -m "Fix login redirect" $ ade prs create --lane --base main --draft + $ ade prs checks --text $ ade proof record --seconds 20 $ ade ios-sim apps --text $ ade ios-sim launch --target --text @@ -301,6 +310,66 @@ _ ____ _____ Start with: ade doctor --text +## ade code --help +_ ____ _____ + / \ | _ \| ____| + / _ \ | | | | _| + / ___ \| |_| | |___ + /_/ \_\____/|_____| + + ADE Code + + Launch the terminal-native ADE Work chat. It uses the same project lanes, + chat sessions, transcript state, and slash commands as desktop ADE, but it + does not require the desktop app to be running. + + $ ade code Start the TUI for the current project + $ ade code --print-state Smoke-test attach/embed state + $ ade code --embedded Force the embedded runtime fallback + $ ade code --require-socket Fail instead of starting an embedded runtime when no runtime endpoint exists + $ ade code --socket /tmp/ade.sock Attach to a specific local endpoint + $ ade code --lane Launch focused on a specific lane + $ ade code remote --target --project + Launch against a saved desktop remote machine + $ ade code remote session --target --project --session + Open a specific remote chat or Claude terminal session + $ ade code remote --list-targets List saved remote machines + $ ade --project-root code Launch against a specific ADE project + + Keys: + ctrl-o Open or focus lanes and chats + ctrl-p Open or focus details + ctrl-g Split chat: add another chat tile + ctrl-w Split chat: close focused tile + tab Split chat: cycle focused tile + shift-tab Cycle pane focus + esc Return or cancel the active pane + ? Help when it is the first prompt character + / Command palette + +## ade skill --help +_ ____ _____ + / \ | _ \| ____| + / _ \ | | | | _| + / ___ \| |_| | |___ + /_/ \_\____/|_____| + + ADE Skills + + Browse ADE's bundled, version-locked agent skills directly from the bundled + resources. This is a local command that does NOT require the machine brain — + it is the tamper-proof backstop for agents that can't natively discover + ADE's skills. + + $ ade skill list List bundled skills (JSON: name, description, path) + $ ade skill list --text One "name — description" line per skill + $ ade skill show Print a skill's SKILL.md (JSON: name, description, content, path) + $ ade skill show --text Print just the skill's markdown body + + Flags: + --text Human-readable output. + --json Structured JSON output (default). + ## ade lanes --help _ ____ _____ / \ | _ \| ____| @@ -330,6 +399,7 @@ _ ____ _____ $ ade lanes import --branch Register an existing branch/worktree $ ade lanes archive Archive a lane in ADE $ ade lanes unarchive Restore an archived lane + $ ade lanes delete --force Delete a lane and clean up its worktree $ ade lanes attach --path --name Attach an external worktree $ ade lanes reparent --parent Move lane onto a new parent (runs git rebase) $ ade lanes reparent --parent --stack-base-branch @@ -448,7 +518,7 @@ _ ____ _____ Run tab Run tab commands mirror ADE process definitions and runtime state. They use - the machine runtime daemon when live process state is needed. + the machine ADE brain when live process state is needed. $ ade run defs --text List configured run commands $ ade run ps --lane --text List process runtime state @@ -508,9 +578,10 @@ _ ____ _____ Work chats Chat commands use ADE agent chat sessions. Live provider-backed chat normally - requires an attached runtime because the daemon owns provider/session state. + requires an attached runtime because it owns provider/session state. - $ ade chat list --text List chat sessions + $ ade chat list --lane --text List chat sessions + $ ade chat list --include-automation --no-archived --text $ ade chat create --lane --provider codex --model [--fast] $ ade chat create --from-linear-issue ENG-431 Start a chat with an attached issue + kickoff (alias: --linear-issue-json) $ ade chat send --text "next step" Send a message @@ -562,7 +633,7 @@ _ ____ _____ Linear workflows Daemon bridge (for an agent running inside a tracked ADE CLI session): - these commands route over the ADE daemon to the desktop runtime, which holds + these commands route over the ADE runtime to the desktop runtime, which holds the Linear credentials — the CLI never needs a Linear token. When ADE launches an agent with an attached issue it injects $ADE_CHAT_SESSION_ID and $ADE_LINEAR_ISSUE_IDS, so the agent can read and write its issue with no ids. @@ -595,6 +666,9 @@ _ ____ _____ $ ade linear sync run Trigger a sync run $ ade linear sync queue --text List sync queue items $ ade linear sync resolve --queue-item --action approve + $ ade linear ingress status --text Show Linear ingress status + $ ade linear ingress start --text Ensure Linear webhook and start relay ingress + $ ade linear ingress start-local --text Start only the local Linear webhook listener $ ade linear route worker --input-json '{"issueId":"LIN-123","workerId":"worker-1"}' $ ade linear install Register ADE as the "Open in coding tool" target $ ade linear install --dry-run Preview the ~/.linear/coding-tools.json write @@ -617,6 +691,12 @@ _ ____ _____ $ ade automations run [--lane ] [--dry-run] $ ade automations trigger [--lane ] Trigger a rule manually + $ ade automations ingress status [--text] Show webhook gateway status + $ ade automations ingress start [--text] Start the local webhook listener + $ ade automations ingress refresh [--text] Re-detect Tailscale/gateway status + $ ade --role cto automations ingress set-url + Save the public gateway URL + $ ade --role cto automations ingress clear-url Clear the public gateway URL $ ade automations runs [--rule ] [--status ] [--limit 50] $ ade automations run-show [--json] Inspect a run $ ade automations example Print an example rule (stdout) @@ -691,7 +771,7 @@ _ ____ _____ Prefer screenshots/images, screen recordings, and browser captures/traces. Console logs are supporting diagnostics, not a replacement for visual proof. Local screenshot/video fallback is macOS-only and runs headless by default - unless --socket is explicitly requested. Runtime socket mode has the best + unless --socket is explicitly requested. Attached runtime mode has the best parity for shared proof state. $ ade proof status --text Show proof backend capabilities @@ -713,7 +793,7 @@ _ ____ _____ iOS simulator commands build, launch, mirror, inspect, and control the ADE drawer simulator. Aliases: `ade ios` and `ade simulator` route to the same - surface. For drawer/shared session state, prefer runtime socket mode + surface. For drawer/shared session state, prefer attached runtime mode (--socket) so launch/select/tap operate on the same long-lived ADE service. Launch opens Simulator by default and ADE shows it in the drawer. Optional simulator control tools enable tap, drag, type, and inspect actions. @@ -743,6 +823,9 @@ _ ____ _____ $ ade ios-sim inspect --x 120 --y 420 --text Inspect a point in the simulator $ ade ios-sim preview-status --text Xcode MCP readiness for Preview Lab $ ade ios-sim previews --source --text List nearby #Preview definitions + $ ade ios-sim preview-match --source Resolve best Preview Lab match + $ ade ios-sim preview-ensure --text Open/wait for Xcode Preview Lab + $ ade ios-sim preview-current --text Render preview for the selected simulator UI $ ade ios-sim preview-render --source Render a SwiftUI preview through Xcode MCP Live view: @@ -828,7 +911,7 @@ _ ____ _____ ADE browser Browser commands control ADE's project-scoped built-in browser pane. Use - desktop socket mode so CLI calls, chat link clicks, terminal localhost links, + desktop bridge mode so CLI calls, chat link clicks, terminal localhost links, and the Work sidebar share the browser for the active project only. Browser tabs, cookies, and storage are isolated between separate projects. The browser is project-scoped, not lane-scoped. Ownership is per tab/session: @@ -837,15 +920,17 @@ _ ____ _____ tab switching are passive view operations; use "browser claim --tab --lane " to claim an already-open tab. ADE-launched agents should list tabs first and use only a tab/session owned - by their current chat. If no owned tab exists, open a fresh owned tab; plain - "browser open " does this automatically for ADE-launched agents unless - --active-tab or --tab is passed. + by their current chat. Plain "browser open " reuses that owned tab for + ADE-launched agents and creates one only when none exists, without revealing + the Browser panel unless --panel is passed. Use --new-tab only when the task + truly needs another tab; --active-tab and --tab stay explicit. Tabs and navigation: $ ade --socket browser status --text Show active tab and tab list $ ade --socket browser claim --lane Attribute the active browser tab to a lane $ ade --socket browser panel --text Open the Work sidebar Browser panel $ ade --socket browser open https://example.com --text + $ ade --socket browser open https://example.com --panel --text $ ade --socket browser open localhost:5173 --new-tab --text $ ade --socket browser open localhost:5173 --active-tab --text $ ade --socket browser open https://example.com --no-panel @@ -898,9 +983,11 @@ _ ____ _____ Flags: --url URL for panel/open/new-tab. Bare localhost gets http://. - --new-tab Open navigation in a new tab instead of active tab. + --new-tab Always open navigation in a new tab. --active-tab Navigate the active tab; aliases: --current-tab, --same-tab. --background Create a new tab without activating it. + --panel, --show-panel + Reveal the Work sidebar Browser panel for this command. --no-panel Keep the Work sidebar panel hidden; alias: --hidden. --tab, --tab-id Target tab for switch/close/open/control/capture/claim. --browser-session @@ -944,9 +1031,9 @@ _ ____ _____ Agent-focused command-line interface for ADE. - ADE CLI commands operate through the machine ADE runtime daemon by default. - If the daemon is not running, the CLI starts it, registers the selected - project, and routes project actions through that runtime. + ADE CLI commands operate through the machine ADE brain by default. + The brain is the always-on ADE process for this machine: it owns the project + catalog, sync endpoint, and execution authority for the channel. $ ade help Display help for a command $ ade auth status Check local ADE CLI readiness @@ -956,13 +1043,14 @@ _ ____ _____ $ ade link lane | session | branch | pr | linear-issue Build a shareable deeplink (copies to clipboard) $ ade linear install Register ADE as Linear's "Open in coding tool" target - $ ade runtime start | stop | status Manage the machine runtime daemon - $ ade serve Run the ADE runtime daemon in foreground + $ ade skill list | show Browse ADE's bundled agent skills (local) + $ ade brain start | stop | status Manage the background ADE brain + $ ade runtime run --socket Run a manual runtime for dev/test work $ ade rpc --stdio Speak ADE JSON-RPC over stdin/stdout - $ ade init [path] Register a project with this machine runtime + $ ade init [path] Register a project with this machine brain $ ade projects list List projects registered on this machine $ ade sync status | pin generate Manage machine sync and phone pairing - $ ade doctor Inspect project, socket, runtime, and tool availability + $ ade doctor Inspect project, brain, runtime, and tool availability $ ade lanes list | show | create | child Work with lanes and lane stacks $ ade git status | commit | push | stash Run ADE-aware git operations $ ade operations status | wait Poll operation/test/chat/run status @@ -986,7 +1074,8 @@ _ ____ _____ $ ade macos-vm status | start | restart | wipe | install-runtime | set-credentials | get-credentials | storage | display-session | detach Run ADE's singleton Apple silicon macOS VM $ ade browser open | tabs | screenshot Use ADE's built-in browser pane - $ ade usage snapshot | refresh | budget Read provider quota usage and edit automation guardrails + $ ade usage snapshot | refresh | budget Read provider quota usage and budget guardrails + $ ade settings pr-transcript-gists enable Attach ADE chat transcript links to new PRs $ ade settings action Call project config actions $ ade update status | check | install | dismiss Read auto-update state and drive install $ ade actions list | run | status | wait Escape hatch for every ADE service action @@ -996,8 +1085,8 @@ _ ____ _____ Global options: --project-root ADE project root. Inside .ade/worktrees/, this resolves to the parent project. --workspace-root Lane/worktree to treat as the active workspace. - --headless Skip the runtime daemon and run an in-process ADE runtime. - --socket Require a live ADE socket; fail instead of falling back to headless. + --headless Skip the machine brain and run an in-process ADE runtime. + --socket Require a live ADE endpoint; fail instead of falling back to headless. --json Print machine-readable JSON. This is the default output mode. --text Print a compact human-readable summary when a formatter exists. --timeout-ms Per-request timeout. Long agent/PR workflows may need several minutes. @@ -1012,6 +1101,7 @@ _ ____ _____ $ ade git stage --lane src/index.ts $ ade git commit --lane -m "Fix login redirect" $ ade prs create --lane --base main --draft + $ ade prs checks --text $ ade proof record --seconds 20 $ ade ios-sim apps --text $ ade ios-sim launch --target --text diff --git a/apps/desktop/scripts/regen-ade-cli-help.cjs b/apps/desktop/scripts/regen-ade-cli-help.cjs index dfc97c900..a59611041 100644 --- a/apps/desktop/scripts/regen-ade-cli-help.cjs +++ b/apps/desktop/scripts/regen-ade-cli-help.cjs @@ -8,6 +8,8 @@ const { spawnSync } = require("child_process"); const SUBCOMMANDS = [ "auth", "doctor", + "code", + "skill", "lanes", "git", "diff", diff --git a/apps/desktop/src/main/services/remoteRuntime/remoteBootstrap.test.ts b/apps/desktop/src/main/services/remoteRuntime/remoteBootstrap.test.ts index 7c118129e..7cfbf1060 100644 --- a/apps/desktop/src/main/services/remoteRuntime/remoteBootstrap.test.ts +++ b/apps/desktop/src/main/services/remoteRuntime/remoteBootstrap.test.ts @@ -430,11 +430,13 @@ function createFakeSpawnProcess(options: { closeCode?: number; error?: Error; st function createTempResources( archLabel = "linux-x64", - options: { nativeDeps?: boolean; ptyHostWorker?: boolean } = {}, + options: { agentSkills?: boolean; nativeDeps?: boolean; ptyHostWorker?: boolean } = {}, ): { resourcesPath: string; binaryPath: string; binarySha256: string; + agentSkillPath: string | null; + agentSkillSha256: string | null; ptyHostWorkerPath: string | null; ptyHostWorkerSha256: string | null; cleanup: () => void; @@ -447,6 +449,15 @@ function createTempResources( if (options.nativeDeps) { fs.writeFileSync(path.join(runtimeDir, `ade-${archLabel}.native.tar.gz`), "native deps fixture\n"); } + let agentSkillPath: string | null = null; + let agentSkillSha256: string | null = null; + if (options.agentSkills) { + const skillDir = path.join(resourcesPath, "agent-skills", "ade-cli-control-plane"); + fs.mkdirSync(skillDir, { recursive: true }); + agentSkillPath = path.join(skillDir, "SKILL.md"); + fs.writeFileSync(agentSkillPath, "# ADE CLI control plane\n"); + agentSkillSha256 = crypto.createHash("sha256").update(fs.readFileSync(agentSkillPath)).digest("hex"); + } let ptyHostWorkerPath: string | null = null; let ptyHostWorkerSha256: string | null = null; if (options.ptyHostWorker) { @@ -461,6 +472,8 @@ function createTempResources( resourcesPath, binaryPath, binarySha256, + agentSkillPath, + agentSkillSha256, ptyHostWorkerPath, ptyHostWorkerSha256, cleanup: () => fs.rmSync(resourcesPath, { recursive: true, force: true }), @@ -1041,6 +1054,80 @@ describe("bootstrapRemoteRuntime upload flow", () => { }); }); + it("uploads bundled ADE agent skills into the selected remote runtime home", async () => { + const resources = createTempResources("linux-x64", { agentSkills: true }); + cleanupResources = resources.cleanup; + const fakeSsh = createFakeSsh(); + const registry = createRegistry(); + connectSshWithRouteMock.mockResolvedValue({ + client: fakeSsh.ssh, + route: uploadRoute, + }); + const commands: string[] = []; + execSshMock.mockImplementation(async (_client: Client, command: string) => { + commands.push(command); + const remotePath = resolvedRemotePath(command); + if (remotePath) return remotePath; + if (command === "uname -sm") return ok("Linux x86_64\n"); + if (isRemoteRuntimeIdentityCommand(command)) return remoteRuntimeIdentityOk({}); + if (command === "mkdir -p $HOME/.ade/bin && chmod 700 $HOME/.ade/bin") return ok(""); + if (command.match(/^rm -f \$HOME\/\.ade\/bin\/ade\.upload-.* && umask 077 && : > \$HOME\/\.ade\/bin\/ade\.upload-.* && chmod 600 \$HOME\/\.ade\/bin\/ade\.upload-/)) return ok(""); + if ( + command.includes("wc -c < $HOME/.ade/bin/ade.upload-") && + !command.includes("shasum") && + !command.includes("mv -f") + ) return ok(`${fs.statSync(resources.binaryPath).size}\n`); + if ( + command.includes("wc -c < $HOME/.ade/bin/ade.upload-") && + command.includes("mv -f $HOME/.ade/bin/ade.upload-") && + command.includes("printf '%s\\n' '2.0.0' > $HOME/.ade/bin/ade.version") + ) return ok(""); + if (command.includes("$HOME/.ade/bin/ade --version")) return ok("ade 2.0.0\n"); + if (command.includes("test -d $HOME/.ade/agent-skills") && command.includes("agent-skills.sha256")) return ok(""); + if (command.match(/^rm -rf \$HOME\/\.ade\/agent-skills\.upload-.* && mkdir -p \$HOME\/\.ade\/agent-skills\.upload-/)) return ok(""); + if (command.match(/^mkdir -p \$HOME\/\.ade\/agent-skills\.upload-.*\/ade-cli-control-plane$/)) return ok(""); + if (command.match(/^rm -f \$HOME\/\.ade\/agent-skills\.upload-.*\/ade-cli-control-plane\/SKILL\.md\.upload-.* && umask 077 && : > \$HOME\/\.ade\/agent-skills\.upload-.*\/ade-cli-control-plane\/SKILL\.md\.upload-.* && chmod 600 \$HOME\/\.ade\/agent-skills\.upload-.*\/ade-cli-control-plane\/SKILL\.md\.upload-/)) return ok(""); + if ( + command.includes("wc -c < $HOME/.ade/agent-skills.upload-") && + command.includes("/ade-cli-control-plane/SKILL.md.upload-") && + command.includes("shasum -a 256") && + command.includes("mv -f $HOME/.ade/agent-skills.upload-") + ) return ok(""); + if ( + command.startsWith("rm -rf $HOME/.ade/agent-skills && mv -f $HOME/.ade/agent-skills.upload-") && + command.includes("> $HOME/.ade/agent-skills.sha256") + ) return ok(""); + return defaultRemoteBootstrapCommand(command); + }); + + await bootstrapRemoteRuntime({ + target: uploadTarget, + registry, + resourcesPath: resources.resourcesPath, + appVersion: APP_VERSION, + }); + + expect(fakeSsh.sftp).toHaveBeenCalledTimes(2); + expect(fakeSsh.sftpWrapper.fastPut).toHaveBeenCalledWith( + resources.agentSkillPath, + expect.stringMatching(/^\/home\/ade\/\.ade\/agent-skills\.upload-.*\/ade-cli-control-plane\/SKILL\.md\.upload-.*\.tmp$/), + expect.objectContaining({ fileSize: fs.statSync(resources.agentSkillPath!).size, mode: 0o600 }), + expect.any(Function), + ); + expect(commands.some((command) => + command.includes("test -d $HOME/.ade/agent-skills") && + command.includes("agent-skills.sha256"), + )).toBe(true); + expect(commands.some((command) => + command.startsWith("rm -rf $HOME/.ade/agent-skills && mv -f $HOME/.ade/agent-skills.upload-") && + command.includes("> $HOME/.ade/agent-skills.sha256"), + )).toBe(true); + expect(openSshRuntimeTransportMock).toHaveBeenCalledWith( + fakeSsh.ssh, + 'ADE_HOME="$HOME/.ade" PATH="$HOME/.ade/bin:$HOME/.local/bin:$HOME/.npm-global/bin${PATH:+:$PATH}" ADE_DEFAULT_ROLE="cto" $HOME/.ade/bin/ade --socket $HOME/.ade/sock/ade.sock rpc --stdio', + ); + }); + it("fails closed when an uploaded runtime reports the wrong version", async () => { const resources = createTempResources(); cleanupResources = resources.cleanup; diff --git a/apps/desktop/src/main/services/remoteRuntime/remoteBootstrap.ts b/apps/desktop/src/main/services/remoteRuntime/remoteBootstrap.ts index c149ed28e..b18d4e0fc 100644 --- a/apps/desktop/src/main/services/remoteRuntime/remoteBootstrap.ts +++ b/apps/desktop/src/main/services/remoteRuntime/remoteBootstrap.ts @@ -175,6 +175,8 @@ type RemoteRuntimeLayout = { binaryRelative: string; versionExpr: string; sha256Expr: string; + agentSkillsDirExpr: string; + agentSkillsSha256Expr: string; ptyHostWorkerExpr: string; ptyHostWorkerSha256Expr: string; }; @@ -208,6 +210,8 @@ export function resolveRemoteRuntimeLayout(env: NodeJS.ProcessEnv = process.env) binaryRelative: `${homeDirName}/bin/ade`, versionExpr: `${binDirExpr}/ade.version`, sha256Expr: `${binDirExpr}/ade.sha256`, + agentSkillsDirExpr: `${homeDirExpr}/agent-skills`, + agentSkillsSha256Expr: `${homeDirExpr}/agent-skills.sha256`, ptyHostWorkerExpr: `${runtimeDirExpr}/ptyHostWorker.cjs`, ptyHostWorkerSha256Expr: `${runtimeDirExpr}/ptyHostWorker.cjs.sha256`, }; @@ -324,6 +328,23 @@ function bundledPtyHostWorkerPath(resourcesPath: string, localBinaryPath: string }) ?? null; } +function bundledAgentSkillsPath(resourcesPath: string, localBinaryPath: string | null): string | null { + const candidates = [ + path.join(resourcesPath, "agent-skills"), + path.join(resourcesPath, "app.asar.unpacked", "agent-skills"), + ]; + if (localBinaryPath) { + candidates.push(path.resolve(path.dirname(localBinaryPath), "..", "agent-skills")); + } + return candidates.find((candidate) => { + try { + return fs.statSync(candidate).isDirectory(); + } catch { + return false; + } + }) ?? null; +} + type LocalArtifactHashCacheEntry = { size: number; mtimeMs: number; @@ -333,7 +354,7 @@ type LocalArtifactHashCacheEntry = { const localArtifactHashCache = new Map(); -function hashRuntimeBinary(localPath: string): string { +function hashLocalFile(localPath: string): string { const stat = fs.statSync(localPath); const cached = localArtifactHashCache.get(localPath); if ( @@ -354,10 +375,62 @@ function hashRuntimeBinary(localPath: string): string { return sha256; } +function hashRuntimeBinary(localPath: string): string { + return hashLocalFile(localPath); +} + function fileSizeBytes(localPath: string): number { return fs.statSync(localPath).size; } +type LocalAgentSkillFile = { + localPath: string; + relativePath: string; + sha256: string; +}; + +function listLocalAgentSkillFiles(root: string): LocalAgentSkillFile[] { + const files: LocalAgentSkillFile[] = []; + const visit = (dir: string): void => { + const entries = fs.readdirSync(dir, { withFileTypes: true }) + .sort((left, right) => left.name.localeCompare(right.name, undefined, { sensitivity: "base" })); + for (const entry of entries) { + const fullPath = path.join(dir, entry.name); + if (entry.isDirectory()) { + visit(fullPath); + } else if (entry.isFile()) { + files.push({ + localPath: fullPath, + relativePath: path.relative(root, fullPath).split(path.sep).join("/"), + sha256: hashLocalFile(fullPath), + }); + } + } + }; + visit(root); + return files.sort((left, right) => left.relativePath.localeCompare(right.relativePath, undefined, { sensitivity: "base" })); +} + +function hashAgentSkillsDirectory(files: readonly LocalAgentSkillFile[]): string { + const hash = crypto.createHash("sha256"); + for (const file of files) { + hash.update(file.relativePath); + hash.update("\0"); + hash.update(file.sha256); + hash.update("\0"); + } + return hash.digest("hex"); +} + +function shellPathSegment(segment: string): string { + return /^[A-Za-z0-9._-]+$/.test(segment) ? segment : shellQuote(segment); +} + +function remoteChildExpr(rootExpr: string, relativePath: string): string { + const parts = relativePath.split(/[\\/]+/).filter(Boolean).map(shellPathSegment); + return [rootExpr.replace(/\/+$/, ""), ...parts].join("/"); +} + function remoteUploadTempSuffix(): string { return `upload-${process.pid}-${Date.now()}-${crypto.randomBytes(4).toString("hex")}.tmp`; } @@ -1036,6 +1109,73 @@ async function uploadNativeDepsBundle( } } +async function uploadBundledAgentSkills( + client: Client, + target: RemoteRuntimeTarget, + route: ConnectedSshRoute, + connectedConfig: OpenSshUploadConfig | null | undefined, + layout: RemoteRuntimeLayout, + localRoot: string, +): Promise { + const files = listLocalAgentSkillFiles(localRoot); + const directorySha256 = hashAgentSkillsDirectory(files); + const remoteStatus = await execSsh( + client, + `test -d ${layout.agentSkillsDirExpr} && test "$(cat ${layout.agentSkillsSha256Expr} 2>/dev/null)" = ${shellQuote(directorySha256)} && echo ok || true`, + ); + if (remoteStatus.stdout.trim() === "ok") return; + + const stagingExpr = `${layout.agentSkillsDirExpr}.${remoteUploadTempSuffix()}`; + try { + await execSshOrThrow( + client, + `rm -rf ${stagingExpr} && mkdir -p ${stagingExpr}`, + "Unable to prepare remote ADE agent skills upload.", + ); + const createdDirs = new Set([""]); + for (const file of files) { + const relativeDir = path.posix.dirname(file.relativePath); + if (relativeDir !== "." && !createdDirs.has(relativeDir)) { + await execSshOrThrow( + client, + `mkdir -p ${remoteChildExpr(stagingExpr, relativeDir)}`, + "Unable to create remote ADE agent skills directory.", + ); + createdDirs.add(relativeDir); + } + const remoteFileExpr = remoteChildExpr(stagingExpr, file.relativePath); + const tempExpr = `${remoteFileExpr}.${remoteUploadTempSuffix()}`; + try { + await uploadSshFile(client, target, route, connectedConfig, file.localPath, tempExpr); + await execSshOrThrow( + client, + [ + remoteFileMatchesCommand(tempExpr, fileSizeBytes(file.localPath), file.sha256), + `mv -f ${tempExpr} ${remoteFileExpr}`, + ].join(" && "), + "Uploaded ADE agent skill did not pass size and checksum verification.", + ); + } catch (error) { + await execSsh(client, `rm -f ${tempExpr}`).catch(() => undefined); + throw error; + } + } + await execSshOrThrow( + client, + [ + `rm -rf ${layout.agentSkillsDirExpr}`, + `mv -f ${stagingExpr} ${layout.agentSkillsDirExpr}`, + `printf '%s\\n' ${shellQuote(directorySha256)} > ${layout.agentSkillsSha256Expr}`, + `chmod 600 ${layout.agentSkillsSha256Expr}`, + ].join(" && "), + "Unable to finalize remote ADE agent skills upload.", + ); + } catch (error) { + await execSsh(client, `rm -rf ${stagingExpr}`).catch(() => undefined); + throw error; + } +} + async function uploadPtyHostWorker( client: Client, target: RemoteRuntimeTarget, @@ -1200,6 +1340,7 @@ export async function bootstrapRemoteRuntime(args: { const nativeDepsBundle = bundledNativeDepsPath(args.resourcesPath, arch.label); const localPtyHostWorker = bundledPtyHostWorkerPath(args.resourcesPath, localBinary); const localPtyHostWorkerSha256 = localPtyHostWorker ? hashRuntimeBinary(localPtyHostWorker) : null; + const localAgentSkillsRoot = bundledAgentSkillsPath(args.resourcesPath, localBinary); let remoteBinaryMatchesLocal: boolean | null = null; if (!localBinary && !executableRuntimeVersion) { @@ -1339,6 +1480,18 @@ export async function bootstrapRemoteRuntime(args: { await verifyUploadedRuntime(); } + const ensureAgentSkillsReady = async (targetLayout: RemoteRuntimeLayout): Promise => { + if (!localAgentSkillsRoot) return; + await uploadBundledAgentSkills( + ssh, + args.target, + connectedRoute, + uploadConnectionConfig, + targetLayout, + localAgentSkillsRoot, + ); + }; + if (!runtimeVersion) { const pathVersionCheck = await execSsh(ssh, `${runtimeEnvPrefix}ade --version || true`); runtimeVersion = normalizeRuntimeVersion(pathVersionCheck.stdout); @@ -1347,6 +1500,8 @@ export async function bootstrapRemoteRuntime(args: { } } + await ensureAgentSkillsReady(layout); + const command = localBinary || runtimeUploaded ? remoteRuntimeRpcCommand(layout, runtimeEnvPrefix, layout.binaryExpr) : remoteRuntimeRpcCommand(layout, runtimeEnvPrefix, "ade"); @@ -1384,6 +1539,7 @@ export async function bootstrapRemoteRuntime(args: { }); const candidateCommand = remoteRuntimeRpcCommand(candidateLayout, candidateRuntimeEnvPrefix, "ade"); try { + await ensureAgentSkillsReady(candidateLayout); openedRuntime = await openValidatedRuntimeClient({ ssh, command: candidateCommand, diff --git a/docs/features/ade-code/README.md b/docs/features/ade-code/README.md index ddce17169..4d46fec74 100644 --- a/docs/features/ade-code/README.md +++ b/docs/features/ade-code/README.md @@ -247,11 +247,29 @@ Lane selection persists `lastLaneId` and updates the runtime's session state so ade code # attached to the machine runtime for the current project ade code --print-state # smoke-test: print mode + socket and exit ade code --embedded # in-process runtime fallback +ade code remote --target mac --project ADE + # attach through SSH to a saved desktop remote target/project +ade code remote session --target mac --project ADE --session chat-1 + # open a specific remote chat or Claude terminal session +ade code remote --list-targets # list saved desktop remote machines +ade code remote --target mac --list-projects + # list projects registered on that remote runtime +ade code remote session --target mac --project ADE --list-sessions + # list launchable remote chats/Claude terminals ade --project-root /repo code # bind to a different project ade --socket /tmp/ade-runtime-dev.sock code # attach to a specific socket (dev runtime, peer machine, etc.) ``` +`ade code remote` is a launcher around the same TUI. It reads saved desktop +remote targets, probes stable/beta/alpha ADE homes over SSH, starts +`ade rpc --stdio` on the selected machine, exposes that stdio stream as a local +loopback `tcp://` JSON-RPC socket, and then invokes the normal `runAdeCodeCli` +with `--remote`, `--remote-label`, `--require-socket`, remote project roots, +and the selected `--lane` / `--session` hints. Remote launches skip local +project-root and build-hash compatibility checks because the authoritative +runtime and filesystem are on the target machine. + After local changes, run `npm run build` inside `apps/ade-cli` so both `dist/cli.cjs` and `dist/tuiClient/cli.mjs` exist for packaged and linked use. The CLI build verifier imports `dist/tuiClient/cli.mjs` from an isolated temp directory, checks that bundled `__dirname` / `__filename` references have ESM shims, and confirms `runAdeCodeCli(["--help"])` prints the ADE Code help banner without relying on repo-local `node_modules`. During repo development, `npm run dev:code` runs the source TUI in the terminal against the shared dev runtime at `/tmp/ade-runtime-dev.sock`; `npm run dev:code:web` mirrors that same process in the browser (see [Browser mirror](#browser-mirror-development)). ## Claude Code 2.1.x parity diff --git a/docs/features/remote-runtime/README.md b/docs/features/remote-runtime/README.md index 169dd333a..1fa275567 100644 --- a/docs/features/remote-runtime/README.md +++ b/docs/features/remote-runtime/README.md @@ -81,7 +81,7 @@ When opening a remote project, ADE checks local projects with the same git origi 1. Add a machine from the remote machines panel or command palette. Discovered machines (LAN + Tailscale) prefill the form with the Tailscale FQDN as the primary host plus every other reachable route (LAN address, mDNS host, alt IPs) on the saved target so reconnects can fall back automatically. 2. Enter a display name, hostname, SSH user, port, and optionally a private key path. If no key path is provided, ADE uses the user's local ssh-agent when `SSH_AUTH_SOCK` is available and reads matching `HostName` / `IdentityFile` entries from `~/.ssh/config`. 3. Connect. If the machine's SSH host key has not been trusted on this Mac before, the Remote pane shows the key fingerprint and records the user's explicit "Trust & connect" decision in `known_hosts`; users should not need to run `ssh ` manually. ADE then opens an SSH session with a bounded handshake timeout, detects the remote platform with `uname -sm`, and starts `ade rpc --stdio`. If the primary host is unreachable, ADE walks alternate `routes` ranked by most-recent success and records the route that wins. -4. If the bundled ADE runtime for that platform is present and the remote ADE binary is missing, stale, or hash-mismatched, ADE uploads `ade-` to `~/.ade/bin/ade` (or the matching channel home), uploads native dependencies to `~/.ade/runtime//`, uploads the PTY host worker used by remote terminals, and verifies `~/.ade/bin/ade --version`. Uploads prefer SFTP and fall back to bounded SSH chunk uploads / OpenSSH when needed. If the desktop has no bundled binary for that arch, bootstrap probes the alternate channel homes (`.ade`, `.ade-alpha`, `.ade-beta`) for a working `ade` and uses whichever already serves a compatible RPC; the chosen home is recorded as a `compatibilityWarnings` entry so the UI explains why a non-default home was used. +4. If the bundled ADE runtime for that platform is present and the remote ADE binary is missing, stale, or hash-mismatched, ADE uploads `ade-` to `~/.ade/bin/ade` (or the matching channel home), uploads native dependencies to `~/.ade/runtime//`, uploads the PTY host worker used by remote terminals, uploads bundled ADE agent skills to `/agent-skills`, and verifies `~/.ade/bin/ade --version`. Uploads prefer SFTP and fall back to bounded SSH chunk uploads / OpenSSH when needed. The skills upload is content-hashed and skipped when the remote `/agent-skills.sha256` marker is current, so remote `ade skill` and agent launches see the same version-locked ADE skills as the desktop bundle. If the desktop has no bundled binary for that arch, bootstrap probes the alternate channel homes (`.ade`, `.ade-alpha`, `.ade-beta`) for a working `ade` and uses whichever already serves a compatible RPC; the chosen home is recorded as a `compatibilityWarnings` entry so the UI explains why a non-default home was used. 5. Pick an existing remote project or register a new remote path; the desktop calls `projects.add { rootPath }` against the remote runtime to bind it. If the same window starts multiple remote opens concurrently, both preload @@ -89,7 +89,7 @@ When opening a remote project, ADE checks local projects with the same git origi After connecting, the desktop persists the active remote project to `globalState.lastRemoteProjectBinding`. When the app relaunches with no startup project path, the first window restores that binding and reconnects to the same target / project automatically. A user-triggered disconnect records manual intent on the target and suppresses restore/autoconnect until the user presses Connect again; repeated implicit reconnect failures also pause automatic reconnects and surface a "Press Connect to try again" message. -Per-channel layout: builds with `ADE_PACKAGE_CHANNEL=alpha|beta` upload to `~/.ade-alpha/` or `~/.ade-beta/` instead of `~/.ade/` so a remote machine can host stable, beta, and alpha runtimes side by side. Remote compatibility launches keep `ADE_DISABLE_RUNTIME_SERVICE_INSTALL=1` so remote probes do not fight the user's login service. +Per-channel layout: builds with `ADE_PACKAGE_CHANNEL=alpha|beta` upload to `~/.ade-alpha/` or `~/.ade-beta/` instead of `~/.ade/` so a remote machine can host stable, beta, and alpha runtimes side by side. Runtime binaries, native deps, PTY workers, and bundled ADE agent skills all live under the selected home. Remote compatibility launches keep `ADE_DISABLE_RUNTIME_SERVICE_INSTALL=1` so remote probes do not fight the user's login service. ## Compatibility warnings @@ -104,6 +104,11 @@ Version skew and capability skew no longer fail the connect outright. The bootst Desktop distributable builds require `apps/desktop/resources/runtime/` to contain every supported `ade-` binary and matching `.native.tar.gz` archive, plus the packaged ADE CLI resources that include `ptyHostWorker.cjs` for remote terminal hosting. The supported targets are `darwin-arm64`, `darwin-x64`, `linux-arm64`, `linux-x64`. +Desktop distributable builds also package `apps/desktop/resources/agent-skills/`. +Remote bootstrap copies that directory into the selected remote ADE home as +`agent-skills/`; the CLI then re-seeds ADE-managed skills into runtime-native +home skill directories on launch. + `apps/desktop/scripts/validate-runtime-resources.mjs` is the preflight that fails the package step when artifacts are missing. Release builds populate the resource directory from the runtime-binary CI workflow's artifacts via `materialize-runtime-resources.mjs`. For local same-platform packaging, build into the resource directory directly: ```bash diff --git a/docs/features/remote-runtime/internal-architecture.md b/docs/features/remote-runtime/internal-architecture.md index eb95c74ff..1c5c695da 100644 --- a/docs/features/remote-runtime/internal-architecture.md +++ b/docs/features/remote-runtime/internal-architecture.md @@ -76,19 +76,20 @@ A remote target stores a primary `hostname` plus an optional `routes` array (`Re 1. Connect over SSH. 2. Detect platform and architecture with `uname -sm` (`normalizeRemoteArch` accepts darwin/linux × arm64/x64). 3. Read the preferred channel home's `bin/ade.version`, `bin/ade.sha256`, and `bin/ade --version` when present. -4. Locate the bundled `ade-` binary, `ade-.native.tar.gz` archive, and packaged `ptyHostWorker.cjs` in desktop resources. +4. Locate the bundled `ade-` binary, `ade-.native.tar.gz` archive, packaged `ptyHostWorker.cjs`, and bundled `agent-skills/` root in desktop resources. 5. If the desktop has no bundled binary for that arch and no executable was found in the preferred home, probe the alternate channel homes (`~/.ade`, `~/.ade-alpha`, `~/.ade-beta`) for a working `ade --version`. The first home that responds is adopted as the active layout and the reason is captured for the connection's `compatibilityWarnings` (`Using remote runtime home because did not contain an ADE service for `). 6. If the local bundle is present and the selected installed version or SHA does not match the desktop bundle, upload the binary to `/bin/ade` (mode 700 dir, +x file, write `/bin/ade.version` and `/bin/ade.sha256`). Uploads prefer SFTP; when that cannot start safely, ADE writes bounded SSH chunks and can fall back to OpenSSH for chunks that did not enter the existing channel. 7. If the native deps archive is present and either the runtime was just uploaded or the remote `/runtime//.ade-version` doesn't match, upload and extract it to `/runtime//`. 8. If the PTY host worker is available locally and the remote worker hash is missing or stale, upload it to `/runtime/ptyHostWorker.cjs` with a sidecar `.sha256`. When the remote has `node`, the runtime environment points `ADE_PTY_HOST_WORKER_PATH` and `ADE_PTY_HOST_WORKER_NODE` at that worker; otherwise it points `ADE_PTY_HOST_WORKER_COMMAND` at the uploaded `ade` binary so the static runtime can run the internal worker entry. 9. Verify the uploaded runtime by running `/bin/ade --version` with the channel/arch/worker environment prefix; abort with `Uploaded ADE service version mismatch` if the reported version doesn't match. -10. Start `ade rpc --stdio`, initialize the JSON-RPC client, normalize capabilities and version through `validateRemoteRuntimeInitializeResult`, and read `projects.list`. Version skew, channel skew, and missing capabilities become `compatibilityWarnings` rather than throws. -11. If the bundled binary was absent and the validated initialize still fails, walk the alternate channel homes again with `ade rpc --stdio` against each candidate. The first home that completes initialize wins; failed candidates are collected so the final error reads `Remote ADE service could not start a compatible RPC runtime. Tried : ; : .`. If a fallback wins, the chosen home is recorded as a compatibility warning. -12. Update the target registry with architecture, runtime version (preferring the value the daemon reported through `initialize`), last-connected timestamp, and a refreshed `routes` array marking the successful route's `lastSucceededAt`. +10. If bundled ADE agent skills are available locally, hash the directory and upload it to `/agent-skills` when `/agent-skills.sha256` is missing or stale. The remote CLI resolves that root from its own binary path and re-seeds ADE-managed skills into runtime-native home skill directories on launch. +11. Start `ade rpc --stdio`, initialize the JSON-RPC client, normalize capabilities and version through `validateRemoteRuntimeInitializeResult`, and read `projects.list`. Version skew, channel skew, and missing capabilities become `compatibilityWarnings` rather than throws. +12. If the bundled binary was absent and the validated initialize still fails, walk the alternate channel homes again with `ade rpc --stdio` against each candidate. The first home that completes initialize wins; failed candidates are collected so the final error reads `Remote ADE service could not start a compatible RPC runtime. Tried : ; : .`. If a fallback wins, the chosen home is recorded as a compatibility warning. +13. Update the target registry with architecture, runtime version (preferring the value the daemon reported through `initialize`), last-connected timestamp, and a refreshed `routes` array marking the successful route's `lastSucceededAt`. If no bundled runtime exists locally and no channel home on the remote can start a compatible RPC, bootstrap fails with an explicit install/build error rather than silently shipping the wrong version. -Channel layout: `resolveRemoteRuntimeLayout` reads `ADE_PACKAGE_CHANNEL` for the preferred home; `resolveRemoteRuntimeLayoutCandidates` enumerates that preferred home plus the stable / alpha / beta layouts (deduped by `homeDirName`) for the fallback walk. Stable uploads to `~/.ade/`; alpha to `~/.ade-alpha/`; beta to `~/.ade-beta/`. Channel builds also pass `ADE_DISABLE_RUNTIME_SERVICE_INSTALL=1` in the environment prefix so the channel binary doesn't fight a stable login service for the socket. +Channel layout: `resolveRemoteRuntimeLayout` reads `ADE_PACKAGE_CHANNEL` for the preferred home; `resolveRemoteRuntimeLayoutCandidates` enumerates that preferred home plus the stable / alpha / beta layouts (deduped by `homeDirName`) for the fallback walk. Stable uploads to `~/.ade/`; alpha to `~/.ade-alpha/`; beta to `~/.ade-beta/`. Runtime binaries, native deps, PTY worker artifacts, and bundled ADE agent skills all stay inside the selected home. Channel builds also pass `ADE_DISABLE_RUNTIME_SERVICE_INSTALL=1` in the environment prefix so the channel binary doesn't fight a stable login service for the socket. ## Local-vs-remote work warning From 23494689c7254379abacdd51a1a261646b8e1d7c Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Mon, 15 Jun 2026 14:57:13 -0400 Subject: [PATCH 05/10] test(ios): cover context compaction lifecycle --- apps/ios/ADETests/ADETests.swift | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/apps/ios/ADETests/ADETests.swift b/apps/ios/ADETests/ADETests.swift index 447af6343..d75c6b4f3 100644 --- a/apps/ios/ADETests/ADETests.swift +++ b/apps/ios/ADETests/ADETests.swift @@ -11006,6 +11006,31 @@ final class ADETests: XCTestCase { XCTAssertNil(parsed.tokensFreedLabel) } + func testWorkContextCompactLifecycleMergesStartedAndCompletedCard() { + let transcript: [WorkChatEnvelope] = [ + WorkChatEnvelope( + sessionId: "chat-1", + timestamp: "2026-06-15T00:00:01.000Z", + sequence: 1, + event: .contextCompact(summary: "Manual", isInProgress: true, turnId: "turn-compact") + ), + WorkChatEnvelope( + sessionId: "chat-1", + timestamp: "2026-06-15T00:00:02.000Z", + sequence: 2, + event: .contextCompact(summary: "Manual\nPre-compact tokens: 12000", isInProgress: false, turnId: "turn-compact") + ), + ] + + let cards = buildWorkEventCards(from: transcript).filter { $0.kind == "contextCompact" } + + XCTAssertEqual(cards.count, 1) + XCTAssertEqual(cards.first?.id, "context-compact:chat-1:turn:turn-compact") + XCTAssertEqual(cards.first?.title, "Context compacted") + XCTAssertEqual(cards.first?.body, "Manual\nPre-compact tokens: 12000") + XCTAssertEqual(cards.first?.isInProgress, false) + } + // MARK: - Timeline dedup + ask_user regression tests func testBuildWorkToolCardsDedupesDuplicateToolCallsByItemId() { From a93cf4ba2c6444ab9ce4396bc9be8e3948636ea5 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Mon, 15 Jun 2026 15:12:45 -0400 Subject: [PATCH 06/10] fix(ade-code): harden remote session bridge --- .../__tests__/remoteLauncher.test.ts | 32 +++++++++++++++++++ apps/ade-cli/src/tuiClient/remoteLauncher.ts | 26 +++++++++++---- 2 files changed, 52 insertions(+), 6 deletions(-) diff --git a/apps/ade-cli/src/tuiClient/__tests__/remoteLauncher.test.ts b/apps/ade-cli/src/tuiClient/__tests__/remoteLauncher.test.ts index 9873337d2..838348ca2 100644 --- a/apps/ade-cli/src/tuiClient/__tests__/remoteLauncher.test.ts +++ b/apps/ade-cli/src/tuiClient/__tests__/remoteLauncher.test.ts @@ -175,4 +175,36 @@ describe("ade code remote launcher", () => { { sessionId: "claude-command-1", kind: "terminal", title: "Claude terminal" }, ]); }); + + it("includes legacy Claude terminals when only resume metadata identifies them", async () => { + const client = { + request: async (_method: string, params: unknown) => { + const args = (params as { arguments?: { domain?: string; action?: string } }).arguments; + if (args?.domain === "chat" && args.action === "listSessions") { + return { result: [] }; + } + if (args?.domain === "terminal" && args.action === "list") { + return { + result: [ + { + terminalId: "claude-metadata-1", + laneId: "lane-1", + title: "Claude metadata terminal", + status: "running", + runtimeState: "idle", + startedAt: "2026-06-15T00:00:00.000Z", + toolType: "shell", + resumeMetadata: { provider: "claude" }, + }, + ], + }; + } + throw new Error("unexpected request"); + }, + }; + + await expect(listRemoteSessions(client as never, "project-1")).resolves.toMatchObject([ + { sessionId: "claude-metadata-1", kind: "terminal", title: "Claude metadata terminal" }, + ]); + }); }); diff --git a/apps/ade-cli/src/tuiClient/remoteLauncher.ts b/apps/ade-cli/src/tuiClient/remoteLauncher.ts index f49b69cf7..39aa1d9b9 100644 --- a/apps/ade-cli/src/tuiClient/remoteLauncher.ts +++ b/apps/ade-cli/src/tuiClient/remoteLauncher.ts @@ -778,17 +778,22 @@ function coerceChatSessions(value: unknown): AgentChatSessionSummary[] { const lastActivityAt = trimString(entry.lastActivityAt); if (!sessionId || !laneId || !provider || !model || !status || !startedAt || !lastActivityAt) return []; return [{ - ...(entry as AgentChatSessionSummary), sessionId, laneId, provider: provider as AgentChatSessionSummary["provider"], model, + title: trimString(entry.title), + goal: trimString(entry.goal), status: status as AgentChatSessionSummary["status"], startedAt, - endedAt: typeof entry.endedAt === "string" ? entry.endedAt : null, + endedAt: trimString(entry.endedAt), lastActivityAt, - lastOutputPreview: typeof entry.lastOutputPreview === "string" ? entry.lastOutputPreview : null, - summary: typeof entry.summary === "string" ? entry.summary : null, + lastOutputPreview: trimString(entry.lastOutputPreview), + summary: trimString(entry.summary), + awaitingInput: typeof entry.awaitingInput === "boolean" ? entry.awaitingInput : undefined, + pendingInputItemId: trimString(entry.pendingInputItemId), + threadId: trimString(entry.threadId) ?? undefined, + requestedCwd: trimString(entry.requestedCwd), }]; }); } @@ -800,17 +805,25 @@ function coerceTerminalSessions(value: unknown): ChatTerminalSession[] { const terminalId = trimString(entry.terminalId); const laneId = trimString(entry.laneId); if (!terminalId || !laneId) return []; + const status = trimString(entry.status) ?? "ended"; return [{ - ...(entry as ChatTerminalSession), terminalId, + ptyId: trimString(entry.ptyId), + chatSessionId: trimString(entry.chatSessionId), laneId, + laneName: trimString(entry.laneName) ?? laneId, title: trimString(entry.title) ?? terminalId, goal: trimString(entry.goal), toolType: trimString(entry.toolType) as ChatTerminalSession["toolType"], - status: (trimString(entry.status) ?? "ended") as ChatTerminalSession["status"], + status: status as ChatTerminalSession["status"], runtimeState: (trimString(entry.runtimeState) ?? "idle") as ChatTerminalSession["runtimeState"], + active: typeof entry.active === "boolean" ? entry.active : status === "running", startedAt: trimString(entry.startedAt) ?? new Date(0).toISOString(), endedAt: trimString(entry.endedAt), + exitCode: typeof entry.exitCode === "number" && Number.isInteger(entry.exitCode) ? entry.exitCode : null, + pid: typeof entry.pid === "number" && Number.isInteger(entry.pid) ? entry.pid : null, + resumeCommand: trimString(entry.resumeCommand), + resumeMetadata: isRecord(entry.resumeMetadata) ? entry.resumeMetadata as ChatTerminalSession["resumeMetadata"] : null, lastOutputPreview: trimString(entry.lastOutputPreview), summary: trimString(entry.summary), }]; @@ -1017,6 +1030,7 @@ async function startRemoteBridge(attempt: RemoteRpcAttempt): Promise((resolve, reject) => { const cleanup = (): void => { From a3bb209a9514746b823aee4bb1eb74390f40ce65 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Mon, 15 Jun 2026 15:16:05 -0400 Subject: [PATCH 07/10] fix(tui): close cross-turn compaction blocks --- .../src/tuiClient/__tests__/aggregate.test.ts | 20 +++++++++++++++++++ apps/ade-cli/src/tuiClient/aggregate.ts | 17 ++++++++++++++-- 2 files changed, 35 insertions(+), 2 deletions(-) diff --git a/apps/ade-cli/src/tuiClient/__tests__/aggregate.test.ts b/apps/ade-cli/src/tuiClient/__tests__/aggregate.test.ts index 5c62a3586..1019cd35c 100644 --- a/apps/ade-cli/src/tuiClient/__tests__/aggregate.test.ts +++ b/apps/ade-cli/src/tuiClient/__tests__/aggregate.test.ts @@ -187,6 +187,26 @@ describe("aggregateChatBlocks typed groups", () => { expect(blocks[0]).toMatchObject({ kind: "compaction", live: false, trigger: "auto", preTokens: 120_000 }); }); + it("collapses cross-turn context_compact completion into the live compaction block", () => { + const events: AgentChatEventEnvelope[] = [ + env("2026-01-01T12:00:00.000Z", { type: "context_compact", trigger: "auto", state: "started", turnId: "turn-1" }), + env("2026-01-01T12:00:02.000Z", { type: "context_compact", trigger: "manual", state: "completed", preTokens: 120_000, turnId: "turn-2" }), + ]; + const blocks = aggregate(events).filter((b) => b.kind === "compaction"); + expect(blocks).toHaveLength(1); + expect(blocks[0]).toMatchObject({ kind: "compaction", live: false, trigger: "manual", preTokens: 120_000 }); + }); + + it("collapses cross-turn codex compaction completion into the live compaction block", () => { + const events: AgentChatEventEnvelope[] = [ + env("2026-01-01T12:00:00.000Z", { type: "codex_context_compaction", trigger: "auto", state: "started", turnId: "turn-1" }), + env("2026-01-01T12:00:02.000Z", { type: "codex_context_compaction", trigger: "auto", state: "completed", turnId: "turn-2" }), + ]; + const blocks = aggregate(events).filter((b) => b.kind === "compaction"); + expect(blocks).toHaveLength(1); + expect(blocks[0]).toMatchObject({ kind: "compaction", live: false, trigger: "auto" }); + }); + it("renders a context_compact begin as a live (in-progress) compaction block", () => { const events: AgentChatEventEnvelope[] = [ env("2026-01-01T12:00:00.000Z", { type: "context_compact", trigger: "manual", state: "started", turnId: "turn-1" }), diff --git a/apps/ade-cli/src/tuiClient/aggregate.ts b/apps/ade-cli/src/tuiClient/aggregate.ts index 32c646a62..9df7377a3 100644 --- a/apps/ade-cli/src/tuiClient/aggregate.ts +++ b/apps/ade-cli/src/tuiClient/aggregate.ts @@ -339,6 +339,19 @@ function findLastBlock( return null; } +function findCompactionBlockForCompletion( + blocks: AggregatedBlock[], + turnId: string | null, +): Extract | null { + const exact = findLastBlock(blocks, "compaction", turnId); + if (exact) return exact; + for (let index = blocks.length - 1; index >= 0; index -= 1) { + const candidate = blocks[index]!; + if (candidate.kind === "compaction" && candidate.live) return candidate; + } + return null; +} + function isLiveTurnBlock( block: AggregatedBlock, ): block is Extract { @@ -795,7 +808,7 @@ export function aggregateChatBlocks(args: { // Runtimes that expose a begin signal send state:"started" then "completed"; // flip the existing block live→done on completion instead of stacking a second // block. Legacy/completion-only sources omit state and render as done. - const existing = event.state ? findLastBlock(blocks, "compaction", turnId) : null; + const existing = event.state === "completed" ? findCompactionBlockForCompletion(blocks, turnId) : null; if (existing && event.state === "completed") { existing.live = false; existing.trigger = event.trigger; @@ -813,7 +826,7 @@ export function aggregateChatBlocks(args: { continue; } if (event.type === "codex_context_compaction") { - const existing = findLastBlock(blocks, "compaction", turnId); + const existing = event.state === "completed" ? findCompactionBlockForCompletion(blocks, turnId) : null; if (existing && event.state === "completed") { existing.live = false; existing.trigger = event.trigger; From e8aa6cc1a57aff1e142199583326b8eb6b5e535d Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Mon, 15 Jun 2026 15:22:51 -0400 Subject: [PATCH 08/10] fix: address review follow-ups --- apps/ade-cli/src/cli.ts | 4 + .../src/tuiClient/__tests__/project.test.ts | 12 +++ apps/ade-cli/src/tuiClient/app.tsx | 4 +- .../ModelPicker/ModelPickerPane.tsx | 2 +- apps/ade-cli/src/tuiClient/project.ts | 4 +- apps/desktop/resources/ade-cli-help.txt | 4 + .../services/chat/agentChatService.test.ts | 82 +++++++++++++++++++ .../main/services/chat/agentChatService.ts | 8 ++ .../main/services/lanes/laneService.test.ts | 79 +++++++++--------- 9 files changed, 157 insertions(+), 42 deletions(-) diff --git a/apps/ade-cli/src/cli.ts b/apps/ade-cli/src/cli.ts index d25d5d835..e236907dd 100644 --- a/apps/ade-cli/src/cli.ts +++ b/apps/ade-cli/src/cli.ts @@ -1147,6 +1147,10 @@ const HELP_BY_COMMAND: Record = { $ ade code remote session --target --project --session Open a specific remote chat or Claude terminal session $ ade code remote --list-targets List saved remote machines + $ ade code remote --target --list-projects + List ADE projects available on the remote machine + $ ade code remote session --target --project --list-sessions + List remote chat and Claude terminal sessions $ ade --project-root code Launch against a specific ADE project Keys: diff --git a/apps/ade-cli/src/tuiClient/__tests__/project.test.ts b/apps/ade-cli/src/tuiClient/__tests__/project.test.ts index 5191a13cc..c9c222bb3 100644 --- a/apps/ade-cli/src/tuiClient/__tests__/project.test.ts +++ b/apps/ade-cli/src/tuiClient/__tests__/project.test.ts @@ -72,6 +72,18 @@ describe("chooseInitialLane", () => { expect(context.remoteLabel).toBe("Mac Studio"); }); + it("falls back from blank remote roots", () => { + const context = detectProjectLaunchContext({ + cwd: "/tmp", + projectRoot: " ", + workspaceRoot: "", + remote: true, + }); + + expect(context.projectRoot).toBe(path.resolve("/tmp")); + expect(context.workspaceRoot).toBe(path.resolve("/tmp")); + }); + it("prefers the ADE worktree lane hint", () => { const lanes = [ lane({ id: "main", name: "main", laneType: "primary", worktreePath: "/repo" }), diff --git a/apps/ade-cli/src/tuiClient/app.tsx b/apps/ade-cli/src/tuiClient/app.tsx index 0c827a8c0..8114b308d 100644 --- a/apps/ade-cli/src/tuiClient/app.tsx +++ b/apps/ade-cli/src/tuiClient/app.tsx @@ -13182,7 +13182,7 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath, // to fit (matches the single-view resize). Single-tile fallback (the grid is too // small to split) is left to the single-view resize effect. useEffect(() => { - const conn = connectionRef.current; + const conn = connection; if (!conn || !gridViewActive || !multiView) return; const tiles = multiView.tiles.slice(0, 6); if (!canRenderMultiChatGrid(tiles.length, chatWrapWidth, gridRowBudget)) return; @@ -13195,7 +13195,7 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath, const rows = claudeTerminalRowsForPane(Math.max(1, rect.h - 3)); void resizeTerminal(conn, tile.sessionId, cols, rows).catch(() => undefined); }); - }, [gridViewActive, multiView, chatWrapWidth, gridRowBudget]); + }, [connection, gridViewActive, multiView, chatWrapWidth, gridRowBudget]); // Whether the grid's focused tile is a running Claude terminal — drives the // footer "^t control (single)" hint so the unavailable-in-grid control is clear. diff --git a/apps/ade-cli/src/tuiClient/components/ModelPicker/ModelPickerPane.tsx b/apps/ade-cli/src/tuiClient/components/ModelPicker/ModelPickerPane.tsx index 617705bd4..cb6285100 100644 --- a/apps/ade-cli/src/tuiClient/components/ModelPicker/ModelPickerPane.tsx +++ b/apps/ade-cli/src/tuiClient/components/ModelPicker/ModelPickerPane.tsx @@ -302,10 +302,10 @@ function SubProviderTabs({ selectedIndex: number; width: number; }) { + const hoveredId = useHoveredHitId(); if (tabs.length <= 1) return null; const safe = Math.max(0, Math.min(selectedIndex, tabs.length - 1)); if (!tabs[safe]) return null; - const hoveredId = useHoveredHitId(); const visible = providerTabSegments(tabs, safe, width); return ( diff --git a/apps/ade-cli/src/tuiClient/project.ts b/apps/ade-cli/src/tuiClient/project.ts index eaa75f214..8682d3249 100644 --- a/apps/ade-cli/src/tuiClient/project.ts +++ b/apps/ade-cli/src/tuiClient/project.ts @@ -51,8 +51,8 @@ export function detectProjectLaunchContext(args: { remoteLabel?: string | null; } = {}): ProjectLaunchContext { const launchCwd = normalizeRoot(args.cwd ?? process.cwd()); - const explicitProjectRoot = args.projectRoot?.trim(); - const explicitWorkspaceRoot = args.workspaceRoot?.trim(); + const explicitProjectRoot = args.projectRoot?.trim() || null; + const explicitWorkspaceRoot = args.workspaceRoot?.trim() || null; const remote = args.remote === true; const worktree = findAdeWorktreeContext(launchCwd); const gitRoot = findGitRoot(launchCwd); diff --git a/apps/desktop/resources/ade-cli-help.txt b/apps/desktop/resources/ade-cli-help.txt index 56c8fd2e6..9f51c9e4e 100644 --- a/apps/desktop/resources/ade-cli-help.txt +++ b/apps/desktop/resources/ade-cli-help.txt @@ -334,6 +334,10 @@ _ ____ _____ $ ade code remote session --target --project --session Open a specific remote chat or Claude terminal session $ ade code remote --list-targets List saved remote machines + $ ade code remote --target --list-projects + List ADE projects available on the remote machine + $ ade code remote session --target --project --list-sessions + List remote chat and Claude terminal sessions $ ade --project-root code Launch against a specific ADE project Keys: diff --git a/apps/desktop/src/main/services/chat/agentChatService.test.ts b/apps/desktop/src/main/services/chat/agentChatService.test.ts index 19fec45e2..504325f18 100644 --- a/apps/desktop/src/main/services/chat/agentChatService.test.ts +++ b/apps/desktop/src/main/services/chat/agentChatService.test.ts @@ -17098,6 +17098,88 @@ describe("createAgentChatService", () => { await sendPromise; }); + it("dedupes repeated OpenCode compaction part updates", async () => { + const events: AgentChatEventEnvelope[] = []; + let releaseStream!: () => void; + const streamGate = new Promise((resolve) => { + releaseStream = () => resolve(); + }); + + vi.mocked(streamText).mockImplementation(() => ({ + fullStream: (async function* () { + await streamGate; + yield { type: "finish", usage: {} }; + })(), + }) as any); + + const { service } = createService({ + onEvent: (event: AgentChatEventEnvelope) => events.push(event), + }); + + const session = await service.createSession({ + laneId: "lane-1", + provider: "opencode", + model: "opencode/openai/gpt-5.4", + modelId: "opencode/openai/gpt-5.4", + }); + + const sendPromise = service.sendMessage({ + sessionId: session.id, + text: "Compact this context.", + }); + + await waitForEvent( + events, + (event): event is AgentChatEventEnvelope & { + event: Extract; + } => event.event.type === "status" && event.event.turnStatus === "started", + ); + + const state = [...mockState.openCodeSessions.values()][0]!; + state.events.push( + { + type: "message.part.updated", + properties: { + part: { id: "compact-part-1", sessionID: "opencode-session-1", type: "compaction", auto: false }, + delta: "", + }, + }, + { + type: "message.part.updated", + properties: { + part: { id: "compact-part-1", sessionID: "opencode-session-1", type: "compaction", auto: false }, + delta: "", + }, + }, + { + type: "session.compacted", + properties: { sessionID: "opencode-session-1" }, + }, + ); + const waiters = [...state.waiters]; + state.waiters.length = 0; + waiters.forEach((waiter) => waiter()); + + await waitForEvent( + events, + (event): event is AgentChatEventEnvelope & { + event: Extract; + } => event.event.type === "context_compact" && event.event.state === "completed", + ); + + const compactionEvents = events + .map((event) => event.event) + .filter((event): event is Extract => + event.type === "context_compact" + ); + expect(compactionEvents).toHaveLength(2); + expect(compactionEvents.map((event) => event.state)).toEqual(["started", "completed"]); + expect(compactionEvents.every((event) => event.trigger === "manual")).toBe(true); + + releaseStream(); + await sendPromise; + }); + it("emits immediate startup activity before Claude SDK stream output arrives", async () => { const events: AgentChatEventEnvelope[] = []; const setPermissionMode = vi.fn().mockResolvedValue(undefined); diff --git a/apps/desktop/src/main/services/chat/agentChatService.ts b/apps/desktop/src/main/services/chat/agentChatService.ts index 4934a13c0..c6f91b90c 100644 --- a/apps/desktop/src/main/services/chat/agentChatService.ts +++ b/apps/desktop/src/main/services/chat/agentChatService.ts @@ -829,6 +829,7 @@ type OpenCodeRuntime = { textByPartId: Map; reasoningByPartId: Map; toolStateByPartId: Map; + compactionStartedPartIds: Set; /** IDs of OpenCode child sessions already announced as subagents this run. */ subagentSessionIds: Set; /** @@ -7783,6 +7784,7 @@ export function createAgentChatService(args: { textByPartId: new Map(), reasoningByPartId: new Map(), toolStateByPartId: new Map(), + compactionStartedPartIds: new Set(), subagentSessionIds: new Set(), lastCompactionTrigger: null, }; @@ -13053,6 +13055,7 @@ export function createAgentChatService(args: { runtime.textByPartId.clear(); runtime.reasoningByPartId.clear(); runtime.toolStateByPartId.clear(); + runtime.compactionStartedPartIds.clear(); const toPromptFiles = resolvedAttachments .map((attachment) => ({ @@ -13285,6 +13288,11 @@ export function createAgentChatService(args: { // session.compacted lands when it finishes. Surface this as a live begin so // the chat shows "compacting…" instead of feeling stuck. if (part.type === "compaction") { + const partId = typeof (part as { id?: unknown }).id === "string" ? (part as { id: string }).id : null; + if (partId) { + if (runtime.compactionStartedPartIds.has(partId)) continue; + runtime.compactionStartedPartIds.add(partId); + } const trigger = (part as { auto?: boolean }).auto === false ? "manual" : "auto"; runtime.lastCompactionTrigger = trigger; emitChatEvent(managed, { diff --git a/apps/desktop/src/main/services/lanes/laneService.test.ts b/apps/desktop/src/main/services/lanes/laneService.test.ts index 4c93267f9..0340e7f8c 100644 --- a/apps/desktop/src/main/services/lanes/laneService.test.ts +++ b/apps/desktop/src/main/services/lanes/laneService.test.ts @@ -111,47 +111,52 @@ describe("laneService createFromUnstaged", () => { it("includes worktree availability in lane summaries", async () => { const repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-lane-service-worktree-available-")); const db = await openKvDb(path.join(repoRoot, "kv.sqlite"), createLogger()); - await seedProjectAndStack(db, { projectId: "proj-worktree-available", repoRoot }); - const missingChildPath = path.join(repoRoot, "child"); + try { + await seedProjectAndStack(db, { projectId: "proj-worktree-available", repoRoot }); + const missingChildPath = path.join(repoRoot, "child"); - vi.mocked(runGit).mockImplementation(async (args: string[], opts?: { cwd?: string }) => { - const laneBranchGitStub = defaultLaneBranchGitStub(args); - if (laneBranchGitStub) return laneBranchGitStub; - const cwd = opts?.cwd ?? repoRoot; - if (args[0] === "worktree" && args[1] === "list") return { exitCode: 0, stdout: "", stderr: "" }; - if (args[0] === "rev-parse" && args[1] === "--abbrev-ref" && args[2] === "HEAD") { - return { exitCode: 0, stdout: "main\n", stderr: "" }; - } - if (args[0] === "rev-parse" && args[1] === "--path-format=absolute" && args[2] === "--show-toplevel") { - return cwd === missingChildPath - ? { exitCode: 128, stdout: "", stderr: "missing worktree" } - : { exitCode: 0, stdout: `${cwd}\n`, stderr: "" }; - } - if (args[0] === "rev-parse" && args[1] === "--verify") return { exitCode: 1, stdout: "", stderr: "" }; - if (args[0] === "status") return { exitCode: 0, stdout: "", stderr: "" }; - if (args[0] === "rev-list" && args[1] === "--left-right") return { exitCode: 0, stdout: "0\t0\n", stderr: "" }; - if (args[0] === "rev-parse" && args[1] === "--abbrev-ref" && args.includes("@{upstream}")) { - return { exitCode: 1, stdout: "", stderr: "" }; - } - if (args[0] === "rev-parse" && args[1] === "--path-format=absolute" && args[2] === "--git-dir") { - return { exitCode: 1, stdout: "", stderr: "" }; - } - throw new Error(`Unexpected git call: ${args.join(" ")}`); - }); + vi.mocked(runGit).mockImplementation(async (args: string[], opts?: { cwd?: string }) => { + const laneBranchGitStub = defaultLaneBranchGitStub(args); + if (laneBranchGitStub) return laneBranchGitStub; + const cwd = opts?.cwd ?? repoRoot; + if (args[0] === "worktree" && args[1] === "list") return { exitCode: 0, stdout: "", stderr: "" }; + if (args[0] === "rev-parse" && args[1] === "--abbrev-ref" && args[2] === "HEAD") { + return { exitCode: 0, stdout: "main\n", stderr: "" }; + } + if (args[0] === "rev-parse" && args[1] === "--path-format=absolute" && args[2] === "--show-toplevel") { + return cwd === missingChildPath + ? { exitCode: 128, stdout: "", stderr: "missing worktree" } + : { exitCode: 0, stdout: `${cwd}\n`, stderr: "" }; + } + if (args[0] === "rev-parse" && args[1] === "--verify") return { exitCode: 1, stdout: "", stderr: "" }; + if (args[0] === "status") return { exitCode: 0, stdout: "", stderr: "" }; + if (args[0] === "rev-list" && args[1] === "--left-right") return { exitCode: 0, stdout: "0\t0\n", stderr: "" }; + if (args[0] === "rev-parse" && args[1] === "--abbrev-ref" && args.includes("@{upstream}")) { + return { exitCode: 1, stdout: "", stderr: "" }; + } + if (args[0] === "rev-parse" && args[1] === "--path-format=absolute" && args[2] === "--git-dir") { + return { exitCode: 1, stdout: "", stderr: "" }; + } + throw new Error(`Unexpected git call: ${args.join(" ")}`); + }); - const service = createLaneService({ - db, - projectRoot: repoRoot, - projectId: "proj-worktree-available", - defaultBaseRef: "main", - worktreesDir: path.join(repoRoot, "worktrees"), - }); + const service = createLaneService({ + db, + projectRoot: repoRoot, + projectId: "proj-worktree-available", + defaultBaseRef: "main", + worktreesDir: path.join(repoRoot, "worktrees"), + }); - const lanes = await service.list({ includeStatus: true }); + const lanes = await service.list({ includeStatus: true }); - expect(lanes.find((lane) => lane.id === "lane-main")?.worktreeAvailable).toBe(true); - expect(lanes.find((lane) => lane.id === "lane-parent")?.worktreeAvailable).toBe(true); - expect(lanes.find((lane) => lane.id === "lane-child")?.worktreeAvailable).toBe(false); + expect(lanes.find((lane) => lane.id === "lane-main")?.worktreeAvailable).toBe(true); + expect(lanes.find((lane) => lane.id === "lane-parent")?.worktreeAvailable).toBe(true); + expect(lanes.find((lane) => lane.id === "lane-child")?.worktreeAvailable).toBe(false); + } finally { + db.close(); + fs.rmSync(repoRoot, { recursive: true, force: true }); + } }); it("recreates the primary lane when the only stored primary lane is archived", async () => { From d1989c4d6c3a8880301e4c41f6cb095046e3d825 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Mon, 15 Jun 2026 15:31:59 -0400 Subject: [PATCH 09/10] fix(ade-code): preserve ambiguous remote project errors --- .../__tests__/remoteLauncher.test.ts | 26 ++++++++++++++++++- apps/ade-cli/src/tuiClient/remoteLauncher.ts | 5 ++-- 2 files changed, 28 insertions(+), 3 deletions(-) diff --git a/apps/ade-cli/src/tuiClient/__tests__/remoteLauncher.test.ts b/apps/ade-cli/src/tuiClient/__tests__/remoteLauncher.test.ts index 838348ca2..8322bf2cb 100644 --- a/apps/ade-cli/src/tuiClient/__tests__/remoteLauncher.test.ts +++ b/apps/ade-cli/src/tuiClient/__tests__/remoteLauncher.test.ts @@ -1,9 +1,10 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { buildRemoteRuntimeRpcCommand, listRemoteSessions, parseRemoteAdeCodeArgs, remoteRuntimeLayoutCandidates, + selectProject, takeAdeCodeRemoteArgs, } from "../remoteLauncher"; @@ -207,4 +208,27 @@ describe("ade code remote launcher", () => { { sessionId: "claude-metadata-1", kind: "terminal", title: "Claude metadata terminal" }, ]); }); + + it("does not register a new remote project when a path query is ambiguous", async () => { + const request = vi.fn(); + await expect(selectProject(request as never, [ + { + projectId: "frontend", + displayName: "frontend", + rootPath: "/home/alice/frontend", + addedAt: 0, + lastOpenedAt: 0, + gitOriginUrl: null, + }, + { + projectId: "backend", + displayName: "backend", + rootPath: "/home/alice/backend", + addedAt: 0, + lastOpenedAt: 0, + gitOriginUrl: null, + }, + ], "/home/alice")).rejects.toThrow("matches multiple entries"); + expect(request).not.toHaveBeenCalled(); + }); }); diff --git a/apps/ade-cli/src/tuiClient/remoteLauncher.ts b/apps/ade-cli/src/tuiClient/remoteLauncher.ts index 39aa1d9b9..5d2a90ade 100644 --- a/apps/ade-cli/src/tuiClient/remoteLauncher.ts +++ b/apps/ade-cli/src/tuiClient/remoteLauncher.ts @@ -942,7 +942,7 @@ async function selectScope(options: RemoteCliOptions): Promise Date: Mon, 15 Jun 2026 15:33:31 -0400 Subject: [PATCH 10/10] fix(opencode): dedupe compaction parts without ids --- .../src/main/services/chat/agentChatService.test.ts | 6 +++--- apps/desktop/src/main/services/chat/agentChatService.ts | 8 ++++---- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/apps/desktop/src/main/services/chat/agentChatService.test.ts b/apps/desktop/src/main/services/chat/agentChatService.test.ts index 504325f18..f9046e92a 100644 --- a/apps/desktop/src/main/services/chat/agentChatService.test.ts +++ b/apps/desktop/src/main/services/chat/agentChatService.test.ts @@ -17098,7 +17098,7 @@ describe("createAgentChatService", () => { await sendPromise; }); - it("dedupes repeated OpenCode compaction part updates", async () => { + it("dedupes repeated OpenCode compaction part updates without relying on part ids", async () => { const events: AgentChatEventEnvelope[] = []; let releaseStream!: () => void; const streamGate = new Promise((resolve) => { @@ -17140,14 +17140,14 @@ describe("createAgentChatService", () => { { type: "message.part.updated", properties: { - part: { id: "compact-part-1", sessionID: "opencode-session-1", type: "compaction", auto: false }, + part: { sessionID: "opencode-session-1", type: "compaction", auto: false }, delta: "", }, }, { type: "message.part.updated", properties: { - part: { id: "compact-part-1", sessionID: "opencode-session-1", type: "compaction", auto: false }, + part: { sessionID: "opencode-session-1", type: "compaction", auto: false }, delta: "", }, }, diff --git a/apps/desktop/src/main/services/chat/agentChatService.ts b/apps/desktop/src/main/services/chat/agentChatService.ts index c6f91b90c..5f6d0ce85 100644 --- a/apps/desktop/src/main/services/chat/agentChatService.ts +++ b/apps/desktop/src/main/services/chat/agentChatService.ts @@ -13276,6 +13276,7 @@ export function createAgentChatService(args: { turnId, }); runtime.lastCompactionTrigger = null; + runtime.compactionStartedPartIds.clear(); continue; } @@ -13289,10 +13290,9 @@ export function createAgentChatService(args: { // the chat shows "compacting…" instead of feeling stuck. if (part.type === "compaction") { const partId = typeof (part as { id?: unknown }).id === "string" ? (part as { id: string }).id : null; - if (partId) { - if (runtime.compactionStartedPartIds.has(partId)) continue; - runtime.compactionStartedPartIds.add(partId); - } + const compactionKey = partId ?? `turn:${turnId}`; + if (runtime.compactionStartedPartIds.has(compactionKey)) continue; + runtime.compactionStartedPartIds.add(compactionKey); const trigger = (part as { auto?: boolean }).auto === false ? "manual" : "auto"; runtime.lastCompactionTrigger = trigger; emitChatEvent(managed, {