diff --git a/apps/ade-cli/README.md b/apps/ade-cli/README.md index ea59aac23..5ba7dcdd1 100644 --- a/apps/ade-cli/README.md +++ b/apps/ade-cli/README.md @@ -192,6 +192,8 @@ sync.getPin sync.setPin sync.clearPin sync.setActiveLanePresence ``` +`runtimeEvents.subscribe` returns `eventEpoch`, `nextCursor`, `hasMore`, `gap`, and `oldestCursor`; when `gap` is true, the caller's cursor predates the retained buffer and it should refresh state before resuming from `oldestCursor` / `nextCursor`. + **Project-scoped** — every other request must carry `params.projectId`. `ade/actions/call` (and the legacy ADE action / tool catalog underneath it) is dispatched into the per-project `ProjectScope` returned by `ProjectScopeRegistry.get(projectId)`. `ade/initialize` advertises `runtimeInfo.multiProject: true` and `capabilities.projects: true`. Clients use that to switch between sending `projectId` per request (multi-project runtime) and the legacy per-process binding (embedded runtime). Sync is owned by the sync service for the most-recently-opened registered project; `ProjectScopeRegistry.ensureSyncHost` refreshes the active sync project when projects are added or removed. @@ -217,7 +219,7 @@ 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 + # open a remote chat or provider CLI 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 ``` @@ -291,6 +293,8 @@ ade prs comments pr-id --text ade run defs --text ade run start web --lane lane-id ade shell start --lane lane-id -- npm test +ade terminal list --lane lane-id --text +ade terminal resume --terminal session-id --text ade new chat --mode chat --lane lane-id --provider codex --model openai/gpt-5.5 --reasoning-effort xhigh --no-fast --permissions full-auto --prompt "fix failing tests" ade new chat --mode cli --lane lane-id --provider codex --model openai/gpt-5.5 --reasoning-effort xhigh --no-fast --permissions full-auto --prompt "fix failing tests" ade new chat --mode chat --lane auto --lane-name fix-checkout-flow --prompt "fix failing tests" diff --git a/apps/ade-cli/package-lock.json b/apps/ade-cli/package-lock.json index 6bd871fd5..7c5772f1a 100644 --- a/apps/ade-cli/package-lock.json +++ b/apps/ade-cli/package-lock.json @@ -32,6 +32,7 @@ "opencode-ai": "^1.15.5", "react": "^19.2.7", "sql.js": "^1.13.0", + "string-width": "^4.2.3", "ws": "^8.20.0", "yaml": "^2.8.2", "zod": "^4.3.6" @@ -1822,7 +1823,6 @@ "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", "license": "MIT", - "optional": true, "engines": { "node": ">=8" } @@ -2552,8 +2552,7 @@ "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "license": "MIT", - "optional": true + "license": "MIT" }, "node_modules/encodeurl": { "version": "2.0.0", @@ -3493,7 +3492,6 @@ "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", "license": "MIT", - "optional": true, "engines": { "node": ">=8" } @@ -5341,7 +5339,6 @@ "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", "license": "MIT", - "optional": true, "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", @@ -5356,7 +5353,6 @@ "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", "license": "MIT", - "optional": true, "dependencies": { "ansi-regex": "^5.0.1" }, @@ -7636,8 +7632,7 @@ "ansi-regex": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "optional": true + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==" }, "ansi-styles": { "version": "5.2.0", @@ -8100,8 +8095,7 @@ "emoji-regex": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "optional": true + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==" }, "encodeurl": { "version": "2.0.0", @@ -8697,8 +8691,7 @@ "is-fullwidth-code-point": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", - "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "optional": true + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==" }, "is-in-ci": { "version": "2.0.0", @@ -9851,7 +9844,6 @@ "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "optional": true, "requires": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", @@ -9862,7 +9854,6 @@ "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "optional": true, "requires": { "ansi-regex": "^5.0.1" } diff --git a/apps/ade-cli/package.json b/apps/ade-cli/package.json index 06378d539..9bcb9a0f1 100644 --- a/apps/ade-cli/package.json +++ b/apps/ade-cli/package.json @@ -48,6 +48,7 @@ "opencode-ai": "^1.15.5", "react": "^19.2.7", "sql.js": "^1.13.0", + "string-width": "^4.2.3", "ws": "^8.20.0", "yaml": "^2.8.2", "zod": "^4.3.6" diff --git a/apps/ade-cli/src/adeRpcServer.ts b/apps/ade-cli/src/adeRpcServer.ts index 1b666a443..4380b4373 100644 --- a/apps/ade-cli/src/adeRpcServer.ts +++ b/apps/ade-cli/src/adeRpcServer.ts @@ -4844,7 +4844,9 @@ async function runTool(args: { events: sliced, nextCursor: result.nextCursor, hasMore: filtered.length > limit || result.hasMore, - eventEpoch: result.eventEpoch + eventEpoch: result.eventEpoch, + gap: result.gap === true, + oldestCursor: result.oldestCursor ?? null }; } return runtime.eventBuffer.drain(cursor, limit); diff --git a/apps/ade-cli/src/bootstrap.test.ts b/apps/ade-cli/src/bootstrap.test.ts index d21209c48..012f0fe88 100644 --- a/apps/ade-cli/src/bootstrap.test.ts +++ b/apps/ade-cli/src/bootstrap.test.ts @@ -35,9 +35,73 @@ describe("createEventBuffer", () => { const result = buffer.drain(0); // Should have IDs 3, 4, 5 (the oldest 1, 2 were evicted) expect(result.events.map((e) => e.id)).toEqual([3, 4, 5]); + expect(result.gap).toBe(true); + expect(result.oldestCursor).toBe(3); expect(result.events[0]!.payload).toEqual({ i: 2 }); }); + it("reports no gap when the next retained event is contiguous with the cursor", () => { + const buffer = createEventBuffer(3); + + for (let i = 0; i < 5; i++) { + buffer.push({ timestamp: `2026-03-01T00:0${i}:00Z`, category: "runtime", payload: { i } }); + } + + const result = buffer.drain(2); + expect(result.events.map((event) => event.id)).toEqual([3, 4, 5]); + expect(result.gap).toBe(false); + expect(result.oldestCursor).toBe(3); + }); + + it("evicts by retained byte budget and reports the replay gap", () => { + const buffer = createEventBuffer(10, { maxBytes: 260, maxEventBytes: 260 }); + + for (let i = 0; i < 4; i++) { + buffer.push({ + timestamp: `2026-03-01T00:0${i}:00Z`, + category: "pty", + payload: { data: "x".repeat(90), i }, + }); + } + + const result = buffer.drain(0); + expect(result.events.length).toBeLessThan(4); + expect(result.events.at(-1)?.id).toBe(4); + expect(result.gap).toBe(true); + expect(result.oldestCursor).toBeGreaterThan(1); + }); + + it("does not retain events larger than the per-event cap but still notifies live subscribers", () => { + const buffer = createEventBuffer(10, { maxBytes: 1024, maxEventBytes: 64 }); + const seen: BufferedEvent[] = []; + buffer.subscribe((event) => seen.push(event)); + + buffer.push({ + timestamp: "2026-03-01T00:00:00Z", + category: "pty", + payload: { data: "x".repeat(200) }, + }); + + expect(seen).toHaveLength(1); + const result = buffer.drain(0); + expect(result.events).toEqual([]); + expect(result.gap).toBe(true); + expect(result.nextCursor).toBe(1); + }); + + it("reports a replay gap when an oversized event is skipped between retained events", () => { + const buffer = createEventBuffer(10, { maxBytes: 4096, maxEventBytes: 180 }); + + buffer.push({ timestamp: "2026-03-01T00:00:00Z", category: "runtime", payload: { data: "small-1" } }); + buffer.push({ timestamp: "2026-03-01T00:00:01Z", category: "runtime", payload: { data: "x".repeat(500) } }); + buffer.push({ timestamp: "2026-03-01T00:00:02Z", category: "runtime", payload: { data: "small-2" } }); + + const result = buffer.drain(1); + expect(result.events.map((event) => event.id)).toEqual([3]); + expect(result.gap).toBe(true); + expect(result.oldestCursor).toBe(3); + }); + it("drains events after cursor", () => { const buffer = createEventBuffer(); diff --git a/apps/ade-cli/src/cli.ts b/apps/ade-cli/src/cli.ts index 88b7fdb59..ecf0867ff 100644 --- a/apps/ade-cli/src/cli.ts +++ b/apps/ade-cli/src/cli.ts @@ -478,7 +478,8 @@ const TOP_LEVEL_HELP = `${ADE_BANNER} $ ade prs list | create | show | checks Manage PRs, queues, and GitHub integration $ ade run defs | ps | start | logs Manage Run tab process definitions and runtime $ ade shell start | write | resize | close Launch and control tracked shell sessions - $ ade terminal list | read | write | signal Control an attached session terminal + $ ade terminal list | resume | read | write | signal + Control an attached session terminal $ ade history list | show | commits | export Inspect ADE operation timeline and lane commits $ ade chat list | create | send | interrupt Work with ADE agent chats $ ade cto state | chats Operate CTO state and Work chats @@ -1151,12 +1152,12 @@ const HELP_BY_COMMAND: Record = { $ 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 + Open a specific remote chat or provider CLI 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 + List remote chat and provider CLI terminal sessions $ ade --project-root code Launch against a specific ADE project Keys: @@ -1389,8 +1390,9 @@ const HELP_BY_COMMAND: Record = { tracked agent CLI session. Use attached runtime mode when you want the same terminal the app is viewing. - $ ade terminal list --chat-session --text List terminals for a session + $ ade terminal list --chat-session --text List running and ended terminals for a session $ ade terminal active --chat-session --text Show the active terminal + $ ade terminal resume --terminal --text Resume an ended provider CLI terminal $ ade terminal read --terminal --text Read terminal scrollback $ ade terminal read --pty --text Read by PTY id $ ade app-control logs --text Read the active App Control launch terminal @@ -5918,6 +5920,7 @@ function buildTerminalPlan(args: string[]): CliPlan { return { kind: "execute", label: "terminal list", + formatter: "terminal-list", steps: [ actionStep( "result", @@ -5932,6 +5935,31 @@ function buildTerminalPlan(args: string[]): CliPlan { ], }; } + if (sub === "resume" || sub === "reattach") { + const terminal = + readValue(args, ["--terminal", "--terminal-id", "--session", "--session-id"]) ?? + firstStandalonePositional(args); + return { + kind: "execute", + label: "terminal resume", + formatter: "pty-create", + steps: [ + actionStep( + "result", + "pty", + "resumeSession", + collectGenericObjectArgs(args, { + sessionId: requireValue(terminal, "terminalId"), + cols: readIntOption(args, ["--cols"], 120), + rows: readIntOption(args, ["--rows"], 36), + model: readValue(args, ["--model", "--model-id"]), + reasoningEffort: readValue(args, ["--reasoning", "--reasoning-effort"]), + permissionMode: readValue(args, ["--permission-mode", "--permissions"]), + }), + ), + ], + }; + } if (sub === "active" || sub === "current") { return { kind: "execute", @@ -11250,6 +11278,33 @@ function createSocketConnection(socketPath: string): net.Socket { return net.createConnection(socketPath); } +async function probeLocalSocketForLiveness(socketPath: string): Promise<"live" | "stale" | "unknown"> { + if (socketPath.startsWith("tcp://") || isAdeRuntimeNamedPipePath(socketPath)) { + return "unknown"; + } + return await new Promise((resolve) => { + const socket = net.createConnection(socketPath); + let settled = false; + let timer: ReturnType | null = null; + const finish = (value: "live" | "stale" | "unknown") => { + if (settled) return; + settled = true; + if (timer) clearTimeout(timer); + socket.destroy(); + resolve(value); + }; + timer = setTimeout(() => finish("unknown"), 500); + socket.once("connect", () => finish("live")); + socket.once("error", (error: NodeJS.ErrnoException) => { + if (error.code === "ENOENT" || error.code === "ECONNREFUSED") { + finish("stale"); + return; + } + finish("unknown"); + }); + }); +} + function isRetryableSocketConnectError(error: NodeJS.ErrnoException): boolean { return ( error.code === "ENOENT" || @@ -11313,7 +11368,7 @@ class SocketJsonRpcClient { private nextId = 1; private closedError: Error | null = null; private pending = new Map< - number, + string, { resolve: (value: unknown) => void; reject: (error: Error) => void; @@ -11363,15 +11418,16 @@ class SocketJsonRpcClient { }; const body = `${JSON.stringify(payload)}\n`; return new Promise((resolve, reject) => { + const pendingKey = String(id); const timer = setTimeout(() => { - this.pending.delete(id); + this.pending.delete(pendingKey); reject(new Error(`Timed out waiting for ${method}.`)); }, this.timeoutMs); - this.pending.set(id, { resolve, reject, timer }); + this.pending.set(pendingKey, { resolve, reject, timer }); this.socket.write(body, "utf8", (error) => { if (!error) return; clearTimeout(timer); - this.pending.delete(id); + this.pending.delete(pendingKey); reject(error); }); }); @@ -11454,7 +11510,7 @@ class SocketJsonRpcClient { return; } if (!isRecord(parsed)) return; - const id = typeof parsed.id === "number" ? parsed.id : null; + const id = typeof parsed.id === "number" || typeof parsed.id === "string" ? parsed.id : null; if (id == null) { const method = asString(parsed.method); if (!method) return; @@ -11466,9 +11522,10 @@ class SocketJsonRpcClient { } return; } - const pending = this.pending.get(id); + const pendingKey = String(id); + const pending = this.pending.get(pendingKey); if (!pending) return; - this.pending.delete(id); + this.pending.delete(pendingKey); clearTimeout(pending.timer); if (isRecord(parsed.error)) { pending.reject( @@ -13690,9 +13747,21 @@ async function runServe( fs.mkdirSync(layout.adeDir, { recursive: true, mode: 0o700 }); if (!isAdeRuntimeNamedPipePath(socketPath)) { fs.mkdirSync(path.dirname(socketPath), { recursive: true, mode: 0o700 }); - try { - fs.unlinkSync(socketPath); - } catch {} + if (fs.existsSync(socketPath)) { + const liveness = await probeLocalSocketForLiveness(socketPath); + if (liveness === "live" || liveness === "unknown") { + throw new CliExecutionError("ADE brain socket is already in use.", { + socketPath, + cause: liveness === "live" + ? "Another ADE brain is accepting connections on this socket." + : "ADE could not prove the existing socket is stale.", + nextAction: "Stop the existing ADE brain or choose a different --socket path.", + }); + } + try { + fs.unlinkSync(socketPath); + } catch {} + } } const socketState = createHeadlessRpcServer(createHandler); @@ -15198,13 +15267,15 @@ function formatTerminalList(value: unknown): string { ? [value] : firstArray(value, ["terminals", "items"]); return renderTable( - ["terminal", "pty", "chat", "status", "runtime", "title"], + ["terminal", "pty", "chat", "status", "runtime", "ended", "resume", "title"], terminals.map((terminal) => [ terminal.terminalId, terminal.ptyId, terminal.chatSessionId, terminal.status, terminal.runtimeState, + terminal.endedAt, + terminal.endedAt && (terminal.resumeCommand || terminal.resumeMetadata) ? "yes" : "", terminal.title, ]), "ADE attached terminals\n(no terminals found)", diff --git a/apps/ade-cli/src/eventBuffer.ts b/apps/ade-cli/src/eventBuffer.ts index 6c4447da1..4015657d2 100644 --- a/apps/ade-cli/src/eventBuffer.ts +++ b/apps/ade-cli/src/eventBuffer.ts @@ -12,6 +12,8 @@ export type EventBufferDrainResult = { nextCursor: number; hasMore: boolean; eventEpoch: string; + gap: boolean; + oldestCursor: number | null; }; export type EventBuffer = { @@ -23,18 +25,69 @@ export type EventBuffer = { size(): number; }; -export function createEventBuffer(capacity = 10_000): EventBuffer { - const events: BufferedEvent[] = []; +type RetainedBufferedEvent = { + event: BufferedEvent; + bytes: number; +}; + +export type EventBufferOptions = { + maxBytes?: number; + maxEventBytes?: number; +}; + +const DEFAULT_EVENT_BUFFER_MAX_BYTES = 16 * 1024 * 1024; +const DEFAULT_EVENT_BUFFER_MAX_EVENT_BYTES = 1024 * 1024; + +export function createEventBuffer( + capacity = 10_000, + options: EventBufferOptions = {}, +): EventBuffer { + const events: RetainedBufferedEvent[] = []; const listeners = new Set<(event: BufferedEvent) => void>(); const eventEpoch = randomUUID(); + const maxBytes = Math.max(0, Math.floor(options.maxBytes ?? DEFAULT_EVENT_BUFFER_MAX_BYTES)); + const maxEventBytes = Math.max(0, Math.floor(options.maxEventBytes ?? DEFAULT_EVENT_BUFFER_MAX_EVENT_BYTES)); let nextId = 1; + let retainedBytes = 0; + let lastSkippedCursor: number | null = null; + + const evictOldest = (): void => { + const evicted = events.shift(); + if (evicted) retainedBytes = Math.max(0, retainedBytes - evicted.bytes); + }; + + const drainMetadata = (cursor: number): Pick => { + const oldest = events[0]?.event.id ?? null; + const skippedGap = lastSkippedCursor != null && cursor < lastSkippedCursor; + if (oldest == null) { + const gap = cursor < nextId - 1 || skippedGap; + return { + gap, + oldestCursor: gap ? nextId : null, + }; + } + const retainedGap = cursor < oldest - 1; + return { + gap: retainedGap || skippedGap, + oldestCursor: skippedGap + ? Math.max(oldest, (lastSkippedCursor ?? 0) + 1) + : oldest, + }; + }; return { push(event) { const entry: BufferedEvent = { id: nextId++, ...event }; - events.push(entry); - while (events.length > capacity) { - events.shift(); + const bytes = Buffer.byteLength(JSON.stringify(entry), "utf8"); + if (bytes > maxEventBytes) { + lastSkippedCursor = entry.id; + } + if (capacity > 0 && maxBytes > 0 && bytes <= maxEventBytes) { + events.push({ event: entry, bytes }); + retainedBytes += bytes; + while (events.length > capacity || retainedBytes > maxBytes) { + evictOldest(); + } } for (const listener of [...listeners]) { try { @@ -46,17 +99,26 @@ export function createEventBuffer(capacity = 10_000): EventBuffer { }, drain(cursor, limit = 100) { const clamped = Math.max(1, Math.min(1000, limit)); - const startIdx = events.findIndex((e) => e.id > cursor); + const metadata = drainMetadata(cursor); + const startIdx = events.findIndex((e) => e.event.id > cursor); if (startIdx === -1) { - return { events: [], nextCursor: cursor, hasMore: false, eventEpoch }; + return { + events: [], + nextCursor: metadata.gap ? nextId - 1 : cursor, + hasMore: false, + eventEpoch, + ...metadata, + }; } const slice = events.slice(startIdx, startIdx + clamped); - const lastId = slice.length > 0 ? slice[slice.length - 1]!.id : cursor; + const drained = slice.map((entry) => entry.event); + const lastId = drained.length > 0 ? drained[drained.length - 1]!.id : cursor; return { - events: slice, + events: drained, nextCursor: lastId, hasMore: startIdx + clamped < events.length, eventEpoch, + ...metadata, }; }, subscribe(listener) { diff --git a/apps/ade-cli/src/multiProjectRpcServer.ts b/apps/ade-cli/src/multiProjectRpcServer.ts index 195122fe3..c763eadf8 100644 --- a/apps/ade-cli/src/multiProjectRpcServer.ts +++ b/apps/ade-cli/src/multiProjectRpcServer.ts @@ -409,6 +409,8 @@ export function createMultiProjectRpcRequestHandler( nextCursor: scope.runtime.eventBuffer.latestCursor(), hasMore: false, eventEpoch, + gap: false, + oldestCursor: null, }; for (const event of replayResult.events) { if (shouldForward(event)) @@ -419,6 +421,8 @@ export function createMultiProjectRpcRequestHandler( nextCursor: replayResult.nextCursor, hasMore: replayResult.hasMore, eventEpoch: replayResult.eventEpoch, + gap: replayResult.gap === true, + oldestCursor: replayResult.oldestCursor ?? null, }; }; diff --git a/apps/ade-cli/src/services/sync/brainProjectActionsSyncHandler.ts b/apps/ade-cli/src/services/sync/brainProjectActionsSyncHandler.ts index 391650175..60a1efcb8 100644 --- a/apps/ade-cli/src/services/sync/brainProjectActionsSyncHandler.ts +++ b/apps/ade-cli/src/services/sync/brainProjectActionsSyncHandler.ts @@ -32,7 +32,11 @@ import { parseSyncEnvelope, wsDataToText, } from "./syncProtocol"; -import type { SyncProjectCatalogProvider } from "./syncHostService"; +import { + buildSyncHostHelloOkPayload, + buildSyncProjectCatalogMessages, + type SyncProjectCatalogProvider, +} from "./syncHostService"; import { resolveDeviceDisplayName } from "./deviceRegistryService"; type BrainProjectActionsSyncHandlerArgs = { @@ -209,6 +213,20 @@ function send( })); } +function sendProjectCatalog( + ws: WebSocket, + catalog: SyncProjectCatalogPayload, + requestId?: string | null, +): void { + for (const message of buildSyncProjectCatalogMessages({ + projectCatalog: catalog, + requestId, + compressionThresholdBytes: DEFAULT_SYNC_COMPRESSION_THRESHOLD_BYTES, + })) { + send(ws, message.type, message.payload, message.requestId); + } +} + function projectActionsEnabled(provider: SyncProjectCatalogProvider): boolean { return Boolean( provider.browseDirectories @@ -343,7 +361,7 @@ export function createBrainProjectActionsSyncHandler( try { const project = await action(payload); send(peer.ws, resultType, { ok: true, project }, requestId); - send(peer.ws, "project_catalog", await projectCatalog(args.projectCatalogProvider, args.logger)); + sendProjectCatalog(peer.ws, await projectCatalog(args.projectCatalogProvider, args.logger)); } catch (error) { send(peer.ws, resultType, { ok: false, @@ -355,7 +373,7 @@ export function createBrainProjectActionsSyncHandler( const handleAuthenticatedEnvelope = async (peer: BrainPeerState, envelope: ReturnType): Promise => { switch (envelope.type) { case "project_catalog_request": { - send(peer.ws, "project_catalog", await projectCatalog(args.projectCatalogProvider, args.logger), envelope.requestId); + sendProjectCatalog(peer.ws, await projectCatalog(args.projectCatalogProvider, args.logger), envelope.requestId); break; } case "project_switch_request": { @@ -410,7 +428,7 @@ export function createBrainProjectActionsSyncHandler( ); send(peer.ws, "project_forget_result", result, envelope.requestId); if (result.ok) { - send(peer.ws, "project_catalog", await projectCatalog(args.projectCatalogProvider, args.logger)); + sendProjectCatalog(peer.ws, await projectCatalog(args.projectCatalogProvider, args.logger)); } } catch (error) { send(peer.ws, "project_forget_result", { @@ -664,29 +682,21 @@ export function createBrainProjectActionsSyncHandler( peer.metadata = hello.peer; const catalog = await projectCatalog(args.projectCatalogProvider, args.logger); const brain = brainMetadata(); - send(ws, "hello_ok", { + send(ws, "hello_ok", buildSyncHostHelloOkPayload({ peer: hello.peer, brain, serverDbVersion: 0, heartbeatIntervalMs, pollIntervalMs, - projects: catalog.projects, - features: { - fileAccess: true, - terminalStreaming: true, - chatStreaming: { enabled: true }, - projectCatalog: { enabled: true }, - projectActions: { enabled: projectActionsEnabled(args.projectCatalogProvider) }, - changesetAck: { enabled: true }, - bootstrapAuth: true, - pairingAuth: { enabled: true, pinDigits: 6 }, - commandRouting: { - mode: "allowlisted", - supportedActions: [], - actions: [], - }, - }, - }, envelope.requestId); + projectCatalog: catalog, + projectCatalogEnabled: true, + crossProjectChatEnabled: false, + projectActionsEnabled: projectActionsEnabled(args.projectCatalogProvider), + remoteCommandSupportedActions: [], + remoteCommandDescriptors: [], + localCommandDescriptors: [], + compressionThresholdBytes: DEFAULT_SYNC_COMPRESSION_THRESHOLD_BYTES, + }), envelope.requestId); return; } diff --git a/apps/ade-cli/src/services/sync/sharedSyncListener.ts b/apps/ade-cli/src/services/sync/sharedSyncListener.ts index 666745581..43794d11d 100644 --- a/apps/ade-cli/src/services/sync/sharedSyncListener.ts +++ b/apps/ade-cli/src/services/sync/sharedSyncListener.ts @@ -29,6 +29,7 @@ const DEFAULT_PARKED_PEER_GRACE_MS = 30_000; // a few in-flight requests fit comfortably; anything beyond signals a peer // flooding an unowned socket. const PARKED_MESSAGE_BUFFER_LIMIT = 256; +const PARKED_MESSAGE_BUFFER_BYTES = 512 * 1024; // Mirror of the syncService preferred-port semantics: insist on the first // candidate for ~3.2s before drifting, so a dying listener from a previous // brain process can free the port paired phones have saved. @@ -112,6 +113,7 @@ export type SharedSyncListener = { type ParkedEntry = { snapshot: SyncPeerHandoffSnapshot; bufferedMessages: Array<{ data: RawData; isBinary: boolean }>; + bufferedBytes: number; onMessage: (data: RawData, isBinary: boolean) => void; onClose: () => void; onError: (error: Error) => void; @@ -123,6 +125,16 @@ function isRetryableListenerBindError(error: unknown): boolean { return code === "EADDRINUSE" || code === "EACCES"; } +function rawDataBytes(data: RawData): number { + if (typeof data === "string") return Buffer.byteLength(data, "utf8"); + if (Buffer.isBuffer(data)) return data.length; + if (Array.isArray(data)) { + return data.reduce((sum, chunk) => sum + rawDataBytes(chunk), 0); + } + if (data instanceof ArrayBuffer) return data.byteLength; + return Buffer.byteLength(String(data), "utf8"); +} + export function createSharedSyncListener(options: { logger?: SharedSyncListenerLogger; bindHost?: string; @@ -164,17 +176,50 @@ export function createSharedSyncListener(options: { } return; } - const bufferedMessages: Array<{ data: RawData; isBinary: boolean }> = [ - ...(snapshot.bufferedMessages ?? []), - ]; + const bufferedMessages: Array<{ data: RawData; isBinary: boolean }> = []; + let bufferedBytes = 0; + for (const message of snapshot.bufferedMessages ?? []) { + const messageBytes = rawDataBytes(message.data); + if ( + bufferedMessages.length >= PARKED_MESSAGE_BUFFER_LIMIT || + bufferedBytes + messageBytes > PARKED_MESSAGE_BUFFER_BYTES + ) { + try { + ws.close(4002, "Sync host handoff buffer exceeded"); + } catch {} + return; + } + bufferedMessages.push(message); + bufferedBytes += messageBytes; + } const entry: ParkedEntry = { snapshot: { ...snapshot, bufferedMessages }, bufferedMessages, + bufferedBytes, onMessage: (data, isBinary) => { // Buffer frames that arrive while no host owns the socket so the // adopting host can replay them (e.g. a hello sent mid-handoff). - if (bufferedMessages.length >= PARKED_MESSAGE_BUFFER_LIMIT) return; + const messageBytes = rawDataBytes(data); + if ( + bufferedMessages.length >= PARKED_MESSAGE_BUFFER_LIMIT || + entry.bufferedBytes + messageBytes > PARKED_MESSAGE_BUFFER_BYTES + ) { + unpark(entry); + logger.warn?.("sync_listener.parked_peer_buffer_overflow", { + peerDeviceId: snapshot.metadata?.deviceId ?? snapshot.pairedDeviceId ?? null, + bufferedMessages: bufferedMessages.length, + bufferedBytes: entry.bufferedBytes, + nextMessageBytes: messageBytes, + }); + try { + ws.close(4002, "Sync host handoff buffer exceeded"); + } catch { + // ignore close failures + } + return; + } bufferedMessages.push({ data, isBinary }); + entry.bufferedBytes += messageBytes; }, onClose: () => { const existing = parked.get(ws); diff --git a/apps/ade-cli/src/services/sync/syncHostService.test.ts b/apps/ade-cli/src/services/sync/syncHostService.test.ts index 79bfc193a..f772d3375 100644 --- a/apps/ade-cli/src/services/sync/syncHostService.test.ts +++ b/apps/ade-cli/src/services/sync/syncHostService.test.ts @@ -8,6 +8,7 @@ import type { CrsqlChangeRow, SyncMobileProjectSummary, SyncPeerMetadata, + SyncProjectCatalogPayload, SyncRemoteCommandDescriptor, } from "../../../../desktop/src/shared/types"; import { @@ -15,6 +16,7 @@ import { CHAT_EVENT_REPLAY_MAX_EVENTS, SYNC_HOST_CHAT_ACTIVE_BACKGROUND_BACKPRESSURE_BYTES, buildSyncHostHelloOkPayload, + buildSyncProjectCatalogMessages, createChatEventReplayBuffer, createSyncHostService, planChatEventResume, @@ -224,6 +226,47 @@ describe("buildSyncHostHelloOkPayload", () => { }); }); +describe("buildSyncProjectCatalogMessages", () => { + it("keeps small catalogs as a single catalog message", () => { + const project = createDiscoveryProject({ id: "project-small", rootPath: "/srv/small" }); + + expect(buildSyncProjectCatalogMessages({ + projectCatalog: { projects: [project] }, + requestId: "catalog-small", + compressionThresholdBytes: Number.MAX_SAFE_INTEGER, + })).toEqual([{ + type: "project_catalog", + payload: { projects: [project] }, + requestId: "catalog-small", + }]); + }); + + it("chunks oversized fallback catalogs with stable request metadata", () => { + const projects = Array.from({ length: 3 }, (_, index) => + createDiscoveryProject({ + id: `project-large-${index}`, + rootPath: `/srv/${"x".repeat(130_000)}-${index}`, + })); + + const messages = buildSyncProjectCatalogMessages({ + projectCatalog: { projects }, + requestId: "catalog-large", + compressionThresholdBytes: Number.MAX_SAFE_INTEGER, + maxProjectCatalogEnvelopeBytes: 512, + }); + + expect(messages.length).toBeGreaterThan(1); + expect(messages.every((message) => message.type === "project_catalog_chunk")).toBe(true); + expect(messages.every((message) => message.requestId === "catalog-large")).toBe(true); + const payloads = messages.map((message) => message.payload as { catalogId: string; index: number; total: number; done: boolean; projects: SyncMobileProjectSummary[] }); + expect(new Set(payloads.map((payload) => payload.catalogId)).size).toBe(1); + expect(payloads.map((payload) => payload.index)).toEqual([0, 1, 2]); + expect(payloads.every((payload) => payload.total === messages.length)).toBe(true); + expect(payloads.map((payload) => payload.done)).toEqual([false, false, true]); + expect(payloads.flatMap((payload) => payload.projects).map((project) => project.id)).toEqual(projects.map((project) => project.id)); + }); +}); + function makeChange(dbVersion: number, seq: number, value = `value-${seq}`): CrsqlChangeRow { return { table: "kv", @@ -1509,6 +1552,471 @@ describe("sync host handoff over a shared listener", () => { await listener.close(); } }); + + it("closes parked peers when buffered handoff frames exceed the byte budget", async () => { + const listener = createSharedSyncListener({ bindHost: "127.0.0.1", parkedPeerGraceMs: 500 }); + const logger = createDiscoveryLogger(); + const loggedListener = createSharedSyncListener({ + logger, + bindHost: "127.0.0.1", + parkedPeerGraceMs: 500, + }); + let client: WebSocket | null = null; + try { + await listener.close(); + const port = await loggedListener.ensureListening([0]); + client = new WebSocket(`ws://127.0.0.1:${port}`); + const { closeEvents } = trackClientEnvelopes(client); + await new Promise((resolve, reject) => { + client!.once("open", () => resolve()); + client!.once("error", reject); + }); + client.send(Buffer.alloc(600 * 1024, "x")); + + const closeEvent = await waitForValue( + () => closeEvents[0], + "parked byte-budget close", + ); + expect(closeEvent.code).toBe(4002); + expect(closeEvent.reason).toBe("Sync host handoff buffer exceeded"); + expect(logger.warn).toHaveBeenCalledWith( + "sync_listener.parked_peer_buffer_overflow", + expect.objectContaining({ nextMessageBytes: 600 * 1024 }), + ); + } finally { + try { + client?.close(); + } catch { + // ignore + } + await loggedListener.close(); + } + }); +}); + +describe("sync host reliability guards", () => { + beforeEach(() => { + publishMock.mockReset(); + spawnMock.mockReset(); + bonjourDestroyMock.mockReset(); + bonjourConstructorMock.mockReset(); + spawnMock.mockImplementation(() => ({ kill: vi.fn(), once: vi.fn(), unref: vi.fn() })); + }); + + function createReliabilityHost( + projectRoot: string, + overrides: Partial[0]> = {}, + ) { + const project = createDiscoveryProject({ + id: "project-1", + rootPath: projectRoot, + isOpen: true, + }); + const base = createHostArgs(projectRoot, [project]); + return createSyncHostService({ + ...base, + projectId: "project-1", + db: { + sync: { + getSiteId: () => "site-host-reliability", + getDbVersion: () => 0, + exportChangesSince: () => [], + applyChanges: () => ({ appliedCount: 0 }), + discardUnpublishedChangesForTables: () => {}, + }, + }, + deviceRegistryService: { + ...base.deviceRegistryService, + upsertPeerMetadata: vi.fn(), + }, + ...overrides, + } as unknown as Parameters[0]); + } + + it("serializes project switch handling without deadlocking later peer messages", async () => { + const { projectRoot, cleanup } = createTempProjectRoot(); + const project = createDiscoveryProject({ + id: "project-1", + rootPath: projectRoot, + isOpen: true, + }); + const prepareProjectConnection = vi.fn(async () => { + await new Promise((resolve) => setTimeout(resolve, 50)); + return { ok: true, project, connection: null }; + }); + const completeProjectConnection = vi.fn(async () => {}); + const host = createReliabilityHost(projectRoot, { + projectCatalogProvider: { + listProjects: vi.fn(async () => ({ projects: [project] })), + prepareProjectConnection, + completeProjectConnection, + }, + }); + let peer: Awaited> | null = null; + try { + const port = await host.waitUntilListening(); + peer = await connectPeer(port, host.getBootstrapToken(), "ios-project-switch"); + + peer.ws.send(encodeSyncEnvelope({ + type: "project_switch_request", + requestId: "switch-serialized", + payload: { projectId: "project-1" }, + })); + peer.ws.send(encodeSyncEnvelope({ + type: "project_catalog_request", + requestId: "catalog-after-switch", + payload: {}, + })); + + const switchResult = await waitForEnvelope(peer.envelopes, "project_switch_result", "switch-serialized"); + const catalog = await waitForEnvelope(peer.envelopes, "project_catalog", "catalog-after-switch"); + expect(switchResult.payload).toMatchObject({ ok: true }); + expect((catalog.payload as SyncProjectCatalogPayload).projects.map((entry) => entry.id)).toEqual(["project-1"]); + expect(completeProjectConnection).toHaveBeenCalledTimes(1); + } finally { + try { + peer?.ws.close(); + } catch { + // ignore + } + await host.dispose(); + cleanup(); + } + }); + + it("completes a prepared project switch even when result delivery fails", async () => { + const { projectRoot, cleanup } = createTempProjectRoot(); + const project = createDiscoveryProject({ + id: "project-1", + rootPath: projectRoot, + isOpen: true, + }); + const completeProjectConnection = vi.fn(async () => {}); + const host = createReliabilityHost(projectRoot, { + projectCatalogProvider: { + listProjects: vi.fn(async () => ({ projects: [project] })), + prepareProjectConnection: vi.fn(async () => ({ ok: true, project, connection: null })), + completeProjectConnection, + }, + }); + let peer: Awaited> | null = null; + let bufferedAmountSpy: { mockRestore(): void } | null = null; + try { + const port = await host.waitUntilListening(); + peer = await connectPeer(port, host.getBootstrapToken(), "ios-project-switch-stall"); + bufferedAmountSpy = vi + .spyOn(WebSocket.prototype, "bufferedAmount", "get") + .mockReturnValue(17 * 1024 * 1024); + + peer.ws.send(encodeSyncEnvelope({ + type: "project_switch_request", + requestId: "switch-stalled", + payload: { projectId: "project-1" }, + })); + + await waitForValue( + () => completeProjectConnection.mock.calls[0], + "project switch completion after send failure", + ); + expect(completeProjectConnection).toHaveBeenCalledWith( + { projectId: "project-1" }, + expect.objectContaining({ ok: true }), + ); + } finally { + bufferedAmountSpy?.mockRestore(); + try { + peer?.ws.close(); + } catch { + // ignore + } + await host.dispose(); + cleanup(); + } + }); + + it("closes peers instead of queueing required sends beyond the byte budget", async () => { + const { projectRoot, cleanup } = createTempProjectRoot(); + const host = createReliabilityHost(projectRoot); + let peer: Awaited> | null = null; + let bufferedAmountSpy: { mockRestore(): void } | null = null; + try { + const port = await host.waitUntilListening(); + peer = await connectPeer(port, host.getBootstrapToken(), "ios-required-backpressure"); + bufferedAmountSpy = vi + .spyOn(WebSocket.prototype, "bufferedAmount", "get") + .mockReturnValue(17 * 1024 * 1024); + + peer.ws.send(encodeSyncEnvelope({ + type: "file_request", + requestId: "required-backpressure", + payload: { action: "listWorkspaces", args: {} }, + })); + + const closeEvent = await waitForValue( + () => peer?.closeEvents[0], + "required-send backpressure close", + ); + expect(closeEvent.code).toBe(4001); + expect(closeEvent.reason).toBe("Required sync response backpressured"); + } finally { + bufferedAmountSpy?.mockRestore(); + try { + peer?.ws.close(); + } catch { + // ignore + } + await host.dispose(); + cleanup(); + } + }); + + it("closes peers instead of dropping project catalog chunks under backpressure", async () => { + const { projectRoot, cleanup } = createTempProjectRoot(); + const host = createReliabilityHost(projectRoot); + let peer: Awaited> | null = null; + let bufferedAmountSpy: { mockRestore(): void } | null = null; + try { + const port = await host.waitUntilListening(); + peer = await connectPeer(port, host.getBootstrapToken(), "ios-catalog-backpressure"); + bufferedAmountSpy = vi + .spyOn(WebSocket.prototype, "bufferedAmount", "get") + .mockReturnValue(17 * 1024 * 1024); + + peer.ws.send(encodeSyncEnvelope({ + type: "project_catalog_request", + requestId: "catalog-backpressure", + payload: {}, + })); + + const closeEvent = await waitForValue( + () => peer?.closeEvents[0], + "project catalog required-send backpressure close", + ); + expect(closeEvent.code).toBe(4001); + expect(closeEvent.reason).toBe("Required sync response backpressured"); + } finally { + bufferedAmountSpy?.mockRestore(); + try { + peer?.ws.close(); + } catch { + // ignore + } + await host.dispose(); + cleanup(); + } + }); + + it("processes heartbeat pongs while a long command is still queued", async () => { + vi.useFakeTimers(); + const { projectRoot, cleanup } = createTempProjectRoot(); + const sendMessage = vi.fn((): Promise => new Promise(() => {})); + const host = createReliabilityHost(projectRoot, { + heartbeatIntervalMs: 5_000, + agentChatService: { + sendMessage, + subscribeToEvents: vi.fn(() => vi.fn()), + } as unknown as Parameters[0]["agentChatService"], + }); + let peer: ReturnType & { ws: WebSocket } | null = null; + const waitForTrackedEnvelope = ( + tracked: ReturnType, + predicate: (envelope: ParsedSyncEnvelope) => boolean, + _label: string, + ): Promise => { + const existing = tracked.envelopes.find(predicate); + if (existing) return Promise.resolve(existing); + return new Promise((resolve) => { + const cleanupListener = () => { + peer?.ws.off("message", onMessage); + }; + const onMessage = () => { + const match = tracked.envelopes.find(predicate); + if (!match) return; + cleanupListener(); + resolve(match); + }; + peer?.ws.on("message", onMessage); + }); + }; + try { + const port = await host.waitUntilListening(); + const ws = new WebSocket(`ws://127.0.0.1:${port}`); + const tracked = trackClientEnvelopes(ws); + peer = { ws, ...tracked }; + await new Promise((resolve, reject) => { + ws.once("open", () => resolve()); + ws.once("error", reject); + }); + const helloOk = waitForTrackedEnvelope(tracked, (envelope) => envelope.type === "hello_ok", "hello_ok"); + ws.send(encodeSyncEnvelope({ + type: "hello", + payload: { + peer: { + deviceId: "desktop-heartbeat", + deviceName: "Desktop heartbeat", + platform: "macOS", + deviceType: "desktop", + siteId: "desktop-heartbeat-site", + dbVersion: 0, + }, + auth: { kind: "bootstrap", token: host.getBootstrapToken() }, + }, + })); + await helloOk; + + const commandAck = waitForTrackedEnvelope( + tracked, + (envelope) => envelope.type === "command_ack" && envelope.requestId === "long-command", + "long command ack", + ); + ws.send(encodeSyncEnvelope({ + type: "command", + requestId: "long-command", + projectId: "project-1", + payload: { + commandId: "long-command", + action: "chat.send", + projectId: "project-1", + args: { + sessionId: "session-1", + text: "hello", + }, + }, + })); + await commandAck; + + const ping = waitForTrackedEnvelope( + tracked, + (envelope) => envelope.type === "heartbeat" && (envelope.payload as { kind?: string } | null)?.kind === "ping", + "heartbeat ping", + ); + await vi.advanceTimersByTimeAsync(5_000); + const pingEnvelope = await ping; + ws.send(encodeSyncEnvelope({ + type: "heartbeat", + payload: { + kind: "pong", + sentAt: (pingEnvelope.payload as { sentAt?: string }).sentAt, + }, + })); + + await vi.advanceTimersByTimeAsync(10_000); + await Promise.resolve(); + expect(tracked.closeEvents).toEqual([]); + expect(sendMessage).toHaveBeenCalledTimes(1); + } finally { + try { + peer?.ws.close(); + } catch { + // ignore + } + await host.dispose(); + cleanup(); + vi.useRealTimers(); + } + }); + + it("times out a hung queued message and continues with later messages", async () => { + const { projectRoot, cleanup } = createTempProjectRoot(); + const logger = createDiscoveryLogger(); + const sendMessage = vi.fn((): Promise => new Promise(() => {})); + const host = createReliabilityHost(projectRoot, { + logger, + messageTimeoutMs: 100, + agentChatService: { + sendMessage, + subscribeToEvents: vi.fn(() => vi.fn()), + } as unknown as Parameters[0]["agentChatService"], + }); + let peer: Awaited> | null = null; + try { + const port = await host.waitUntilListening(); + peer = await connectPeer(port, host.getBootstrapToken(), "desktop-timeout"); + + peer.ws.send(encodeSyncEnvelope({ + type: "command", + requestId: "hung-command", + projectId: "project-1", + payload: { + commandId: "hung-command", + action: "chat.send", + projectId: "project-1", + args: { + sessionId: "session-1", + text: "hello", + }, + }, + })); + await waitForEnvelope(peer.envelopes, "command_ack", "hung-command"); + + peer.ws.send(encodeSyncEnvelope({ + type: "project_catalog_request", + requestId: "catalog-after-timeout", + payload: {}, + })); + + const catalog = await waitForEnvelope(peer.envelopes, "project_catalog", "catalog-after-timeout"); + expect((catalog.payload as SyncProjectCatalogPayload).projects.map((project) => project.id)).toEqual(["project-1"]); + await waitForValue( + () => logger.warn.mock.calls.find(([event]) => event === "sync_host.message_failed") ?? null, + "message timeout warning", + ); + expect(logger.warn).toHaveBeenCalledWith("sync_host.message_failed", expect.objectContaining({ + error: expect.stringContaining("Timed out handling sync message command"), + messageType: "command", + peerDeviceId: "desktop-timeout", + requestId: "hung-command", + })); + expect(sendMessage).toHaveBeenCalledTimes(1); + } finally { + try { + peer?.ws.close(); + } catch { + // ignore + } + await host.dispose(); + cleanup(); + } + }); + + it("rejects oversized artifact reads with a clear file response", async () => { + const { projectRoot, cleanup } = createTempProjectRoot(); + const artifactPath = path.join(projectRoot, ".ade", "artifacts", "large.bin"); + fs.mkdirSync(path.dirname(artifactPath), { recursive: true }); + fs.writeFileSync(artifactPath, Buffer.alloc(8 * 1024 * 1024 + 1)); + const host = createReliabilityHost(projectRoot); + let peer: Awaited> | null = null; + try { + const port = await host.waitUntilListening(); + peer = await connectPeer(port, host.getBootstrapToken(), "ios-artifact-cap"); + peer.ws.send(encodeSyncEnvelope({ + type: "file_request", + requestId: "artifact-large", + payload: { + action: "readArtifact", + args: { path: artifactPath }, + }, + })); + + const response = await waitForEnvelope(peer.envelopes, "file_response", "artifact-large"); + expect(response.payload).toMatchObject({ + ok: false, + action: "readArtifact", + error: { + code: "file_request_failed", + }, + }); + expect((response.payload as { error?: { message?: string } }).error?.message).toMatch(/too large to sync/i); + } finally { + try { + peer?.ws.close(); + } catch { + // ignore + } + await host.dispose(); + cleanup(); + } + }); }); describe("chat_subscribe snapshots", () => { diff --git a/apps/ade-cli/src/services/sync/syncHostService.ts b/apps/ade-cli/src/services/sync/syncHostService.ts index 2c363182b..ebae9434d 100644 --- a/apps/ade-cli/src/services/sync/syncHostService.ts +++ b/apps/ade-cli/src/services/sync/syncHostService.ts @@ -6,7 +6,7 @@ import { fileURLToPath } from "node:url"; import { promisify } from "node:util"; import { createHash, randomBytes, timingSafeEqual } from "node:crypto"; import { Bonjour, type Service as BonjourService } from "bonjour-service"; -import { WebSocketServer, WebSocket, type RawData } from "ws"; +import { WebSocketServer, WebSocket } from "ws"; import { resolveAdeLayout } from "../../../../desktop/src/shared/adeLayout"; import type { AgentChatEventEnvelope, @@ -105,7 +105,7 @@ import type { DeviceRegistryService } from "./deviceRegistryService"; import { createSyncPairingStore, type SyncPairingRecord } from "./syncPairingStore"; import type { SyncPinStore } from "./syncPinStore"; import type { SyncRuntimeNameStore } from "./syncRuntimeNameStore"; -import { DEFAULT_SYNC_COMPRESSION_THRESHOLD_BYTES, DEFAULT_SYNC_HOST_PORT, DEFAULT_SYNC_MAX_FRAME_BYTES, encodeSyncEnvelope, encodeSyncEnvelopeFrames, mapPlatform, parseSyncEnvelope, SYNC_CHUNKED_ENVELOPES_CAPABILITY, wsDataToText } from "./syncProtocol"; +import { DEFAULT_SYNC_COMPRESSION_THRESHOLD_BYTES, DEFAULT_SYNC_HOST_PORT, DEFAULT_SYNC_MAX_FRAME_BYTES, encodeSyncEnvelope, encodeSyncEnvelopeFrames, mapPlatform, parseSyncEnvelope, SYNC_CHUNKED_ENVELOPES_CAPABILITY, wsDataToText, type ParsedSyncEnvelope } from "./syncProtocol"; import { resolveTailscaleCliPath } from "./resolveTailscaleCliPath"; import { createSyncRemoteCommandService, type SyncRemoteCommandService } from "./syncRemoteCommandService"; import { @@ -179,6 +179,10 @@ const DEFAULT_TERMINAL_HISTORY_PAGE_BYTES = 262_144; const MIN_TERMINAL_HISTORY_PAGE_BYTES = 4_096; const MAX_TERMINAL_HISTORY_PAGE_BYTES = 524_288; const PEER_BACKPRESSURE_BYTES = 4 * 1024 * 1024; +const REQUIRED_SEND_MAX_BUFFERED_BYTES = 16 * 1024 * 1024; +const SEND_AND_WAIT_TIMEOUT_MS = 15_000; +const DEFAULT_SYNC_MESSAGE_TIMEOUT_MS = 60_000; +const MAX_SYNC_ARTIFACT_BYTES = 8 * 1024 * 1024; export const SYNC_HOST_CHAT_ACTIVE_BACKGROUND_BACKPRESSURE_BYTES = 512 * 1024; export const SYNC_HOST_CHAT_ACTIVE_CHANGESET_BATCH_BYTES = 64 * 1024; const MOBILE_COMMAND_RESULT_CACHE_TTL_MS = 30 * 60 * 1000; @@ -207,6 +211,7 @@ const MAX_CHANGESET_ACK_RETRIES = 6; const LANE_PRESENCE_TTL_MS = 60_000; const SYNC_MDNS_SERVICE_TYPE = "ade-sync"; const MAX_PROJECT_CATALOG_ENVELOPE_BYTES = 768 * 1024; +const MAX_PROJECT_CATALOG_CHUNK_BYTES = 192 * 1024; const BONJOUR_PROJECT_TXT_ENTRY_LIMIT = 24; const BONJOUR_PROJECT_NAME_MAX_LENGTH = 48; export const SYNC_TAILNET_DISCOVERY_SERVICE_NAME = "svc:ade-sync"; @@ -217,6 +222,91 @@ export type NativeLanDiscoveryProcess = { ppid: number; command: string; }; + +export type SyncProjectCatalogMessage = + | { + type: "project_catalog"; + payload: SyncProjectCatalogPayload; + requestId?: string | null; + } + | { + type: "project_catalog_chunk"; + payload: SyncProjectCatalogChunkPayload; + requestId?: string | null; + }; + +export function splitSyncProjectCatalog( + projects: SyncMobileProjectSummary[], + maxChunkBytes = MAX_PROJECT_CATALOG_CHUNK_BYTES, +): SyncMobileProjectSummary[][] { + const chunks: SyncMobileProjectSummary[][] = []; + let chunk: SyncMobileProjectSummary[] = []; + let chunkBytes = 0; + + const flush = (): void => { + if (chunk.length === 0) return; + chunks.push(chunk); + chunk = []; + chunkBytes = 0; + }; + + for (const project of projects) { + const projectBytes = Buffer.byteLength(JSON.stringify(project), "utf8"); + if (chunk.length > 0 && chunkBytes + projectBytes > maxChunkBytes) { + flush(); + } + chunk.push(project); + chunkBytes += projectBytes; + } + flush(); + return chunks; +} + +export function buildSyncProjectCatalogMessages(args: { + projectCatalog: SyncProjectCatalogPayload; + requestId?: string | null; + compressionThresholdBytes?: number; + maxProjectCatalogEnvelopeBytes?: number; +}): SyncProjectCatalogMessage[] { + const requestId = args.requestId ?? null; + const envelopeBytes = Buffer.byteLength(encodeSyncEnvelope({ + type: "project_catalog", + payload: args.projectCatalog, + requestId, + compressionThresholdBytes: args.compressionThresholdBytes, + }), "utf8"); + if (envelopeBytes <= (args.maxProjectCatalogEnvelopeBytes ?? MAX_PROJECT_CATALOG_ENVELOPE_BYTES)) { + return [{ type: "project_catalog", payload: args.projectCatalog, requestId }]; + } + + const chunks = splitSyncProjectCatalog(args.projectCatalog.projects); + const total = Math.max(1, chunks.length); + const catalogId = randomBytes(8).toString("hex"); + if (chunks.length === 0) { + return [{ + type: "project_catalog_chunk", + payload: { + catalogId, + index: 0, + total, + done: true, + projects: [], + }, + requestId, + }]; + } + return chunks.map((projects, index) => ({ + type: "project_catalog_chunk", + payload: { + catalogId, + index, + total, + done: index === total - 1, + projects, + }, + requestId, + })); +} export function syncFileRequestWorkspaceId(payload: SyncFileRequest): string | null { switch (payload.action) { case "listTree": @@ -277,6 +367,7 @@ type PeerState = { latencyMs: number | null; awaitingHeartbeatAt: string | null; missedHeartbeatCount: number; + backpressuredSinceMs: number | null; remoteAddress: string | null; remotePort: number | null; subscribedSessionIds: Set; @@ -297,6 +388,7 @@ type PeerState = { rosterSubscribed: boolean; rosterSeq: number; rosterBaseline: Map; + messageQueue: Promise; }; type PendingChangesetBatch = { @@ -535,6 +627,7 @@ type SyncHostServiceArgs = { heartbeatIntervalMs?: number; pollIntervalMs?: number; authTimeoutMs?: number; + messageTimeoutMs?: number; brainStatusIntervalMs?: number; compressionThresholdBytes?: number; deviceRegistryService?: DeviceRegistryService; @@ -1147,14 +1240,15 @@ export function createSyncHostService(args: SyncHostServiceArgs) { logger: args.logger, }); const heartbeatIntervalMs = Math.max(5_000, Math.floor(args.heartbeatIntervalMs ?? DEFAULT_SYNC_HEARTBEAT_INTERVAL_MS)); + const backpressureTimeoutMs = Math.max(heartbeatIntervalMs * 3, 10_000); const pollIntervalMs = Math.max(100, Math.floor(args.pollIntervalMs ?? DEFAULT_SYNC_POLL_INTERVAL_MS)); const brainStatusIntervalMs = Math.max(1_000, Math.floor(args.brainStatusIntervalMs ?? DEFAULT_BRAIN_STATUS_INTERVAL_MS)); const authTimeoutMs = Math.max(1_000, Math.floor(args.authTimeoutMs ?? SYNC_HOST_AUTH_TIMEOUT_MS)); + const messageTimeoutMs = Math.max(100, Math.floor(args.messageTimeoutMs ?? DEFAULT_SYNC_MESSAGE_TIMEOUT_MS)); const compressionThresholdBytes = Math.max(256, Math.floor(args.compressionThresholdBytes ?? DEFAULT_SYNC_COMPRESSION_THRESHOLD_BYTES)); const maxChangesetBatchBytes = DEFAULT_MAX_CHANGESET_BATCH_BYTES; const maxChangesetBatchRows = DEFAULT_MAX_CHANGESET_BATCH_ROWS; const maxProjectCatalogEnvelopeBytes = MAX_PROJECT_CATALOG_ENVELOPE_BYTES; - const maxProjectCatalogChunkBytes = 192 * 1024; const hostProjectIdAliases = uniqueStrings( (args.projectIdAliases ?? []) .map((alias) => toOptionalString(alias)) @@ -1683,6 +1777,15 @@ export function createSyncHostService(args: SyncHostServiceArgs) { for (const peer of peers) { if (!peer.authenticated || peer.ws.readyState !== WebSocket.OPEN) continue; if (isPeerBackpressured(peer)) { + if (isPeerBackpressuredTooLong(peer)) { + args.logger.warn("sync_host.peer_backpressure_timeout", { + peerDeviceId: peer.metadata?.deviceId ?? null, + bufferedAmount: peer.ws.bufferedAmount, + backpressuredMs: peer.backpressuredSinceMs == null ? null : Date.now() - peer.backpressuredSinceMs, + }); + closeBackpressuredPeer(peer, "Backpressure timed out"); + continue; + } args.logger.debug("sync_host.heartbeat_deferred_backpressure", { peerDeviceId: peer.metadata?.deviceId ?? null, bufferedAmount: peer.ws.bufferedAmount, @@ -1757,6 +1860,7 @@ export function createSyncHostService(args: SyncHostServiceArgs) { latencyMs: null, awaitingHeartbeatAt: null, missedHeartbeatCount: 0, + backpressuredSinceMs: null, remoteAddress, remotePort, subscribedSessionIds: new Set(), @@ -1768,6 +1872,7 @@ export function createSyncHostService(args: SyncHostServiceArgs) { rosterSubscribed: false, rosterSeq: 0, rosterBaseline: new Map(), + messageQueue: Promise.resolve(), }; peers.add(peer); peer.authTimeout = setTimeout(() => { @@ -1784,12 +1889,31 @@ export function createSyncHostService(args: SyncHostServiceArgs) { }, authTimeoutMs); peer.authTimeout.unref?.(); ws.on("message", (raw) => { - void handleMessage(peer, raw).catch((error) => { - args.logger.warn("sync_host.message_failed", { + let envelope: ParsedSyncEnvelope; + try { + envelope = parseSyncEnvelope(wsDataToText(raw)); + } catch (error) { + args.logger.warn("sync_host.message_parse_failed", { error: error instanceof Error ? error.message : String(error), peerDeviceId: peer.metadata?.deviceId ?? null, }); - }); + return; + } + if (handleImmediateControlEnvelope(peer, envelope)) return; + peer.messageQueue = peer.messageQueue + .catch(() => {}) + .then(() => handleMessageWithTimeout(peer, envelope)) + .catch((error) => { + args.logger.warn("sync_host.message_failed", { + error: error instanceof Error ? error.message : String(error), + peerDeviceId: peer.metadata?.deviceId ?? null, + peerDeviceName: peer.metadata?.deviceName ?? null, + remoteAddress: peer.remoteAddress ?? null, + remotePort: peer.remotePort ?? null, + messageType: envelope.type, + requestId: envelope.requestId ?? null, + }); + }); }); ws.on("close", (code, reason) => { clearPeerAuthTimeout(peer); @@ -2459,8 +2583,25 @@ export function createSyncHostService(args: SyncHostServiceArgs) { function sendRequired(peer: PeerState, type: SyncEnvelope["type"], payload: TPayload, requestId?: string | null): boolean { const ws = peer.ws; if (ws.readyState !== WebSocket.OPEN) return false; + const frames = encodeFramesFor(peer, type, payload, requestId); + const frameBytes = frames.reduce((sum, frame) => sum + Buffer.byteLength(frame, "utf8"), 0); + const backpressured = isPeerBackpressured(peer); + if ( + ws.bufferedAmount + frameBytes > REQUIRED_SEND_MAX_BUFFERED_BYTES || + (backpressured && isPeerBackpressuredTooLong(peer)) + ) { + args.logger.warn("sync_host.required_send_backpressured", { + type, + requestId: requestId ?? null, + peerDeviceId: peer.metadata?.deviceId ?? peer.pairedDeviceId ?? null, + bufferedAmount: ws.bufferedAmount, + frameBytes, + }); + closeBackpressuredPeer(peer, "Required sync response backpressured"); + return false; + } let reported = false; - for (const frame of encodeFramesFor(peer, type, payload, requestId)) { + for (const frame of frames) { ws.send(frame, (error) => { if (!error || reported) return; reported = true; @@ -2476,7 +2617,27 @@ export function createSyncHostService(args: SyncHostServiceArgs) { } function isPeerBackpressured(peer: PeerState): boolean { - return peer.ws.bufferedAmount >= PEER_BACKPRESSURE_BYTES; + const backpressured = peer.ws.bufferedAmount >= PEER_BACKPRESSURE_BYTES; + if (!backpressured) { + peer.backpressuredSinceMs = null; + return false; + } + peer.backpressuredSinceMs ??= Date.now(); + return true; + } + + function closeBackpressuredPeer(peer: PeerState, reason: string): void { + try { + peer.ws.close(4001, reason); + } catch { + // ignore close failures + } + } + + function isPeerBackpressuredTooLong(peer: PeerState): boolean { + if (!isPeerBackpressured(peer)) return false; + return peer.backpressuredSinceMs != null + && Date.now() - peer.backpressuredSinceMs >= backpressureTimeoutMs; } function shouldDeferBackgroundChangesForChat(peer: PeerState): boolean { @@ -2496,38 +2657,45 @@ export function createSyncHostService(args: SyncHostServiceArgs) { return Promise.reject(new Error("Cannot send on closed WebSocket.")); } const frames = encodeFramesFor(ws, type, payload, requestId); + const frameBytes = frames.reduce((sum, frame) => sum + Buffer.byteLength(frame, "utf8"), 0); + if (ws.bufferedAmount + frameBytes > REQUIRED_SEND_MAX_BUFFERED_BYTES) { + return Promise.reject(new Error("WebSocket send buffer is over the required-send budget.")); + } return new Promise((resolve, reject) => { let failed = false; let remaining = frames.length; + const timer = setTimeout(() => { + if (failed) return; + failed = true; + reject(new Error(`Timed out sending ${type} after ${SEND_AND_WAIT_TIMEOUT_MS}ms.`)); + }, SEND_AND_WAIT_TIMEOUT_MS); for (const frame of frames) { ws.send(frame, (error) => { if (failed) return; if (error) { failed = true; + clearTimeout(timer); reject(error); return; } remaining -= 1; - if (remaining === 0) resolve(); + if (remaining === 0) { + clearTimeout(timer); + resolve(); + } }); } }); } - function encodedEnvelopeBytes( - type: SyncEnvelope["type"], - payload: TPayload, - requestId?: string | null, - ): number { - return Buffer.byteLength(encodeSyncEnvelope({ type, payload, requestId, compressionThresholdBytes }), "utf8"); - } - function closeExistingPeersForDevice(deviceId: string, currentPeer: PeerState): void { const normalized = toOptionalString(deviceId); if (!normalized) return; for (const peer of peers) { if (peer === currentPeer) continue; if (peer.metadata?.deviceId !== normalized && peer.pairedDeviceId !== normalized) continue; + const presenceDeviceId = peer.metadata?.deviceId ?? peer.pairedDeviceId ?? normalized; + const presenceRemoved = removeAllPresenceForDevice(presenceDeviceId, "remote"); peer.authenticated = false; peer.metadata = null; peer.authKind = null; @@ -2538,6 +2706,9 @@ export function createSyncHostService(args: SyncHostServiceArgs) { } catch { // ignore close failures } + if (presenceRemoved) { + broadcastBrainStatus(); + } } } @@ -2604,64 +2775,20 @@ export function createSyncHostService(args: SyncHostServiceArgs) { } } - function splitProjectCatalog(projects: SyncMobileProjectSummary[]): SyncMobileProjectSummary[][] { - const chunks: SyncMobileProjectSummary[][] = []; - let chunk: SyncMobileProjectSummary[] = []; - let chunkBytes = 0; - - const flush = (): void => { - if (chunk.length === 0) return; - chunks.push(chunk); - chunk = []; - chunkBytes = 0; - }; - - for (const project of projects) { - const projectBytes = Buffer.byteLength(JSON.stringify(project), "utf8"); - if (chunk.length > 0 && chunkBytes + projectBytes > maxProjectCatalogChunkBytes) { - flush(); - } - chunk.push(project); - chunkBytes += projectBytes; - } - flush(); - return chunks; - } - function sendProjectCatalog( peer: PeerState, projectCatalog: SyncProjectCatalogPayload, requestId?: string | null, - ): void { - if (encodedEnvelopeBytes("project_catalog", projectCatalog, requestId) <= maxProjectCatalogEnvelopeBytes) { - send(peer.ws, "project_catalog", projectCatalog, requestId); - return; - } - - const chunks = splitProjectCatalog(projectCatalog.projects); - const total = Math.max(1, chunks.length); - const catalogId = randomBytes(8).toString("hex"); - if (chunks.length === 0) { - send(peer.ws, "project_catalog_chunk", { - catalogId, - index: 0, - total, - done: true, - projects: [], - } satisfies SyncProjectCatalogChunkPayload, requestId); - return; - } - - chunks.forEach((projects, index) => { - send(peer.ws, "project_catalog_chunk", { - catalogId, - index, - total, - done: index === total - 1, - projects, - } satisfies SyncProjectCatalogChunkPayload, requestId); - }); - } + ): void { + for (const message of buildSyncProjectCatalogMessages({ + projectCatalog, + requestId, + compressionThresholdBytes, + maxProjectCatalogEnvelopeBytes, + })) { + if (!sendRequired(peer, message.type, message.payload, message.requestId)) break; + } + } async function handleProjectSwitchRequest( peer: PeerState, @@ -3470,10 +3597,29 @@ export function createSyncHostService(args: SyncHostServiceArgs) { } catch { throw new Error("Artifact path must resolve within .ade/artifacts."); } - if (!fs.existsSync(resolvedArtifactPath) || !fs.statSync(resolvedArtifactPath).isFile()) { + return resolvedArtifactPath; + } + + async function readArtifactBlob( + request: Extract["args"], + ): Promise { + const artifactPath = resolveArtifactPath(request); + let stat: fs.Stats; + try { + stat = await fs.promises.stat(artifactPath); + } catch { + throw new Error("Artifact file does not exist."); + } + if (!stat.isFile()) { throw new Error("Artifact file does not exist."); } - return resolvedArtifactPath; + if (stat.size > MAX_SYNC_ARTIFACT_BYTES) { + throw new Error( + `Artifact is too large to sync (${stat.size} bytes; max ${MAX_SYNC_ARTIFACT_BYTES} bytes).`, + ); + } + const buffer = await fs.promises.readFile(artifactPath); + return createBlobFromBuffer(normalizeRelative(path.relative(args.projectRoot, artifactPath)), buffer); } function isMobilePeer(peer: PeerState): boolean { @@ -3570,8 +3716,7 @@ export function createSyncHostService(args: SyncHostServiceArgs) { result = await args.fileService.searchText(payload.args); break; case "readArtifact": { - const artifactPath = resolveArtifactPath(payload.args); - result = createBlobFromBuffer(normalizeRelative(path.relative(args.projectRoot, artifactPath)), fs.readFileSync(artifactPath)); + result = await readArtifactBlob(payload.args); break; } default: @@ -3866,13 +4011,56 @@ export function createSyncHostService(args: SyncHostServiceArgs) { } } - async function handleMessage(peer: PeerState, raw: RawData): Promise { - const rawText = wsDataToText(raw); - const envelope = parseSyncEnvelope(rawText); + function markPeerMessageSeen(peer: PeerState): string | null { const heartbeatAwaitedAt = peer.awaitingHeartbeatAt; peer.lastSeenAt = nowIso(); peer.awaitingHeartbeatAt = null; peer.missedHeartbeatCount = 0; + return heartbeatAwaitedAt; + } + + function handleHeartbeatEnvelope( + peer: PeerState, + envelope: ParsedSyncEnvelope, + heartbeatAwaitedAt: string | null, + ): void { + const payload = envelope.payload as { kind?: string; sentAt?: string } | null; + if (payload?.kind === "ping") { + send(peer.ws, "heartbeat", { + kind: "pong", + sentAt: payload.sentAt ?? nowIso(), + dbVersion: args.db.sync.getDbVersion(), + }, envelope.requestId); + } else if (payload?.kind === "pong" && heartbeatAwaitedAt) { + const now = Date.now(); + const sentAtMs = Date.parse(heartbeatAwaitedAt); + peer.latencyMs = Number.isFinite(sentAtMs) ? Math.max(0, now - sentAtMs) : null; + peer.awaitingHeartbeatAt = null; + } + } + + function handleImmediateControlEnvelope(peer: PeerState, envelope: ParsedSyncEnvelope): boolean { + if (!peer.authenticated || envelope.type !== "heartbeat") return false; + const heartbeatAwaitedAt = markPeerMessageSeen(peer); + handleHeartbeatEnvelope(peer, envelope, heartbeatAwaitedAt); + return true; + } + + function handleMessageWithTimeout(peer: PeerState, envelope: ParsedSyncEnvelope): Promise { + let timer: ReturnType | null = null; + const timeout = new Promise((_resolve, reject) => { + timer = setTimeout(() => { + reject(new Error(`Timed out handling sync message ${envelope.type} after ${messageTimeoutMs}ms.`)); + }, messageTimeoutMs); + timer.unref?.(); + }); + return Promise.race([handleMessage(peer, envelope), timeout]).finally(() => { + if (timer) clearTimeout(timer); + }); + } + + async function handleMessage(peer: PeerState, envelope: ParsedSyncEnvelope): Promise { + const heartbeatAwaitedAt = markPeerMessageSeen(peer); if (!peer.authenticated) { if (envelope.type !== "hello" && envelope.type !== "pairing_request") { @@ -4147,19 +4335,7 @@ export function createSyncHostService(args: SyncHostServiceArgs) { break; } case "heartbeat": { - const payload = envelope.payload as { kind?: string; sentAt?: string } | null; - if (payload?.kind === "ping") { - send(peer.ws, "heartbeat", { - kind: "pong", - sentAt: payload.sentAt ?? nowIso(), - dbVersion: args.db.sync.getDbVersion(), - }, envelope.requestId); - } else if (payload?.kind === "pong" && heartbeatAwaitedAt) { - const now = Date.now(); - const sentAtMs = Date.parse(heartbeatAwaitedAt); - peer.latencyMs = Number.isFinite(sentAtMs) ? Math.max(0, now - sentAtMs) : null; - peer.awaitingHeartbeatAt = null; - } + handleHeartbeatEnvelope(peer, envelope, heartbeatAwaitedAt); break; } case "changeset_batch": { @@ -4690,6 +4866,8 @@ export function createSyncHostService(args: SyncHostServiceArgs) { for (const peer of peers) { if (!peer.authenticated || peer.authKind !== "paired" || peer.pairedDeviceId !== deviceId) continue; revokedConnectedPeer = true; + const presenceDeviceId = peer.metadata?.deviceId ?? peer.pairedDeviceId ?? deviceId; + const presenceRemoved = removeAllPresenceForDevice(presenceDeviceId, "remote"); peer.authenticated = false; peer.metadata = null; peer.authKind = null; @@ -4700,6 +4878,7 @@ export function createSyncHostService(args: SyncHostServiceArgs) { } catch { // ignore close failures } + if (presenceRemoved) revokedConnectedPeer = true; } if (revokedConnectedPeer) { args.onStateChanged?.(); diff --git a/apps/ade-cli/src/tuiClient/__tests__/ApprovalPrompt.test.tsx b/apps/ade-cli/src/tuiClient/__tests__/ApprovalPrompt.test.tsx index 8985c849d..ddf01f66c 100644 --- a/apps/ade-cli/src/tuiClient/__tests__/ApprovalPrompt.test.tsx +++ b/apps/ade-cli/src/tuiClient/__tests__/ApprovalPrompt.test.tsx @@ -63,6 +63,8 @@ describe("ApprovalPrompt", () => { expect(frame).toContain("Banner text"); expect(frame).toContain("Compact"); expect(frame).toContain("Detailed"); + expect(frame).toContain("1-9"); + expect(frame).toContain("pick"); expect(frame).toContain("enter"); expect(frame).toContain("next/send"); }); diff --git a/apps/ade-cli/src/tuiClient/__tests__/ChatView.test.tsx b/apps/ade-cli/src/tuiClient/__tests__/ChatView.test.tsx index 4901940d4..c1d69d6e6 100644 --- a/apps/ade-cli/src/tuiClient/__tests__/ChatView.test.tsx +++ b/apps/ade-cli/src/tuiClient/__tests__/ChatView.test.tsx @@ -3,10 +3,14 @@ import { describe, expect, it } from "vitest"; import { render } from "ink-testing-library"; import { ChatView, + chatScrollMaxOffsetFromSelectableRows, computeChatScrollMaxOffset, + renderChatSelectableRows, renderChatSelectableRowTexts, + renderChatSelectableRowTextsFromRows, renderChatTranscriptPlainText, renderChatVisibleSelectionRows, + renderChatVisibleSelectionRowsFromRows, selectedTextFromChatRows, workGroupExpandKey, } from "../components/ChatView"; @@ -100,6 +104,18 @@ describe("ChatView", () => { )).toBe(" const value = 1; \n return value; "); }); + it("selects CJK and emoji by terminal display cells", () => { + expect(selectedTextFromChatRows( + ["a界b"], + { startRow: 0, startColumn: 1, endRow: 0, endColumn: 2 }, + )).toBe("界"); + + expect(selectedTextFromChatRows( + ["a🙂b"], + { startRow: 0, startColumn: 1, endRow: 0, endColumn: 2 }, + )).toBe("🙂"); + }); + it("copies selected absolute transcript rows outside the visible viewport", () => { const events = Array.from({ length: 12 }, (_, index): AgentChatEventEnvelope => ({ sessionId: "s1", @@ -120,6 +136,64 @@ describe("ChatView", () => { .toContain("selectable row 12"); }); + it("derives scroll and selection data from one selectable row pass", () => { + const events = Array.from({ length: 8 }, (_, index): AgentChatEventEnvelope => ({ + sessionId: "s1", + timestamp: `2026-01-01T12:00:${String(index).padStart(2, "0")}.000Z`, + sequence: index + 1, + event: { + type: index % 2 === 0 ? "user_message" : "text", + text: `single pass row ${index + 1}`, + }, + })); + const blocks = aggregateChatBlocks({ events, notices: [], activeSession: session }); + const selectableRows = renderChatSelectableRows({ + blocks, + width: 80, + streaming: true, + showWorkingIndicator: true, + }); + + expect(chatScrollMaxOffsetFromSelectableRows({ rows: selectableRows, maxRows: 5 })).toBe( + computeChatScrollMaxOffset({ + blocks, + events, + notices: [], + activeSession: session, + maxRows: 5, + width: 80, + streaming: true, + showWorkingIndicator: true, + }), + ); + expect(renderChatVisibleSelectionRowsFromRows({ + rows: selectableRows, + maxRows: 5, + scrollOffsetRows: 1, + unseenMessageCount: 2, + })).toEqual(renderChatVisibleSelectionRows({ + blocks, + events, + notices: [], + activeSession: session, + maxRows: 5, + scrollOffsetRows: 1, + unseenMessageCount: 2, + width: 80, + streaming: true, + showWorkingIndicator: true, + })); + expect(renderChatSelectableRowTextsFromRows(selectableRows)).toEqual(renderChatSelectableRowTexts({ + blocks, + events, + notices: [], + activeSession: session, + width: 80, + streaming: true, + showWorkingIndicator: true, + })); + }); + it("renders a bordered hero card with the ADE wordmark when the chat is empty", () => { const frame = renderEvents([]); // Hero card uses a bordered box diff --git a/apps/ade-cli/src/tuiClient/__tests__/Drawer.test.tsx b/apps/ade-cli/src/tuiClient/__tests__/Drawer.test.tsx index 347c842cf..9568d263d 100644 --- a/apps/ade-cli/src/tuiClient/__tests__/Drawer.test.tsx +++ b/apps/ade-cli/src/tuiClient/__tests__/Drawer.test.tsx @@ -3,7 +3,15 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { render } from "ink-testing-library"; import type { AgentChatSessionSummary } from "../../../../desktop/src/shared/types/chat"; import type { LaneSummary } from "../../../../desktop/src/shared/types/lanes"; +import type { ChatTerminalSession } from "../../../../desktop/src/shared/types/sessions"; import { Drawer } from "../components/Drawer"; +import { + closedCliRightPaneRow, + closedCliSessionStatusKind, + deriveClosedCliSessions, + deriveOpenDrawerSessions, + type ClosedCliSessionSummary, +} from "../closedCliSessions"; function stripAnsi(text: string): string { return text.replace(/\u001b\[[0-?]*[ -/]*[@-~]/g, ""); @@ -37,6 +45,31 @@ function lane(id: string, name: string, branchRef: string, createdAt: string, ah }; } +function terminal(overrides: Partial = {}): ChatTerminalSession { + return { + terminalId: "terminal-1", + ptyId: null, + chatSessionId: null, + laneId: "lane-1", + laneName: "Lane 1", + title: "Codex CLI", + toolType: "codex", + goal: null, + status: "completed", + runtimeState: "exited", + active: false, + startedAt: "2026-01-01T00:00:00.000Z", + endedAt: "2026-01-01T00:01:00.000Z", + exitCode: 0, + pid: null, + resumeCommand: "codex resume terminal-1", + resumeMetadata: { provider: "codex", targetKind: "session", targetId: "terminal-1", launch: {} }, + lastOutputPreview: null, + summary: null, + ...overrides, + }; +} + afterEach(() => { vi.useRealTimers(); }); @@ -83,6 +116,43 @@ describe("Drawer diff stats", () => { }); }); +describe("Drawer closed CLI sessions", () => { + it.each([ + ["failed status", { status: "failed", exitCode: 1, runtimeState: "killed" }, "failed"], + ["non-zero exit", { status: "completed", exitCode: 2, runtimeState: "exited" }, "failed"], + ["user close", { status: "disposed", exitCode: 130, runtimeState: "killed" }, "idle"], + ["clean close", { status: "completed", exitCode: 0, runtimeState: "exited" }, "idle"], + ] as const)("classifies %s for closed-session glyphs", (_label, overrides, expected) => { + const [session] = deriveClosedCliSessions([terminal({ + terminalId: `t-${_label.replace(/\s+/g, "-")}`, + ...overrides, + })]); + + expect(session).toBeTruthy(); + expect(closedCliSessionStatusKind(session!)).toBe(expected); + expect(closedCliRightPaneRow(session!, null)).toContain(expected === "failed" ? "✗" : "○"); + }); + + it("filters closed CLI sessions out of the open drawer list", () => { + const [closed] = deriveClosedCliSessions([terminal()]); + const open: AgentChatSessionSummary = { + sessionId: "chat-1", + laneId: "lane-1", + provider: "codex", + model: "gpt-5.5", + status: "idle", + startedAt: "2026-01-01T00:00:00.000Z", + endedAt: null, + lastActivityAt: "2026-01-01T00:01:00.000Z", + lastOutputPreview: null, + summary: null, + }; + + expect(closed).toBeTruthy(); + expect(deriveOpenDrawerSessions([open, closed!], [closed!])).toEqual([open]); + }); +}); + describe("Drawer lane and chat navigation layout", () => { it("puts the primary lane first and removes old header/footer controls", () => { const frame = stripAnsi(render( @@ -164,6 +234,64 @@ describe("Drawer lane and chat navigation layout", () => { expect(chatModeFrame).not.toContain("next lane"); }); + it("renders ended tracked CLI sessions behind the closed group in chat mode", () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-05-12T12:00:00.000Z")); + + const openSession: AgentChatSessionSummary = { + sessionId: "chat-open", + laneId: "lane-1", + provider: "codex", + model: "gpt-5.5", + title: "Open chat", + status: "idle", + startedAt: "2026-05-12T11:30:00.000Z", + endedAt: null, + lastActivityAt: "2026-05-12T11:31:00.000Z", + lastOutputPreview: null, + summary: null, + }; + const closedSession: ClosedCliSessionSummary = { + sessionId: "term-1", + laneId: "lane-1", + provider: "codex", + model: "gpt-5.5", + title: "CLI", + status: "idle", + startedAt: "2026-05-12T10:00:00.000Z", + endedAt: "2026-05-12T11:45:00.000Z", + lastActivityAt: "2026-05-12T11:45:00.000Z", + lastOutputPreview: null, + summary: null, + terminalStatus: "failed", + terminalExitCode: 1, + terminalRuntimeState: "killed", + }; + + const frame = stripAnsi(render( + , + ).lastFrame() ?? ""); + + expect(frame).toContain("▾ closed (1)"); + expect(frame).toContain("CLI"); + expect(frame).toContain("· 15m ago"); + expect(frame).toContain("✗"); + expect(frame).toContain("◎"); + }); + it("previews chats under non-selected lanes and hides branch refs from lane cards", () => { const sessions: AgentChatSessionSummary[] = [ { diff --git a/apps/ade-cli/src/tuiClient/__tests__/RightPane.test.tsx b/apps/ade-cli/src/tuiClient/__tests__/RightPane.test.tsx index ae3a5b3a0..e1cf0f4e7 100644 --- a/apps/ade-cli/src/tuiClient/__tests__/RightPane.test.tsx +++ b/apps/ade-cli/src/tuiClient/__tests__/RightPane.test.tsx @@ -511,6 +511,7 @@ describe("RightPane lane-details", () => { ...baseLaneDetails, }} focused + width={80} />, ); const frame = stripAnsi(result.lastFrame() ?? ""); @@ -540,6 +541,7 @@ describe("RightPane lane-details", () => { }, }} focused + width={80} />, ); const frame = stripAnsi(result.lastFrame() ?? ""); @@ -581,6 +583,30 @@ describe("RightPane lane-details", () => { expect(frame).toContain("● clean"); }); + it("surfaces retryable lane setup failure in lane details", () => { + const result = render( + , + ); + const frame = stripAnsi(result.lastFrame() ?? ""); + + expect(frame).toContain("SETUP"); + expect(frame).toContain("Setup failed"); + expect(frame).toContain("press r to retry"); + }); + it("shows action shortcuts only for the selected row", () => { const result = render( { expect(frame).toContain("reasoning"); expect(frame).not.toContain("think high"); }); + + it("renders the selected model-picker setting detail in the footer", () => { + const result = render( + , + ); + const frame = stripAnsi(result.lastFrame() ?? ""); + + expect(frame).toContain("interface"); + expect(frame).toContain("Chat · CLI"); + }); }); describe("RightPane details", () => { diff --git a/apps/ade-cli/src/tuiClient/__tests__/TerminalPane.test.tsx b/apps/ade-cli/src/tuiClient/__tests__/TerminalPane.test.tsx index 8d5b0ce48..e4b107891 100644 --- a/apps/ade-cli/src/tuiClient/__tests__/TerminalPane.test.tsx +++ b/apps/ade-cli/src/tuiClient/__tests__/TerminalPane.test.tsx @@ -8,6 +8,10 @@ function stripAnsi(value: string): string { return value.replace(/\u001b\[[0-?]*[ -/]*[@-~]/g, ""); } +function countMatches(value: string, pattern: RegExp): number { + return value.match(pattern)?.length ?? 0; +} + /** Poll until `check()` is truthy (xterm write callbacks are async). */ async function waitFor(check: () => boolean, timeoutMs = 1_000): Promise { const deadline = Date.now() + timeoutMs; @@ -145,8 +149,8 @@ describe("TerminalPane", () => { const frame = stripAnsi(result.lastFrame() ?? ""); expect(frame).toContain("CLAUDE CONTROL"); - expect(frame).toContain("Ctrl+T returns to ADE"); - expect(frame).toContain("Ctrl+] escape"); + expect(frame).toContain("^t returns to ADE"); + expect(frame).toContain("^] escape"); expect(frame).toContain("permission prompt"); }); @@ -179,6 +183,23 @@ describe("TerminalPane", () => { expect(stripAnsi(result.lastFrame() ?? "")).toContain("fresh echo"); }); + it("keeps wide glyphs when extracting styled rows from a live terminal", async () => { + const result = render( + , + ); + + await waitFor(() => countMatches(stripAnsi(result.lastFrame() ?? ""), /界/g) >= 3); + expect(countMatches(stripAnsi(result.lastFrame() ?? ""), /界/g)).toBeGreaterThanOrEqual(3); + }); + it("applies live PTY chunks on top of the newest snapshot seed", async () => { const result = render( { { domain: "lane", action: "initEnv", args: { laneId: "lane-1" } }, ]); }); + + it("applies an explicit setup template instead of the default", async () => { + const calls: Array<{ domain: string; action: string; args: Record | undefined }> = []; + const connection = { + action: async (domain: string, action: string, args?: Record) => { + calls.push({ domain, action, args }); + if (action === "listTemplates") return [{ id: "tpl-default", name: "Default" }, { id: "tpl-custom", name: "Custom" }]; + if (action === "getDefaultTemplate") return "tpl-default"; + if (action === "applyTemplate") { + return { laneId: "lane-1", steps: [], startedAt: "2026-01-01T00:00:00.000Z", overallStatus: "completed" }; + } + throw new Error(`unexpected action ${action}`); + }, + } as unknown as AdeCodeConnection; + + const result = await runDefaultLaneSetup(connection, "lane-1", { templateId: "tpl-custom" }); + + expect(result.templateId).toBe("tpl-custom"); + expect(calls).toEqual([ + { domain: "lane", action: "listTemplates", args: undefined }, + { domain: "lane", action: "getDefaultTemplate", args: undefined }, + { domain: "lane", action: "applyTemplate", args: { laneId: "lane-1", templateId: "tpl-custom" } }, + ]); + }); + + it("rejects an explicit setup template that does not exist", async () => { + const connection = { + action: async (_domain: string, action: string) => { + if (action === "listTemplates") return [{ id: "tpl-default", name: "Default" }]; + if (action === "getDefaultTemplate") return "tpl-default"; + throw new Error(`unexpected action ${action}`); + }, + } as unknown as AdeCodeConnection; + + await expect(runDefaultLaneSetup(connection, "lane-1", { templateId: "missing" })) + .rejects.toThrow('Setup template "missing" was not found.'); + }); }); describe("getChatHistoryPage", () => { @@ -395,7 +433,7 @@ describe("discoverProjectSlashCommands", () => { }); describe("getAvailableModels", () => { - it("activates dynamic Cursor model discovery for TUI model lists", async () => { + it("sources Cursor model discovery from the active TUI interface", async () => { const calls: Array<{ domain: string; action: string; args?: Record }> = []; const connection = { action: vi.fn(async (domain: string, action: string, args?: Record) => { @@ -405,15 +443,19 @@ describe("getAvailableModels", () => { } as any; await getAvailableModels(connection, "cursor"); + await getAvailableModels(connection, "cursor", { interfaceMode: "cli" }); expect(calls).toEqual([ { domain: "chat", action: "getAvailableModels", - // TUI chats run cursor through the SDK, so only that source is probed - // synchronously; the CLI flavor revalidates in the background on the host. args: { provider: "cursor", activateRuntime: true, cursorSource: "sdk" }, }, + { + domain: "chat", + action: "getAvailableModels", + args: { provider: "cursor", activateRuntime: true, cursorSource: "cli" }, + }, ]); }); }); @@ -530,7 +572,7 @@ describe("createChatSession", () => { }); }); -describe("startClaudeTerminalSession", () => { +describe("startCliTerminalSession", () => { it("passes Claude model reasoning and permission controls to start_cli_session", async () => { const calls: Array<{ name: string; args?: Record }> = []; const connection = { @@ -544,8 +586,9 @@ describe("startClaudeTerminalSession", () => { }, } as unknown as AdeCodeConnection; - await startClaudeTerminalSession({ + await startCliTerminalSession({ connection, + provider: "claude", laneId: "lane-1", title: "Claude smoke", model: "anthropic/claude-sonnet-4-6", @@ -574,6 +617,101 @@ describe("startClaudeTerminalSession", () => { }, ]); }); + + it("launches every provider CLI with its selected provider + fast mode", async () => { + for (const provider of ["codex", "cursor", "droid", "opencode", "claude"] as const) { + const calls: Array<{ name: string; args?: Record }> = []; + const connection = { + tool: async (name: string, args?: Record) => { + calls.push({ name, args }); + return { sessionId: `term-${provider}`, terminalId: `term-${provider}`, session: null }; + }, + } as unknown as AdeCodeConnection; + + await startCliTerminalSession({ + connection, + provider, + laneId: "lane-1", + model: `${provider}-model`, + reasoningEffort: "medium", + fastMode: true, + permissionMode: "default", + initialInput: "Go", + cols: 120, + rows: 36, + }); + + expect(calls).toHaveLength(1); + expect(calls[0]!.name).toBe("start_cli_session"); + expect(calls[0]!.args).toEqual(expect.objectContaining({ + laneId: "lane-1", + provider, + model: `${provider}-model`, + reasoningEffort: "medium", + fastMode: true, + permissionMode: "default", + initialInput: "Go", + tracked: true, + })); + } + }); + + it("omits fastMode from the payload when the caller does not set it", async () => { + const calls: Array<{ name: string; args?: Record }> = []; + const connection = { + tool: async (name: string, args?: Record) => { + calls.push({ name, args }); + return { sessionId: "term-1", terminalId: "term-1", session: null }; + }, + } as unknown as AdeCodeConnection; + + await startCliTerminalSession({ connection, provider: "codex", laneId: "lane-1", cols: 100, rows: 28 }); + + expect(calls[0]!.args).not.toHaveProperty("fastMode"); + }); +}); + +describe("trackedCliTerminalProvider", () => { + const session = (overrides: Partial): ChatTerminalSession => ({ + terminalId: "t", + ptyId: null, + chatSessionId: null, + laneId: "lane-1", + laneName: "lane-1", + title: "t", + goal: null, + toolType: "shell", + 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, + ...overrides, + }); + + it("resolves each tracked CLI tool type to its provider", () => { + expect(trackedCliTerminalProvider(session({ toolType: "claude" }))).toBe("claude"); + expect(trackedCliTerminalProvider(session({ toolType: "claude-orchestrated" }))).toBe("claude"); + expect(trackedCliTerminalProvider(session({ toolType: "codex" }))).toBe("codex"); + expect(trackedCliTerminalProvider(session({ toolType: "cursor-cli" }))).toBe("cursor"); + expect(trackedCliTerminalProvider(session({ toolType: "droid" }))).toBe("droid"); + expect(trackedCliTerminalProvider(session({ toolType: "opencode" }))).toBe("opencode"); + }); + + it("falls back to resume metadata / command, and rejects plain shells", () => { + expect(trackedCliTerminalProvider(session({ + toolType: "shell", + resumeMetadata: { provider: "codex", targetKind: "session", targetId: "x", launch: {} }, + }))).toBe("codex"); + expect(trackedCliTerminalProvider(session({ toolType: "shell", resumeCommand: "claude --resume s1" }))).toBe("claude"); + expect(trackedCliTerminalProvider(session({ toolType: "shell" }))).toBeNull(); + }); }); describe("resumeTerminalSession", () => { @@ -664,7 +802,7 @@ describe("signalTerminal", () => { }); describe("listTerminalSessions", () => { - it("only exposes Claude Code CLI sessions in the ADE code TUI", async () => { + it("exposes every tracked provider CLI session but hides chat-backed terminals and plain shells", async () => { const calls: Array<{ domain: string; action: string; args?: Record }> = []; const sessions = [ { terminalId: "claude-1", toolType: "claude" }, @@ -674,9 +812,14 @@ describe("listTerminalSessions", () => { { terminalId: "codex-1", toolType: "codex" }, { terminalId: "codex-orch-1", toolType: "codex-orchestrated" }, { terminalId: "legacy-codex-1", toolType: "shell", resumeMetadata: { provider: "codex" } }, + { terminalId: "cursor-cli-1", toolType: "cursor-cli" }, + { terminalId: "droid-1", toolType: "droid" }, + { terminalId: "opencode-1", toolType: "opencode" }, + // Chat-backed terminals (surface via the chat session list) + plain shells stay hidden. { terminalId: "chat-claude-1", toolType: "claude-chat" }, { terminalId: "chat-codex-1", toolType: "codex-chat" }, - { terminalId: "cursor-1", toolType: "cursor" }, + { terminalId: "chat-cursor-1", toolType: "cursor" }, + { terminalId: "chat-droid-1", toolType: "droid-chat" }, { terminalId: "shell-1", toolType: "shell" }, ]; const connection = { @@ -700,6 +843,12 @@ describe("listTerminalSessions", () => { "claude-orch-1", "legacy-claude-1", "legacy-claude-command-1", + "codex-1", + "codex-orch-1", + "legacy-codex-1", + "cursor-cli-1", + "droid-1", + "opencode-1", ]); }); }); diff --git a/apps/ade-cli/src/tuiClient/__tests__/aggregate.test.ts b/apps/ade-cli/src/tuiClient/__tests__/aggregate.test.ts index 1019cd35c..1c04b7e9b 100644 --- a/apps/ade-cli/src/tuiClient/__tests__/aggregate.test.ts +++ b/apps/ade-cli/src/tuiClient/__tests__/aggregate.test.ts @@ -177,6 +177,30 @@ describe("aggregateChatBlocks typed groups", () => { expect(activity!.entries[0]).toMatchObject({ label: "compacting memory", detail: "trimming context" }); }); + it("compacts PreToolUse hook errors into one failed hook activity row", () => { + const events: AgentChatEventEnvelope[] = [ + env("2026-01-01T12:00:00.000Z", { + type: "system_notice", + noticeKind: "hook", + message: "Hook: PreToolUse: Bash error", + turnId: "turn-1", + } as AgentChatEvent), + ]; + const blocks = aggregate(events); + const activity = blocks.find((b) => b.kind === "runtime-activity") as Extract | undefined; + + expect(blocks).toHaveLength(1); + expect(activity?.entries).toEqual([ + { + id: expect.any(String), + label: "hook", + detail: "PreToolUse: Bash error", + status: "failed", + }, + ]); + expect(activity?.live).toBe(false); + }); + 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" }), diff --git a/apps/ade-cli/src/tuiClient/__tests__/appInput.test.ts b/apps/ade-cli/src/tuiClient/__tests__/appInput.test.ts index 996d6966a..d82b9c32d 100644 --- a/apps/ade-cli/src/tuiClient/__tests__/appInput.test.ts +++ b/apps/ade-cli/src/tuiClient/__tests__/appInput.test.ts @@ -14,6 +14,7 @@ import { deletePreviousPromptWord, encodeTerminalPromptSubmit, encodeTerminalPromptSubmitConfirm, + latestAuthFailedPrompt, applyCoalescedPromptInput, firstUrlInText, footerControlsForAvailability, @@ -27,7 +28,6 @@ import { isTerminalSessionFastPollActive, isLaneWorktreeAvailable, isTerminalSessionWorking, - isTerminalSessionResumable, shouldToggleLatestFailedLineOnBlankEnter, isTerminalControlToggle, isChatTextSelectionRange, @@ -38,8 +38,6 @@ import { chatSelectionFromAnchor, chatSessionToOptimisticSummary, chatSelectionPointFromVisibleRows, - codexApprovalSandboxLabel, - cursorModeIdsForState, moveChatSelectionFocusByRows, mergeOptimisticChatSessions, insertPromptText, @@ -53,7 +51,6 @@ import { modelPickerPaneContentOrigin, modelPickerProviderSwitchBlocked, mergeNewChatModelPickerContext, - normalizeCatalogProvider, planSessionStatePrune, planTerminalBufferPrune, isNewChatSetupPane, @@ -73,11 +70,14 @@ import { splitTerminalControlInput, stableInkViewportRows, subagentSnapshotsFromEvents, + terminalBracketedPasteDisableSequence, + terminalBracketedPasteEnableSequence, terminalAlternateScreenDisableSequence, terminalAlternateScrollDisableSequence, terminalInteractiveRestoreSequence, terminalMouseTrackingDisableSequence, terminalMouseTrackingEnableSequence, + terminalControlInputAction, isClaudePlaceholderTitle, isClipboardScratchTemp, mergeOptimisticTerminalSessions, @@ -85,15 +85,85 @@ import { clipboardImageCacheRootForRuntime, uploadClipboardImageAttachmentToRuntime, } from "../app"; +import { isTerminalSessionResumable } from "../closedCliSessions"; +import { + buildSetupRows, + cliProviderForModelStateProvider, + codexApprovalSandboxLabel, + cursorModeIdsForState, + cursorSourceForInterfaceMode, + initialModelState, + applyProviderPermissionMode, + permissionSummary, + reconcileCursorModelStateForInterface, + resolveCursorCliModelForLaunch, +} from "../modelState"; +import { normalizeCatalogProvider } from "../providerMetadata"; +import { + BRACKETED_PASTE_END, + BRACKETED_PASTE_START, + EMPTY_BRACKETED_PASTE_STATE, + consumeBracketedPasteInput, + formatTerminalControlForwardedInput, + stripBracketedPasteMarkers, +} from "../bracketedPaste"; import { clampTerminalPaneCols } from "../components/TerminalPane"; import { clipboardScratchDir } from "../imageTargets"; -import type { AdeCodeConnection, ChatInfoSnapshot, LocalNotice, RightPaneContent } from "../types"; +import type { AdeCodeConnection, AdeCodeModelState, ChatInfoSnapshot, LocalNotice, RightPaneContent } from "../types"; import { resolveSubagentCapability } from "../../../../desktop/src/shared/subagentCapabilities"; -import type { AgentChatSession, AgentChatSessionSummary } from "../../../../desktop/src/shared/types/chat"; +import type { AgentChatEventEnvelope, AgentChatModelInfo, AgentChatSession, AgentChatSessionSummary } from "../../../../desktop/src/shared/types/chat"; import type { LaneSummary } from "../../../../desktop/src/shared/types/lanes"; import type { ChatTerminalSession } from "../../../../desktop/src/shared/types/sessions"; import type { ChatTerminalPreviewResult } from "../../../../desktop/src/shared/types"; +function cursorModelState(overrides: Partial = {}): AdeCodeModelState { + return { + provider: "cursor", + interfaceMode: "chat", + model: "sdk-only", + modelId: "cursor/sdk-only", + displayName: "SDK only", + reasoningEffort: null, + fastMode: false, + permissionMode: "default", + interactionMode: "default", + claudePermissionMode: "default", + codexApprovalPolicy: "never", + codexSandbox: "workspace-write", + codexConfigSource: "config-toml", + opencodePermissionMode: "edit", + droidPermissionMode: "auto-low", + cursorModeId: "agent", + cursorAvailableModeIds: ["agent"], + cursorConfigValues: {}, + ...overrides, + }; +} + +function setupPaneModelState(overrides: Partial = {}): AdeCodeModelState { + return { + provider: "codex", + interfaceMode: "chat", + model: "gpt-5.5", + modelId: null, + displayName: "GPT-5.5", + reasoningEffort: "medium", + fastMode: false, + permissionMode: "default", + interactionMode: "default", + claudePermissionMode: "default", + codexApprovalPolicy: "on-request", + codexSandbox: "workspace-write", + codexConfigSource: "flags", + opencodePermissionMode: "edit", + droidPermissionMode: "auto-low", + cursorModeId: "agent", + cursorAvailableModeIds: [], + cursorConfigValues: {}, + ...overrides, + }; +} + describe("session activity helpers", () => { it("keeps Ink output below the terminal height to avoid full-screen clears", () => { expect(stableInkViewportRows(40)).toBe(39); @@ -990,6 +1060,52 @@ describe("inlineRowCellOrder", () => { }); }); +describe("interface draft setup", () => { + function interfaceRows(interfaceMode: AdeCodeModelState["interfaceMode"], interfaceEditable: boolean) { + return buildSetupRows({ + modelState: setupPaneModelState({ interfaceMode }), + models: [], + includeRefresh: false, + includeApply: true, + interfaceMode, + interfaceEditable, + }); + } + + it("maps tracked CLI providers and rejects chat-only providers", () => { + for (const provider of ["claude", "codex", "cursor", "droid", "opencode"] as const) { + expect(cliProviderForModelStateProvider(provider)).toBe(provider); + } + expect(cliProviderForModelStateProvider("ollama")).toBeNull(); + expect(cliProviderForModelStateProvider("lmstudio")).toBeNull(); + }); + + it("keeps the Interface setup row after Provider and labels the active mode", () => { + const chatRows = interfaceRows("chat", true); + const cliRows = interfaceRows("cli", true); + + expect(chatRows.map((row) => row.kind).slice(0, 2)).toEqual(["provider", "interface"]); + expect(chatRows.find((row) => row.kind === "interface")).toMatchObject({ + value: "Chat", + disabled: false, + cyclable: true, + detail: "Chat · CLI", + }); + expect(cliRows.find((row) => row.kind === "interface")?.value).toBe("CLI"); + }); + + it("locks Interface after launch and remembers the draft default", () => { + expect(interfaceRows("cli", false).find((row) => row.kind === "interface")).toMatchObject({ + value: "CLI", + disabled: true, + cyclable: false, + detail: "tracked CLI session", + }); + expect(initialModelState("cli").interfaceMode).toBe("cli"); + expect(initialModelState("chat").interfaceMode).toBe("chat"); + }); +}); + describe("provider permission helpers", () => { it("summarizes Codex approval and sandbox as a footer detail", () => { expect(codexApprovalSandboxLabel({ @@ -1002,6 +1118,31 @@ describe("provider permission helpers", () => { expect(cursorModeIdsForState({ cursorAvailableModeIds: ["ask", "plan"] })).toEqual(["ask", "plan"]); expect(cursorModeIdsForState({ cursorAvailableModeIds: [] })).toContain("agent"); }); + + it("uses OpenCode permissions for Ollama and LM Studio chat providers", () => { + for (const provider of ["ollama", "lmstudio"] as const) { + const modelState = setupPaneModelState({ + provider, + opencodePermissionMode: "plan", + cursorModeId: "ask", + }); + const permissionRow = buildSetupRows({ + modelState, + models: [], + includeRefresh: false, + includeApply: true, + interfaceMode: "chat", + interfaceEditable: true, + }).find((row) => row.kind === "permission"); + + expect(permissionSummary(modelState)).toBe("plan"); + expect(applyProviderPermissionMode(modelState)).toEqual({ permissionMode: "plan" }); + expect(permissionRow).toMatchObject({ + value: "plan", + detail: "plan · edit · full-auto · config-toml", + }); + } + }); }); describe("formatGitConflictReport", () => { @@ -1148,6 +1289,14 @@ describe("terminal control toggle", () => { expect(isTerminalControlToggle("t", {})).toBe(false); }); + it("ignores arbitrary input while attached but still recognizes detach chords", () => { + expect(terminalControlInputAction("x", {})).toBe("ignore"); + expect(terminalControlInputAction("\x1b[A", {})).toBe("ignore"); + expect(terminalControlInputAction("t", { ctrl: true })).toBe("detach"); + expect(terminalControlInputAction("\x14", {})).toBe("detach"); + expect(terminalControlInputAction("\x1d", {})).toBe("detach"); + }); + it("detaches from terminal control while preserving other raw input bytes", () => { expect(splitTerminalControlInput("a\x14b\x1dc")).toEqual({ detach: true, @@ -1158,6 +1307,20 @@ describe("terminal control toggle", () => { forwarded: "\x1b[A", }); }); + + it("wraps multiline forwarded terminal control input in bracketed paste", () => { + expect(formatTerminalControlForwardedInput("one\ntwo")).toBe( + `${BRACKETED_PASTE_START}one\ntwo${BRACKETED_PASTE_END}`, + ); + expect(splitTerminalControlInput("one\r\ntwo")).toEqual({ + detach: false, + forwarded: `${BRACKETED_PASTE_START}one\ntwo${BRACKETED_PASTE_END}`, + }); + expect(splitTerminalControlInput(`\x14one\ntwo`)).toEqual({ + detach: true, + forwarded: `${BRACKETED_PASTE_START}one\ntwo${BRACKETED_PASTE_END}`, + }); + }); }); describe("pane width helpers", () => { @@ -1423,6 +1586,10 @@ describe("prompt editing helpers", () => { expect(movePromptCursorVertical("abcdef", 3, 1, 1)).toBe(4); expect(movePromptCursorVertical("abcdef", 3, 4, -1)).toBe(1); expect(movePromptCursorVertical("abc", 3, 3, 1)).toBe(3); + expect(movePromptCursorVertical("a界bcde", 4, 2, 1)).toBe(6); + expect(movePromptCursorVertical("a界bcde", 4, 6, -1)).toBe(2); + expect(movePromptCursorVertical("a🙂bcde", 4, 3, 1)).toBe(7); + expect(movePromptCursorVertical("a🙂bcde", 4, 7, -1)).toBe(3); }); it("detects prompt visual-row edges for attachment and model-row navigation", () => { @@ -1443,12 +1610,81 @@ describe("prompt editing helpers", () => { it("does not split multi-byte characters when editing or wrapping", () => { expect(promptDisplayRowsWithCursor("a🙂b", 2, 3).rows).toEqual([ - { text: "a🙂", start: 0, end: 3, cursorColumn: null }, + { text: "a", start: 0, end: 1, cursorColumn: null }, + { text: "🙂", start: 1, end: 3, cursorColumn: null }, { text: "b", start: 3, end: 4, cursorColumn: 0 }, ]); expect(deletePromptBackward("a🙂b", 3)).toEqual({ value: "ab", cursor: 1 }); expect(deletePromptForward("a🙂b", 1)).toEqual({ value: "ab", cursor: 1 }); }); + + it("counts CJK and emoji prompt cursor columns in terminal cells", () => { + const cjk = promptDisplayRowsWithCursor("a界bc", 4, 2); + expect(cjk.rows[0]).toEqual({ text: "a界b", start: 0, end: 3, cursorColumn: 3 }); + expect(cjk.rows[1]).toEqual({ text: "c", start: 3, end: 4, cursorColumn: null }); + + const emoji = promptDisplayRowsWithCursor("a🙂bc", 4, 3); + expect(emoji.rows[0]).toEqual({ text: "a🙂b", start: 0, end: 4, cursorColumn: 3 }); + expect(emoji.rows[1]).toEqual({ text: "c", start: 4, end: 5, cursorColumn: null }); + }); + + it("sources and validates Cursor models by TUI interface", () => { + const sdkOnly: AgentChatModelInfo = { + id: "cursor/sdk-only", + modelId: "cursor/sdk-only", + displayName: "SDK only", + isDefault: true, + cursorAvailability: { sdk: true, cli: false }, + }; + const cliOnly: AgentChatModelInfo = { + id: "cursor/cli-only", + modelId: "cursor/cli-only", + displayName: "CLI only", + isDefault: false, + cursorAvailability: { sdk: false, cli: true }, + cursorCliVariants: [{ modelId: "cli-only-default" }], + }; + const cliReady: AgentChatModelInfo = { + id: "cursor/cli-ready", + modelId: "cursor/cli-ready", + displayName: "CLI ready", + isDefault: false, + reasoningEfforts: [{ effort: "high", description: "High" }], + cursorAvailability: { sdk: true, cli: true }, + cursorCliVariants: [ + { modelId: "cli-ready-default" }, + { modelId: "cli-ready-high", reasoningEffort: "high" }, + ], + }; + + expect(cursorSourceForInterfaceMode("chat")).toBe("sdk"); + expect(cursorSourceForInterfaceMode("cli")).toBe("cli"); + + const toggled = reconcileCursorModelStateForInterface( + cursorModelState(), + "cli", + [sdkOnly, cliOnly], + ); + expect(toggled).toMatchObject({ + interfaceMode: "cli", + modelId: "cursor/cli-only", + model: "cli-only-default", + displayName: "CLI only", + }); + + expect(() => resolveCursorCliModelForLaunch(cursorModelState(), [sdkOnly])) + .toThrow(/available for chat only/i); + expect(resolveCursorCliModelForLaunch( + cursorModelState({ + interfaceMode: "cli", + model: "cli-ready", + modelId: "cursor/cli-ready", + displayName: "CLI ready", + reasoningEffort: "high", + }), + [cliReady], + )).toBe("cli-ready-high"); + }); }); describe("optimistic chat summaries", () => { @@ -1504,6 +1740,58 @@ describe("optimistic chat summaries", () => { }); }); +describe("latestAuthFailedPrompt", () => { + it("restores the most recent prompt when the latest turn failed with auth", () => { + const events: AgentChatEventEnvelope[] = [ + { + sessionId: "s1", + timestamp: "2026-01-01T12:00:00.000Z", + sequence: 1, + event: { type: "user_message", text: "retry deploy", turnId: "turn-1" }, + }, + { + sessionId: "s1", + timestamp: "2026-01-01T12:00:01.000Z", + sequence: 2, + event: { type: "error", message: "Authentication failed: token expired", turnId: "turn-1" }, + }, + ]; + + expect(latestAuthFailedPrompt(events)).toBe("retry deploy"); + }); + + it("ignores older auth failures once a later user turn succeeds", () => { + const events: AgentChatEventEnvelope[] = [ + { + sessionId: "s1", + timestamp: "2026-01-01T12:00:00.000Z", + sequence: 1, + event: { type: "user_message", text: "first", turnId: "turn-1" }, + }, + { + sessionId: "s1", + timestamp: "2026-01-01T12:00:01.000Z", + sequence: 2, + event: { type: "error", message: "auth required", turnId: "turn-1" }, + }, + { + sessionId: "s1", + timestamp: "2026-01-01T12:00:02.000Z", + sequence: 3, + event: { type: "user_message", text: "second", turnId: "turn-2" }, + }, + { + sessionId: "s1", + timestamp: "2026-01-01T12:00:03.000Z", + sequence: 4, + event: { type: "done", status: "completed", turnId: "turn-2" }, + }, + ]; + + expect(latestAuthFailedPrompt(events)).toBeNull(); + }); +}); + describe("terminal mouse tracking", () => { it("uses one conservative cross-terminal mouse baseline", () => { expect(terminalMouseTrackingEnableSequence()).toBe("\x1b[?1000h\x1b[?1002h\x1b[?1006h"); @@ -1516,9 +1804,14 @@ describe("terminal mouse tracking", () => { it("restores mouse tracking, alternate scroll, and the alt screen together", () => { expect(terminalInteractiveRestoreSequence()).toBe( - `${terminalMouseTrackingDisableSequence()}${terminalAlternateScrollDisableSequence()}${terminalAlternateScreenDisableSequence()}`, + `${terminalMouseTrackingDisableSequence()}${terminalAlternateScrollDisableSequence()}${terminalBracketedPasteDisableSequence()}${terminalAlternateScreenDisableSequence()}`, ); }); + + it("uses standard bracketed paste mode toggles for terminal control", () => { + expect(terminalBracketedPasteEnableSequence()).toBe("\x1b[?2004h"); + expect(terminalBracketedPasteDisableSequence()).toBe("\x1b[?2004l"); + }); }); describe("encodeTerminalPromptSubmit", () => { @@ -1536,6 +1829,41 @@ describe("encodeTerminalPromptSubmit", () => { }); }); +describe("bracketed paste input", () => { + it("buffers pasted text and keeps embedded newlines as literal input", () => { + const result = consumeBracketedPasteInput( + EMPTY_BRACKETED_PASTE_STATE, + `${BRACKETED_PASTE_START}one\r\ntwo${BRACKETED_PASTE_END}`, + ); + + expect(result).toEqual({ + consumed: true, + state: EMPTY_BRACKETED_PASTE_STATE, + text: "one\ntwo", + }); + }); + + it("supports bracketed paste split across input chunks", () => { + const first = consumeBracketedPasteInput(EMPTY_BRACKETED_PASTE_STATE, `${BRACKETED_PASTE_START}one`); + expect(first).toEqual({ + consumed: true, + state: { active: true, buffer: "one" }, + text: "", + }); + + const second = consumeBracketedPasteInput(first.state, `\ntwo${BRACKETED_PASTE_END}`); + expect(second).toEqual({ + consumed: true, + state: EMPTY_BRACKETED_PASTE_STATE, + text: "one\ntwo", + }); + }); + + it("strips stray bracketed paste markers from printable prompt input", () => { + expect(stripBracketedPasteMarkers(`${BRACKETED_PASTE_START}alpha${BRACKETED_PASTE_END}`)).toBe("alpha"); + }); +}); + describe("clipboard image attachment routing", () => { it("captures remote clipboard images into a local scratch root", () => { expect(clipboardImageCacheRootForRuntime({ diff --git a/apps/ade-cli/src/tuiClient/__tests__/appPolling.test.tsx b/apps/ade-cli/src/tuiClient/__tests__/appPolling.test.tsx index 730a510d0..c57a2d105 100644 --- a/apps/ade-cli/src/tuiClient/__tests__/appPolling.test.tsx +++ b/apps/ade-cli/src/tuiClient/__tests__/appPolling.test.tsx @@ -15,6 +15,7 @@ const mocks = vi.hoisted(() => ({ getChatHistory: vi.fn(), getSlashCommands: vi.fn(), getAvailableModels: vi.fn(), + fsWatch: vi.fn(), })); vi.mock("../connection", () => ({ @@ -34,6 +35,8 @@ vi.mock("../state", async () => { lastChatByProjectLane: { "/repo": { "lane-1": "chat-1" } }, lastLaneByProject: { "/repo": "lane-1" }, lastLaneId: null, + draftKind: "chat", + draftKindByProject: {}, }), saveAdeCodeProjectState: vi.fn(), }; @@ -52,11 +55,27 @@ vi.mock("../adeApi", async () => { }; }); -import { AdeCodeApp, isLaneWorktreeAvailable, MENTION_REMOTE_DEBOUNCE_MS, shouldHydrateRefreshHistory } from "../app"; +vi.mock("node:fs", async () => { + const actual = await vi.importActual("node:fs"); + return { + ...actual, + watch: mocks.fsWatch, + default: { + ...actual, + watch: mocks.fsWatch, + }, + }; +}); + +import { AdeCodeApp, BACKGROUND_REFRESH_DEBOUNCE_MS, isLaneWorktreeAvailable, MENTION_REMOTE_DEBOUNCE_MS, shouldHydrateRefreshHistory } from "../app"; const reactActGlobal = globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }; let previousReactActEnvironment: boolean | undefined; +function stripAnsi(text: string): string { + return text.replace(/\u001b\[[0-?]*[ -/]*[@-~]/g, ""); +} + beforeAll(() => { previousReactActEnvironment = reactActGlobal.IS_REACT_ACT_ENVIRONMENT; reactActGlobal.IS_REACT_ACT_ENVIRONMENT = true; @@ -126,6 +145,24 @@ async function flushAsyncEffects() { }); } +async function flushInkFrame() { + await act(async () => { + await vi.advanceTimersByTimeAsync(40); + }); + await flushAsyncEffects(); +} + +async function waitForFrame(instance: ReturnType, text: string) { + for (let i = 0; i < 100; i++) { + if (stripAnsi(instance.frames.join("\n")).includes(text)) return; + await flushAsyncEffects(); + await act(async () => { + await vi.advanceTimersByTimeAsync(10); + }); + } + expect(stripAnsi(instance.frames.join("\n"))).toContain(text); +} + async function renderApp(element: React.ReactElement): Promise> { let instance: ReturnType | null = null; await act(async () => { @@ -188,6 +225,7 @@ describe("AdeCodeApp polling", () => { }); mocks.getSlashCommands.mockResolvedValue([]); mocks.getAvailableModels.mockResolvedValue([]); + mocks.fsWatch.mockReturnValue({ close: vi.fn() }); }); afterEach(() => { @@ -223,6 +261,12 @@ describe("AdeCodeApp polling", () => { }); await flushAsyncEffects(); + expect(mocks.listChatSessions).toHaveBeenCalledTimes(1); + await act(async () => { + await vi.advanceTimersByTimeAsync(BACKGROUND_REFRESH_DEBOUNCE_MS); + }); + await flushAsyncEffects(); + expect(mocks.listChatSessions).toHaveBeenCalledTimes(2); expect(mocks.getChatHistory).toHaveBeenCalledTimes(0); @@ -260,24 +304,25 @@ describe("AdeCodeApp polling", () => { }); it("shows startup retry guidance and automatically reconnects", async () => { - mocks.connectToAde - .mockRejectedValueOnce(new Error("socket down")) - .mockResolvedValueOnce(connection); + mocks.connectToAde.mockImplementation(async () => { + throw new Error("socket down"); + }); - const instance = render(); - await flushAsyncEffects(); + const instance = await renderApp(); - expect(instance.lastFrame()).toContain("r retry now"); - expect(mocks.connectToAde).toHaveBeenCalledTimes(1); + await waitForFrame(instance, "r retry now"); + expect(mocks.connectToAde).toHaveBeenCalled(); + const callsBeforeRetry = mocks.connectToAde.mock.calls.length; + mocks.connectToAde.mockResolvedValue(connection); await act(async () => { await vi.advanceTimersByTimeAsync(3_000); }); await flushAsyncEffects(); - expect(mocks.connectToAde).toHaveBeenCalledTimes(2); + expect(mocks.connectToAde.mock.calls.length).toBeGreaterThan(callsBeforeRetry); - instance.unmount(); + await unmountApp(instance); }); it("debounces mention RPCs and caches lane git/pr suggestions", async () => { @@ -290,24 +335,25 @@ describe("AdeCodeApp polling", () => { }); connection.action = actionMock as unknown as AdeCodeConnection["action"]; - const instance = render(); - await flushAsyncEffects(); + const instance = await renderApp(); await act(async () => { instance.stdin.write("@a"); - await vi.advanceTimersByTimeAsync(MENTION_REMOTE_DEBOUNCE_MS - 1); }); - await flushAsyncEffects(); + await flushInkFrame(); expect(connection.action).not.toHaveBeenCalledWith("git", "listRecentCommits", expect.anything()); await act(async () => { - await vi.advanceTimersByTimeAsync(1); + await vi.advanceTimersByTimeAsync(MENTION_REMOTE_DEBOUNCE_MS); }); await flushAsyncEffects(); await act(async () => { instance.stdin.write("b"); + }); + await flushInkFrame(); + await act(async () => { await vi.advanceTimersByTimeAsync(MENTION_REMOTE_DEBOUNCE_MS); }); await flushAsyncEffects(); @@ -317,7 +363,7 @@ describe("AdeCodeApp polling", () => { expect(calls.filter(([domain, action]) => domain === "git" && action === "listRecentCommits")).toHaveLength(1); expect(calls.filter(([domain, action]) => domain === "pr" && action === "listAll")).toHaveLength(1); - instance.unmount(); + await unmountApp(instance); }); }); diff --git a/apps/ade-cli/src/tuiClient/__tests__/commands.test.ts b/apps/ade-cli/src/tuiClient/__tests__/commands.test.ts index 690da61d7..060eac7cb 100644 --- a/apps/ade-cli/src/tuiClient/__tests__/commands.test.ts +++ b/apps/ade-cli/src/tuiClient/__tests__/commands.test.ts @@ -102,6 +102,17 @@ describe("commands", () => { })); }); + it("routes /secrets to the ADE Code right pane", () => { + const parsed = parseCommand("/secrets"); + expect(parsed?.spec?.name).toBe("/secrets"); + expect(parsed ? commandPlacement(parsed) : null).toBe("right"); + expect(paletteCommands("/sec")).toContainEqual(expect.objectContaining({ + name: "/secrets", + source: "ade", + description: "List project secret names and copy masked values", + })); + }); + it("routes runtime commands to chat", () => { const parsed = parseCommand("/ship now", [ { name: "/ship", description: "Ship it", source: "sdk" }, @@ -204,6 +215,10 @@ describe("commands", () => { name: "/skills", description: "List agent skills from project, user, and ADE bundled roots", })); + expect(rows).toContainEqual(expect.objectContaining({ + name: "/secrets", + description: "List project secret names and copy masked values", + })); expect(rows).toContainEqual(expect.objectContaining({ name: "/compact", description: "Compact the active chat context", diff --git a/apps/ade-cli/src/tuiClient/__tests__/connection.test.ts b/apps/ade-cli/src/tuiClient/__tests__/connection.test.ts index 197668ebf..a9560894d 100644 --- a/apps/ade-cli/src/tuiClient/__tests__/connection.test.ts +++ b/apps/ade-cli/src/tuiClient/__tests__/connection.test.ts @@ -1,12 +1,15 @@ +import { EventEmitter } from "node:events"; import fs from "node:fs"; import { createHash } from "node:crypto"; import net from "node:net"; import os from "node:os"; import path from "node:path"; +import { PassThrough } from "node:stream"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { connectToAde } from "../connection"; import { JsonRpcClient } from "../jsonRpcClient"; import { startTuiHeartbeat, type TuiHeartbeat } from "../heartbeat"; +import { ProcessJsonRpcClient } from "../remoteBridge"; import { appendDedupedTuiEvent, appendReservedTuiEvent, @@ -92,6 +95,13 @@ const originalAdeHome = process.env.ADE_HOME; const originalAdeRpcSocketPath = process.env.ADE_RPC_SOCKET_PATH; const originalAdeDefaultRole = process.env.ADE_DEFAULT_ROLE; +class FakeRemoteRpcChild extends EventEmitter { + readonly stdout = new PassThrough(); + readonly stderr = new PassThrough(); + readonly stdin = new PassThrough(); + readonly kill = vi.fn(); +} + function restoreEnv(): void { process.argv[1] = originalArgv1; if (originalAdeHome === undefined) delete process.env.ADE_HOME; @@ -469,6 +479,83 @@ describe("connectToAde embedded mode", () => { serverSocketRef.current?.destroy(); await new Promise((resolve) => server.close(() => resolve())); fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }); + + it("surfaces runtime event replay gaps to subscribers", async () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "ade-code-connection-gap-")); + const socketPath = path.join(tmpDir, "ade.sock"); + const server = net.createServer((socket) => { + let buffer = ""; + socket.on("error", () => {}); + 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; params?: Record }; + const result = (() => { + if (request.method === "ade/initialize") { + return { + runtimeInfo: { multiProject: true, defaultRole: "cto" }, + capabilities: { projects: true }, + }; + } + if (request.method === "projects.add") { + return { projectId: "project-daemon", rootPath: project.projectRoot }; + } + if (request.method === "runtimeEvents.subscribe") { + return { + subscriptionId: "runtime-sub-gap", + gap: true, + oldestCursor: 12, + nextCursor: 90, + }; + } + if (request.method === "runtimeEvents.unsubscribe") { + return { removed: true }; + } + return null; + })(); + if (!socket.destroyed && socket.writable) { + 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, + }); + try { + const gaps: unknown[] = []; + const callback = vi.fn(); + const unsubscribe = await connection.subscribeRuntimeEvents( + { + category: "runtime", + cursor: 50, + limit: 10, + onGap: (gap) => gaps.push(gap), + }, + callback, + ); + expect(gaps).toEqual([{ + gap: true, + oldestCursor: 12, + nextCursor: 90, + subscriptionId: "runtime-sub-gap", + }]); + expect(callback).not.toHaveBeenCalled(); + unsubscribe(); + } finally { + await connection.close(); + await new Promise((resolve) => server.close(() => resolve())); + fs.rmSync(tmpDir, { recursive: true, force: true }); } }); @@ -504,6 +591,54 @@ describe("connectToAde embedded mode", () => { expect(client.close).toHaveBeenCalledTimes(1); }); + it("rechecks the machine socket after taking the spawn lock", async () => { + const socketPath = useMissingMachineSocket(); + const lockPath = path.join(path.dirname(socketPath), `${path.basename(socketPath)}.spawn.lock`); + fs.mkdirSync(path.dirname(lockPath), { recursive: true }); + fs.writeFileSync(lockPath, "stale\n0\n", "utf8"); + const old = new Date(Date.now() - 60_000); + fs.utimesSync(lockPath, old, old); + const client = mockAttachedClient(); + childProcess.spawn.mockImplementation(() => { + fs.mkdirSync(path.dirname(socketPath), { recursive: true }); + fs.writeFileSync(socketPath, "", "utf8"); + return childProcess.child; + }); + + const connection = await connectToAde({ project }); + await connection.close(); + const secondConnection = await connectToAde({ project }); + await secondConnection.close(); + + expect(childProcess.spawn).toHaveBeenCalledTimes(1); + expect(client.request).toHaveBeenCalled(); + expect(fs.existsSync(lockPath)).toBe(false); + }); + + it("unlinks stale machine socket files before retrying daemon startup", async () => { + const socketPath = useMissingMachineSocket(); + fs.mkdirSync(path.dirname(socketPath), { recursive: true }); + fs.writeFileSync(socketPath, ""); + expect(fs.existsSync(socketPath)).toBe(true); + + const client = mockAttachedClient(); + let connectAttempts = 0; + vi.mocked(JsonRpcClient.connect).mockImplementation(async () => { + connectAttempts += 1; + if (connectAttempts === 1) { + throw Object.assign(new Error("connect ECONNREFUSED"), { code: "ECONNREFUSED" }); + } + return client as unknown as JsonRpcClient; + }); + + const connection = await connectToAde({ project }); + await connection.close(); + + expect(childProcess.spawn).toHaveBeenCalledTimes(1); + expect(connectAttempts).toBeGreaterThan(1); + expect(fs.existsSync(socketPath)).toBe(false); + }); + it("repairs the packaged service before spawning an unmanaged machine daemon", async () => { useMissingMachineSocket(); const installEnvRoles: Array = []; @@ -738,6 +873,83 @@ describe("JsonRpcClient", () => { } }); + it("matches responses whose ids are echoed as strings", async () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "ade-code-jsonrpc-")); + const socketPath = path.join(tmpDir, "rpc.sock"); + 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 }; + const response = `${JSON.stringify({ + jsonrpc: "2.0", + id: String(request.id), + result: { method: request.method }, + })}\n`; + socket.write(response.slice(0, 13)); + socket.write(response.slice(13)); + } + }); + }); + + await listenRpc(server, socketPath); + const client = await JsonRpcClient.connect(socketPath); + try { + await expect(client.request("ping")).resolves.toEqual({ method: "ping" }); + } finally { + client.close(); + await closeServer(server); + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }); + + it("handles large Content-Length frames split across many chunks", async () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "ade-code-jsonrpc-")); + const socketPath = path.join(tmpDir, "rpc.sock"); + let resolveServerSocket: (socket: net.Socket) => void = () => {}; + const serverSocketReady = new Promise((resolve) => { + resolveServerSocket = resolve; + }); + const server = net.createServer((socket) => { + resolveServerSocket(socket); + }); + + await listenRpc(server, socketPath); + const client = await JsonRpcClient.connect(socketPath); + const socket = await serverSocketReady; + try { + const notification = new Promise((resolve) => { + client.onNotification("chat/event", resolve); + }); + const message = "x".repeat(128 * 1024); + const payload = JSON.stringify({ + jsonrpc: "2.0", + method: "chat/event", + params: { message }, + }); + const framed = Buffer.concat([ + Buffer.from(`Content-Length: ${Buffer.byteLength(payload, "utf8")}\r\n\r\n`, "ascii"), + Buffer.from(payload, "utf8"), + ]); + for (let offset = 0; offset < framed.length; offset += 1024) { + socket.write(framed.subarray(offset, offset + 1024)); + } + + await expect(notification).resolves.toEqual({ message }); + } finally { + client.close(); + socket.destroy(); + await closeServer(server); + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }); + it("fires onClose when the socket drops unexpectedly", async () => { const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "ade-code-jsonrpc-")); const socketPath = path.join(tmpDir, "rpc.sock"); @@ -777,6 +989,84 @@ describe("JsonRpcClient", () => { fs.rmSync(tmpDir, { recursive: true, force: true }); } }); + + it("times out pending requests by tearing down the socket", async () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "ade-code-jsonrpc-")); + const socketPath = path.join(tmpDir, "rpc.sock"); + let resolveServerSocket: (socket: net.Socket) => void = () => {}; + const serverSocketReady = new Promise((resolve) => { + resolveServerSocket = resolve; + }); + const server = net.createServer((socket) => resolveServerSocket(socket)); + await listenRpc(server, socketPath); + const client = await JsonRpcClient.connect(socketPath); + const socket = await serverSocketReady; + try { + const closed = new Promise((resolve) => client.onClose(resolve)); + const first = client.request("slow", undefined, { timeoutMs: 25 }); + const second = client.request("also-slow", undefined, { timeoutMs: 1_000 }); + + await expect(first).rejects.toThrow(/timed out/i); + await expect(second).rejects.toThrow(/timed out|closed/i); + await expect(closed).resolves.toBeUndefined(); + await expect(client.request("after-timeout")).rejects.toThrow(/closed/i); + } finally { + client.close(); + socket.destroy(); + await closeServer(server); + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }); + + it("fails the connection on parse garbage instead of continuing", async () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "ade-code-jsonrpc-")); + const socketPath = path.join(tmpDir, "rpc.sock"); + let resolveServerSocket: (socket: net.Socket) => void = () => {}; + const serverSocketReady = new Promise((resolve) => { + resolveServerSocket = resolve; + }); + const server = net.createServer((socket) => resolveServerSocket(socket)); + await listenRpc(server, socketPath); + const client = await JsonRpcClient.connect(socketPath); + const socket = await serverSocketReady; + try { + const closed = new Promise((resolve) => client.onClose(resolve)); + const pending = client.request("ping", undefined, { timeoutMs: 1_000 }); + socket.write("this is not json\n"); + + await expect(pending).rejects.toThrow(/Invalid JSON-RPC response|non-JSON output|closed/i); + await expect(closed).resolves.toBeUndefined(); + } finally { + client.close(); + socket.destroy(); + await closeServer(server); + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }); +}); + +describe("ProcessJsonRpcClient", () => { + it("matches string response ids and clears pending RPC timers", async () => { + const clearTimeoutSpy = vi.spyOn(global, "clearTimeout"); + const child = new FakeRemoteRpcChild(); + const client = new ProcessJsonRpcClient(child as never); + + const resolved = client.request("ade/ping"); + child.stdout.write(`${JSON.stringify({ jsonrpc: "2.0", id: "1", result: { ok: true } })}\n`); + await expect(resolved).resolves.toEqual({ ok: true }); + + const rejected = client.request("ade/fail"); + child.stdout.write(`${JSON.stringify({ + jsonrpc: "2.0", + id: 2, + error: { code: -32000, message: "remote failed" }, + })}\n`); + await expect(rejected).rejects.toThrow("remote failed"); + + expect(clearTimeoutSpy).toHaveBeenCalledTimes(2); + client.close(); + clearTimeoutSpy.mockRestore(); + }); }); async function loadStateModule(home: string): Promise { @@ -805,18 +1095,22 @@ describe("ade-code TUI state", () => { lastChatByProjectLane: {}, lastLaneId: null, lastLaneByProject: {}, + draftKind: "chat", + draftKindByProject: {}, }); }); it("persists the last lane id with last chat pointers", async () => { const home = fs.mkdtempSync(path.join(os.tmpdir(), "ade-tui-state-")); - const { loadAdeCodeState, saveAdeCodeState } = await loadStateModule(home); + const { loadAdeCodeState, saveAdeCodeStateAsync } = await loadStateModule(home); - saveAdeCodeState({ + await saveAdeCodeStateAsync({ lastChatByLane: { "lane-2": "chat-9" }, lastChatByProjectLane: {}, lastLaneId: "lane-2", lastLaneByProject: {}, + draftKind: "cli", + draftKindByProject: {}, }); expect(loadAdeCodeState()).toEqual({ @@ -824,6 +1118,8 @@ describe("ade-code TUI state", () => { lastChatByProjectLane: {}, lastLaneId: "lane-2", lastLaneByProject: {}, + draftKind: "cli", + draftKindByProject: {}, }); }); }); diff --git a/apps/ade-cli/src/tuiClient/__tests__/displayWidth.test.ts b/apps/ade-cli/src/tuiClient/__tests__/displayWidth.test.ts new file mode 100644 index 000000000..8aaf428a8 --- /dev/null +++ b/apps/ade-cli/src/tuiClient/__tests__/displayWidth.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from "vitest"; +import { splitByDisplayCells, terminalDisplayWidth, truncateDisplayEnd } from "../displayWidth"; + +describe("splitByDisplayCells", () => { + it("assigns a wide cluster that crosses a split boundary to one segment", () => { + expect(splitByDisplayCells("ab界cd", 2, 3)).toEqual({ + before: "ab", + selected: "界", + after: "cd", + }); + }); +}); + +describe("truncateDisplayEnd", () => { + it("never exceeds maxCells when a wide grapheme straddles the ellipsis boundary", () => { + const truncated = truncateDisplayEnd("界面设置", 2); + expect(terminalDisplayWidth(truncated)).toBeLessThanOrEqual(2); + expect(truncated).toBe("…"); + }); + + it("keeps whole wide clusters that fit before the ellipsis", () => { + const truncated = truncateDisplayEnd("界面设置", 5); + expect(truncated).toBe("界面…"); + expect(terminalDisplayWidth(truncated)).toBe(5); + }); + + it("leaves narrow text untouched below the budget and truncates above it", () => { + expect(truncateDisplayEnd("abc", 4)).toBe("abc"); + expect(truncateDisplayEnd("abcdef", 4)).toBe("abc…"); + expect(truncateDisplayEnd("ab", 1)).toBe("a"); + }); +}); diff --git a/apps/ade-cli/src/tuiClient/__tests__/drawerLayout.test.ts b/apps/ade-cli/src/tuiClient/__tests__/drawerLayout.test.ts index 568d3a43e..c80cc6bb9 100644 --- a/apps/ade-cli/src/tuiClient/__tests__/drawerLayout.test.ts +++ b/apps/ade-cli/src/tuiClient/__tests__/drawerLayout.test.ts @@ -92,6 +92,28 @@ describe("drawerMouseHitForLayout", () => { expect(drawerMouseHitForLayout({ y: 15, layout })).toEqual({ kind: "lane", index: 1 }); }); + it("maps expanded closed CLI group rows through the shared layout model", () => { + const layout = computeDrawerLayout({ + panelHeight: 60, + lanes: [laneInput("a", 2, { closedChatCount: 3, closedExpanded: true })], + expandedLaneIndex: 0, + selectedLaneIndex: 0, + scrollOffsetRows: 0, + }); + + expect(layout.lanes[0]).toMatchObject({ + visibleChatCount: 2, + closedToggleVisible: true, + visibleClosedChatCount: 3, + closedExpanded: true, + }); + expect(drawerMouseHitForLayout({ y: 5, layout })).toEqual({ kind: "chat", laneIndex: 0, chatIndex: 0 }); + expect(drawerMouseHitForLayout({ y: 7, layout })).toEqual({ kind: "closed-toggle", laneIndex: 0 }); + expect(drawerMouseHitForLayout({ y: 8, layout })).toEqual({ kind: "closed-chat", laneIndex: 0, closedIndex: 0 }); + expect(drawerMouseHitForLayout({ y: 10, layout })).toEqual({ kind: "closed-chat", laneIndex: 0, closedIndex: 2 }); + expect(drawerMouseHitForLayout({ y: 11, layout })).toEqual({ kind: "new-chat" }); + }); + it("maps an expanded empty lane's new-chat row directly under its lane line", () => { const layout = computeDrawerLayout({ panelHeight: 60, @@ -138,4 +160,17 @@ describe("drawerMouseHitForLayout", () => { expect(drawerMouseHitForLayout({ y: 13, layout })).toEqual({ kind: "chat", laneIndex: 1, chatIndex: 3 }); expect(drawerMouseHitForLayout({ y: 14, layout })).toEqual({ kind: "lane", index: 1 }); }); + + it("maps the drawer + new lane row through the shared layout model", () => { + const layout = computeDrawerLayout({ + panelHeight: 20, + lanes: [laneInput("a", 0)], + expandedLaneIndex: null, + selectedLaneIndex: null, + scrollOffsetRows: 0, + }); + + expect(layout.newLaneRow).toBe(19); + expect(drawerMouseHitForLayout({ y: layout.newLaneRow, layout })).toEqual({ kind: "new-lane" }); + }); }); diff --git a/apps/ade-cli/src/tuiClient/__tests__/format.test.ts b/apps/ade-cli/src/tuiClient/__tests__/format.test.ts index effb1e04c..a1be9e09f 100644 --- a/apps/ade-cli/src/tuiClient/__tests__/format.test.ts +++ b/apps/ade-cli/src/tuiClient/__tests__/format.test.ts @@ -1,5 +1,15 @@ import { describe, expect, it } from "vitest"; -import { diffLineKind, latestExpandableFailureId, parseAssistantMarkdown, parseInlineRuns, renderChatLines, renderObject } from "../format"; +import { + __clearAssistantMarkdownCacheForTests, + __getAssistantMarkdownCacheStatsForTests, + diffLineKind, + latestExpandableFailureId, + parseAssistantMarkdown, + parseInlineRuns, + renderChatLines, + renderObject, +} from "../format"; +import { formatRelativePastTime } from "../relativeTime"; describe("diffLineKind", () => { it("classifies hunk, meta, add, del, and context lines", () => { @@ -17,7 +27,24 @@ describe("diffLineKind", () => { }); }); +describe("formatRelativePastTime", () => { + it("uses a neutral fallback for missing or invalid timestamps", () => { + expect(formatRelativePastTime(null)).toBe("recently"); + expect(formatRelativePastTime("not-a-date")).toBe("recently"); + }); +}); + describe("renderChatLines", () => { + it("LRU-caches assistant markdown parses by message text", () => { + __clearAssistantMarkdownCacheForTests(); + const text = "Paragraph text\n\n```ts\nconst value = 1;\n```"; + const first = parseAssistantMarkdown(text); + const second = parseAssistantMarkdown(text); + + expect(second).toBe(first); + expect(__getAssistantMarkdownCacheStatsForTests().entries).toBe(1); + }); + it("parses assistant markdown into stable blocks", () => { const blocks = parseAssistantMarkdown([ "# Heading", diff --git a/apps/ade-cli/src/tuiClient/__tests__/keybindings.test.ts b/apps/ade-cli/src/tuiClient/__tests__/keybindings.test.ts index 9a6257611..080a88819 100644 --- a/apps/ade-cli/src/tuiClient/__tests__/keybindings.test.ts +++ b/apps/ade-cli/src/tuiClient/__tests__/keybindings.test.ts @@ -1,8 +1,10 @@ import { describe, expect, it } from "vitest"; import { dispatchKeybinding, + keybindingsEditorCommand, keypressToChord, normalizeKeyChord, + splitEditorCommand, validateClaudeKeybindingsConfig, } from "../keybindings"; @@ -113,4 +115,35 @@ describe("keybindings", () => { expect(keypressToChord("", { pageDown: true })).toBe("pagedown"); expect(keypressToChord("k", { ctrl: true })).toBe("ctrl+k"); }); + + it("builds editor argv without shell parsing the target file path", () => { + expect(splitEditorCommand("code --wait")).toEqual(["code", "--wait"]); + expect(keybindingsEditorCommand("/tmp/keybindings.json", "code --wait", "darwin")).toEqual({ + command: "code", + args: ["--wait", "/tmp/keybindings.json"], + }); + expect(keybindingsEditorCommand("/tmp/keybindings.json", undefined, "darwin")).toEqual({ + command: "open", + args: ["/tmp/keybindings.json"], + }); + }); + + it("preserves quoted segments in VISUAL/EDITOR values", () => { + expect(splitEditorCommand('emacsclient -a ""')).toEqual(["emacsclient", "-a", ""]); + expect(splitEditorCommand('"/Applications/Visual Studio Code.app/Contents/MacOS/Electron" --wait')).toEqual([ + "/Applications/Visual Studio Code.app/Contents/MacOS/Electron", + "--wait", + ]); + expect(splitEditorCommand("vim -c 'set ft=json'")).toEqual(["vim", "-c", "set ft=json"]); + expect(splitEditorCommand('code --user-data-dir "/tmp/my dir"')).toEqual([ + "code", + "--user-data-dir", + "/tmp/my dir", + ]); + expect(splitEditorCommand("edit\\ or --flag")).toEqual(["edit or", "--flag"]); + expect(keybindingsEditorCommand("/tmp/keybindings.json", 'emacsclient -a ""', "linux")).toEqual({ + command: "emacsclient", + args: ["-a", "", "/tmp/keybindings.json"], + }); + }); }); diff --git a/apps/ade-cli/src/tuiClient/__tests__/newLaneForm.test.ts b/apps/ade-cli/src/tuiClient/__tests__/newLaneForm.test.ts index f987ae75b..4983d4f2c 100644 --- a/apps/ade-cli/src/tuiClient/__tests__/newLaneForm.test.ts +++ b/apps/ade-cli/src/tuiClient/__tests__/newLaneForm.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import type { LaneSummary } from "../../../../desktop/src/shared/types/lanes"; +import type { LaneLinearIssue, LaneSummary } from "../../../../desktop/src/shared/types/lanes"; import { LANE_COLOR_PALETTE } from "../../../../desktop/src/shared/laneColorPalette"; import { NEW_LANE_COLOR_OPTIONS, @@ -23,16 +23,44 @@ function lane(id: string, name: string): LaneSummary { return { id, name } as LaneSummary; } +function linearIssue(overrides: Partial = {}): LaneLinearIssue { + return { + id: "issue-1", + identifier: "ADE-123", + title: "Fix setup parity", + description: null, + url: "https://linear.app/ade/issue/ADE-123/fix-setup-parity", + projectId: "project-1", + projectSlug: "ade", + projectName: "ADE", + teamId: "team-1", + teamKey: "ADE", + teamName: "ADE", + stateId: "state-1", + stateName: "Todo", + stateType: "unstarted", + priority: 0, + priorityLabel: "none", + labels: [], + assigneeId: null, + assigneeName: null, + branchName: null, + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + ...overrides, + }; +} + describe("newLaneFormFields", () => { it("shows mode-specific fields for each start mode, always ending in the create button", () => { const primary = newLaneFormFields("primary").map((field) => field.name); - expect(primary).toEqual(["name", "color", "start", "baseBranch", "create"]); + expect(primary).toEqual(["name", "color", "start", "baseBranch", "linearIssue", "templateId", "create"]); const child = newLaneFormFields("child", { activeLaneName: "Current" }).map((field) => field.name); - expect(child).toEqual(["name", "color", "start", "parent", "baseBranch", "create"]); + expect(child).toEqual(["name", "color", "start", "parent", "baseBranch", "linearIssue", "templateId", "create"]); const imported = newLaneFormFields("import").map((field) => field.name); - expect(imported).toEqual(["name", "color", "start", "branchSource", "branch", "baseBranch", "create"]); + expect(imported).toEqual(["name", "color", "start", "branchSource", "branch", "baseBranch", "linearIssue", "templateId", "create"]); }); it("keeps the start row at the same index across modes so focus stays put", () => { @@ -113,12 +141,12 @@ describe("branch typeahead", () => { describe("newLaneFormFieldRowOffsets", () => { it("matches the NewLaneFormPane block heights (start = 5 rows, typeahead +4, create = 2)", () => { - // primary: name(1), color(4), start(7), baseBranch(12, +typeahead), create(19) - expect(newLaneFormFieldRowOffsets(newLaneFormFields("primary"))).toEqual([1, 4, 7, 12, 19]); - // child has no typeahead: name(1), color(4), start(7), parent(12), base(15), create(18) - expect(newLaneFormFieldRowOffsets(newLaneFormFields("child"))).toEqual([1, 4, 7, 12, 15, 18]); - // import: name(1), color(4), start(7), source(12), branch(15, +typeahead), base(22), create(25) - expect(newLaneFormFieldRowOffsets(newLaneFormFields("import"))).toEqual([1, 4, 7, 12, 15, 22, 25]); + // primary: name(1), color(4), start(7), baseBranch(12, +typeahead), issue(19), template(22), create(25) + expect(newLaneFormFieldRowOffsets(newLaneFormFields("primary"))).toEqual([1, 4, 7, 12, 19, 22, 25]); + // child has no typeahead: name(1), color(4), start(7), parent(12), base(15), issue(18), template(21), create(24) + expect(newLaneFormFieldRowOffsets(newLaneFormFields("child"))).toEqual([1, 4, 7, 12, 15, 18, 21, 24]); + // import: name(1), color(4), start(7), source(12), branch(15, +typeahead), base(22), issue(25), template(28), create(31) + expect(newLaneFormFieldRowOffsets(newLaneFormFields("import"))).toEqual([1, 4, 7, 12, 15, 22, 25, 28, 31]); expect(NEW_LANE_TYPEAHEAD_ROWS).toBe(4); }); }); @@ -236,4 +264,50 @@ describe("buildNewLaneSubmission", () => { activeLaneId: null, })).toEqual({ kind: "error", message: "Branch is required." }); }); + + it("attaches Linear issue metadata and selected setup template to create payloads", () => { + const issue = linearIssue(); + + expect(buildNewLaneSubmission({ + values: { name: "feature-x", start: "primary", linearIssue: "ADE-123", templateId: "tpl-default" }, + lanes, + activeLaneId: "lane-1", + linearIssue: issue, + })).toEqual({ + kind: "create", + payload: { + name: "feature-x", + branchName: "ade-123-fix-setup-parity", + linearIssue: issue, + }, + templateId: "tpl-default", + }); + + expect(buildNewLaneSubmission({ + values: { name: "stacked", start: "child", linearIssue: "ADE-123" }, + lanes, + activeLaneId: "lane-2", + linearIssue: issue, + })).toMatchObject({ + kind: "createChild", + payload: { + name: "stacked", + parentLaneId: "lane-2", + branchName: "ade-123-fix-setup-parity", + linearIssue: issue, + }, + }); + }); + + it("rejects Linear issue attachment for imported branches", () => { + expect(buildNewLaneSubmission({ + values: { name: "adopted", start: "import", branch: "origin/feature-y", linearIssue: "ADE-123" }, + lanes, + activeLaneId: null, + linearIssue: linearIssue(), + })).toEqual({ + kind: "error", + message: "Linear issue attachment is not supported when importing an existing branch.", + }); + }); }); diff --git a/apps/ade-cli/src/tuiClient/__tests__/pendingInput.test.ts b/apps/ade-cli/src/tuiClient/__tests__/pendingInput.test.ts index 00ab0d173..fb0c2c9ee 100644 --- a/apps/ade-cli/src/tuiClient/__tests__/pendingInput.test.ts +++ b/apps/ade-cli/src/tuiClient/__tests__/pendingInput.test.ts @@ -3,12 +3,15 @@ import type { AgentChatEventEnvelope, PendingInputRequest } from "../../../../de import { answerForQuestion, buildPendingInputAnswers, + cancelPendingQuestionDigitSelection, + convertPendingQuestionDigitSelectionToText, createPendingQuestionSelectionState, latestPendingApproval, movePendingQuestionFocus, movePendingQuestionOption, pendingQuestionAnsweredCount, pendingQuestionSelectionValue, + selectPendingQuestionDigit, selectPendingQuestionOptionIndex, } from "../pendingInput"; @@ -31,6 +34,16 @@ const baseRequest: PendingInputRequest = { canProceedWithoutAnswer: false, }; +function questionApproval(request: PendingInputRequest = baseRequest) { + return { + itemId: "item-questions", + description: "Need input", + highStakes: false, + mode: "question" as const, + request, + }; +} + describe("pendingInput", () => { it("maps option numbers to structured answers", () => { expect(buildPendingInputAnswers(baseRequest, "2")).toEqual({ path: "manual" }); @@ -157,13 +170,7 @@ describe("pendingInput", () => { }, ], }; - const approval = { - itemId: "item-questions", - description: "Need input", - highStakes: false, - mode: "question" as const, - request, - }; + const approval = questionApproval(request); const initial = createPendingQuestionSelectionState(approval)!; expect(pendingQuestionSelectionValue(request, initial)).toBe("recommended"); @@ -197,13 +204,7 @@ describe("pendingInput", () => { multiSelect: true, }], }; - const approval = { - itemId: "item-multi", - description: "Need input", - highStakes: false, - mode: "question" as const, - request, - }; + const approval = questionApproval(request); const initial = createPendingQuestionSelectionState(approval)!; expect(pendingQuestionSelectionValue(request, initial)).toBeNull(); @@ -230,4 +231,123 @@ describe("pendingInput", () => { expect(pendingQuestionAnsweredCount(request, { path: "manual" })).toBe(1); expect(pendingQuestionAnsweredCount(request, { path: "manual", second: "yes" })).toBe(2); }); + + it("selects a numbered option provisionally until Enter submits the highlighted answer", () => { + const initial = createPendingQuestionSelectionState(questionApproval())!; + const result = selectPendingQuestionDigit(baseRequest, initial, "2"); + + expect(result.selected).toBe(true); + expect(pendingQuestionSelectionValue(baseRequest, result.state)).toBe("manual"); + expect(result.state.answers).toEqual({}); + expect(result.state.pendingDigitSelection).toEqual(expect.objectContaining({ + digit: "2", + previousOptionIndex: 0, + questionId: "path", + })); + }); + + it("converts a provisional digit into free text and restores the previous option", () => { + const request: PendingInputRequest = { + ...baseRequest, + questions: [{ + ...baseRequest.questions[0]!, + options: [ + { label: "One", value: "one" }, + { label: "Two", value: "two" }, + { label: "Three", value: "three" }, + ], + }], + }; + const initial = createPendingQuestionSelectionState(questionApproval(request))!; + const selected = selectPendingQuestionDigit(request, initial, "3").state; + + const converted = convertPendingQuestionDigitSelectionToText(request, selected, " apples"); + + expect(converted?.text).toBe("3 apples"); + expect(converted?.state.pendingDigitSelection).toBeNull(); + expect(converted ? pendingQuestionSelectionValue(request, converted.state) : null).toBe("one"); + }); + + it("restores the original default when a provisional digit is cancelled before Enter", () => { + const initial = createPendingQuestionSelectionState(questionApproval())!; + const selected = selectPendingQuestionDigit(baseRequest, initial, "2").state; + + const cancelled = cancelPendingQuestionDigitSelection(selected); + + expect(cancelled.cancelled).toBe(true); + expect(cancelled.state.pendingDigitSelection).toBeNull(); + expect(pendingQuestionSelectionValue(baseRequest, cancelled.state)).toBe("recommended"); + }); + + it("lets Backspace-cancelled digit input fall back to plain typed text", () => { + const request: PendingInputRequest = { + ...baseRequest, + questions: [{ + ...baseRequest.questions[0]!, + options: [ + { label: "One", value: "one" }, + { label: "Two", value: "two" }, + { label: "Three", value: "three" }, + ], + }], + }; + const initial = createPendingQuestionSelectionState(questionApproval(request))!; + const selected = selectPendingQuestionDigit(request, initial, "3").state; + + const cancelled = cancelPendingQuestionDigitSelection(selected); + + expect(cancelled.cancelled).toBe(true); + expect(convertPendingQuestionDigitSelectionToText(request, cancelled.state, " apples")).toBeNull(); + expect(pendingQuestionSelectionValue(request, cancelled.state)).toBe("one"); + expect(answerForQuestion(request.questions[0]!, "3 apples")).toBe("3 apples"); + }); + + it("leaves out-of-range digits for the composer", () => { + const initial = createPendingQuestionSelectionState(questionApproval())!; + const result = selectPendingQuestionDigit(baseRequest, initial, "9"); + + expect(result.selected).toBe(false); + expect(result.state).toBe(initial); + expect(convertPendingQuestionDigitSelectionToText(baseRequest, result.state, "9")).toBeNull(); + }); + + it("replaces a provisional quick-select on rapid in-range digits", () => { + const selectedOne = selectPendingQuestionDigit(baseRequest, createPendingQuestionSelectionState(questionApproval())!, "1").state; + const selectedTwo = selectPendingQuestionDigit(baseRequest, selectedOne, "2").state; + + expect(pendingQuestionSelectionValue(baseRequest, selectedTwo)).toBe("manual"); + expect(selectedTwo.pendingDigitSelection).toEqual(expect.objectContaining({ + digit: "2", + previousOptionIndex: 0, + })); + }); + + it("converts an out-of-range second digit after a provisional quick-select into text", () => { + const request: PendingInputRequest = { + ...baseRequest, + questions: [{ + ...baseRequest.questions[0]!, + options: [ + { label: "One", value: "one" }, + { label: "Two", value: "two" }, + { label: "Three", value: "three" }, + ], + }], + }; + const selectedThree = selectPendingQuestionDigit(request, createPendingQuestionSelectionState(questionApproval(request))!, "3").state; + const ignoredNine = selectPendingQuestionDigit(request, selectedThree, "9"); + const converted = convertPendingQuestionDigitSelectionToText(request, ignoredNine.state, "9"); + + expect(ignoredNine.selected).toBe(false); + expect(converted?.text).toBe("39"); + expect(converted ? pendingQuestionSelectionValue(request, converted.state) : null).toBe("one"); + }); + + it("keeps direct option selection immediate for click-style callers", () => { + const initial = createPendingQuestionSelectionState(questionApproval())!; + const selected = selectPendingQuestionOptionIndex(baseRequest, initial, 1); + + expect(pendingQuestionSelectionValue(baseRequest, selected)).toBe("manual"); + expect(selected.pendingDigitSelection).toBeNull(); + }); }); diff --git a/apps/ade-cli/src/tuiClient/__tests__/planMode.test.ts b/apps/ade-cli/src/tuiClient/__tests__/planMode.test.ts index d0b83032d..be21a3789 100644 --- a/apps/ade-cli/src/tuiClient/__tests__/planMode.test.ts +++ b/apps/ade-cli/src/tuiClient/__tests__/planMode.test.ts @@ -6,6 +6,7 @@ import type { AdeCodeModelState } from "../types"; function baseModelState(overrides: Partial): AdeCodeModelState { return { provider: "codex", + interfaceMode: "chat", model: "gpt-5.5", modelId: null, displayName: "GPT-5.5", diff --git a/apps/ade-cli/src/tuiClient/__tests__/remoteLauncher.test.ts b/apps/ade-cli/src/tuiClient/__tests__/remoteLauncher.test.ts index 8322bf2cb..af8ecedc7 100644 --- a/apps/ade-cli/src/tuiClient/__tests__/remoteLauncher.test.ts +++ b/apps/ade-cli/src/tuiClient/__tests__/remoteLauncher.test.ts @@ -209,6 +209,33 @@ describe("ade code remote launcher", () => { ]); }); + it("lists every tracked provider CLI terminal and hides chat-backed terminals", async () => { + const terminals = [ + { terminalId: "claude-cli", toolType: "claude", laneId: "lane-1", title: "Claude", status: "running", runtimeState: "idle", startedAt: "2026-06-15T00:00:00.000Z" }, + { terminalId: "codex-cli", toolType: "codex", laneId: "lane-1", title: "Codex", status: "running", runtimeState: "idle", startedAt: "2026-06-15T00:00:00.000Z" }, + { terminalId: "cursor-cli", toolType: "cursor-cli", laneId: "lane-1", title: "Cursor", status: "running", runtimeState: "idle", startedAt: "2026-06-15T00:00:00.000Z" }, + { terminalId: "droid-cli", toolType: "droid", laneId: "lane-1", title: "Droid", status: "running", runtimeState: "idle", startedAt: "2026-06-15T00:00:00.000Z" }, + { terminalId: "opencode-cli", toolType: "opencode", laneId: "lane-1", title: "OpenCode", status: "running", runtimeState: "idle", startedAt: "2026-06-15T00:00:00.000Z" }, + // Chat-backed + plain shell must NOT be launchable. + { terminalId: "codex-chat", toolType: "codex-chat", laneId: "lane-1", title: "Codex chat", status: "running", runtimeState: "idle", startedAt: "2026-06-15T00:00:00.000Z" }, + { terminalId: "cursor-chat", toolType: "cursor", laneId: "lane-1", title: "Cursor chat", status: "running", runtimeState: "idle", startedAt: "2026-06-15T00:00:00.000Z" }, + { terminalId: "raw-shell", toolType: "shell", laneId: "lane-1", title: "Shell", status: "running", runtimeState: "idle", startedAt: "2026-06-15T00:00:00.000Z" }, + ]; + 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: terminals }; + throw new Error("unexpected request"); + }, + }; + + const result = await listRemoteSessions(client as never, "project-1"); + expect(result.map((session) => session.sessionId).sort()).toEqual( + ["claude-cli", "codex-cli", "cursor-cli", "droid-cli", "opencode-cli"].sort(), + ); + }); + 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, [ diff --git a/apps/ade-cli/src/tuiClient/__tests__/state.test.ts b/apps/ade-cli/src/tuiClient/__tests__/state.test.ts index 1c70fd560..94463b7e6 100644 --- a/apps/ade-cli/src/tuiClient/__tests__/state.test.ts +++ b/apps/ade-cli/src/tuiClient/__tests__/state.test.ts @@ -2,7 +2,14 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { afterEach, describe, expect, it } from "vitest"; -import { loadAdeCodeState, normalizeAdeCodeState, saveAdeCodeProjectState, scopedAdeCodeState } from "../state"; +import { + flushAdeCodeStateWrites, + loadAdeCodeState, + normalizeAdeCodeState, + saveAdeCodeProjectState, + saveAdeCodeProjectStateAsync, + scopedAdeCodeState, +} from "../state"; afterEach(() => { delete process.env.ADE_CODE_STATE_DIR; @@ -21,11 +28,17 @@ describe("ade code persisted state", () => { "/repo-a": "repo-a-lane", "/repo-b": "repo-b-lane", }, + draftKind: "chat", + draftKindByProject: { + "/repo-a": "chat", + "/repo-b": "cli", + }, }); expect(scopedAdeCodeState(state, "/repo-b")).toEqual({ lastChatByLane: { main: "repo-b-chat" }, lastLaneId: "repo-b-lane", + draftKind: "cli", }); }); @@ -33,6 +46,7 @@ describe("ade code persisted state", () => { const state = normalizeAdeCodeState({ lastChatByLane: { main: "legacy-chat" }, lastLaneId: "legacy-lane", + draftKind: "cli", lastChatByProjectLane: { "/repo-a": { main: "repo-a-chat" }, }, @@ -44,6 +58,7 @@ describe("ade code persisted state", () => { expect(scopedAdeCodeState(state, "/repo-b")).toEqual({ lastChatByLane: { main: "legacy-chat" }, lastLaneId: "legacy-lane", + draftKind: "cli", }); }); @@ -59,6 +74,12 @@ describe("ade code persisted state", () => { "/repo-a": "repo-a-lane", "/repo-b": null, }, + draftKind: "terminal", + draftKindByProject: { + "/repo-a": "cli", + "/repo-b": "terminal", + "/repo-c": "chat", + }, }); expect(state).toEqual({ @@ -66,20 +87,24 @@ describe("ade code persisted state", () => { lastChatByProjectLane: { "/repo-a": { main: "repo-a-chat" } }, lastLaneId: null, lastLaneByProject: { "/repo-a": "repo-a-lane" }, + draftKind: "chat", + draftKindByProject: { "/repo-a": "cli", "/repo-c": "chat" }, }); }); - it("merges project-scoped saves with existing state under the shared state file", () => { + it("merges project-scoped saves with existing state under the shared state file", async () => { const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "ade-code-state-")); process.env.ADE_CODE_STATE_DIR = stateDir; - saveAdeCodeProjectState("/repo-a", { + await saveAdeCodeProjectStateAsync("/repo-a", { lastChatByLane: { main: "repo-a-chat" }, lastLaneId: "repo-a-lane", + draftKind: "chat", }); - saveAdeCodeProjectState("/repo-b", { + await saveAdeCodeProjectStateAsync("/repo-b", { lastChatByLane: { main: "repo-b-chat" }, lastLaneId: "repo-b-lane", + draftKind: "cli", }); const persisted = loadAdeCodeState(); @@ -91,6 +116,61 @@ describe("ade code persisted state", () => { [path.resolve("/repo-a")]: "repo-a-lane", [path.resolve("/repo-b")]: "repo-b-lane", }); + expect(persisted.draftKindByProject).toEqual({ + [path.resolve("/repo-a")]: "chat", + [path.resolve("/repo-b")]: "cli", + }); + expect(persisted.draftKind).toBe("cli"); expect(fs.existsSync(path.join(stateDir, "ade-code-state.json.lock"))).toBe(false); }); + + it("retries async saves behind an existing lock without blocking the caller", async () => { + const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "ade-code-state-")); + process.env.ADE_CODE_STATE_DIR = stateDir; + fs.mkdirSync(stateDir, { recursive: true }); + const lockPath = path.join(stateDir, "ade-code-state.json.lock"); + fs.writeFileSync(lockPath, "other writer"); + + const savePromise = saveAdeCodeProjectStateAsync("/repo-a", { + lastChatByLane: { main: "repo-a-chat" }, + lastLaneId: "repo-a-lane", + draftKind: "cli", + }); + let settled = false; + savePromise.then(() => { + settled = true; + }); + await Promise.resolve(); + + expect(settled).toBe(false); + + fs.unlinkSync(lockPath); + await savePromise; + + const persisted = loadAdeCodeState(); + expect(persisted.lastChatByProjectLane).toEqual({ + [path.resolve("/repo-a")]: { main: "repo-a-chat" }, + }); + expect(persisted.draftKindByProject).toEqual({ + [path.resolve("/repo-a")]: "cli", + }); + expect(fs.existsSync(lockPath)).toBe(false); + }); + + it("flushes fire-and-forget project state saves before shutdown", async () => { + const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "ade-code-state-")); + process.env.ADE_CODE_STATE_DIR = stateDir; + + saveAdeCodeProjectState("/repo-a", { + lastChatByLane: { main: "repo-a-chat" }, + lastLaneId: "repo-a-lane", + draftKind: "cli", + }); + await flushAdeCodeStateWrites(); + + const persisted = loadAdeCodeState(); + expect(persisted.lastChatByProjectLane[path.resolve("/repo-a")]).toEqual({ main: "repo-a-chat" }); + expect(persisted.lastLaneByProject[path.resolve("/repo-a")]).toBe("repo-a-lane"); + expect(persisted.draftKindByProject[path.resolve("/repo-a")]).toBe("cli"); + }); }); diff --git a/apps/ade-cli/src/tuiClient/adeApi.ts b/apps/ade-cli/src/tuiClient/adeApi.ts index 6184e547a..37959ac91 100644 --- a/apps/ade-cli/src/tuiClient/adeApi.ts +++ b/apps/ade-cli/src/tuiClient/adeApi.ts @@ -57,7 +57,7 @@ import type { TerminalSessionSummary, } from "../../../desktop/src/shared/types"; import { discoverAllProjectSlashCommands } from "../../../desktop/src/main/services/chat/projectSlashCommandDiscovery"; -import type { AdeCodeConnection, ChatHistorySnapshot, CreatedChat, NavigateRequest, NavigateResult } from "./types"; +import type { AdeCodeConnection, AdeCodeInterfaceMode, AdeCodeProvider, ChatHistorySnapshot, CreatedChat, NavigateRequest, NavigateResult } from "./types"; export const DEFAULT_CODEX_REASONING_EFFORT = "low"; @@ -79,15 +79,20 @@ export type DefaultLaneSetupResult = { export async function runDefaultLaneSetup( connection: AdeCodeConnection, laneId: string, + options: { templateId?: string | null } = {}, ): Promise { const [templates, defaultTemplateId] = await Promise.all([ connection.action("lane", "listTemplates").catch(() => []), connection.action("lane", "getDefaultTemplate").catch(() => null), ]); - const trimmedTemplateId = typeof defaultTemplateId === "string" ? defaultTemplateId.trim() : ""; - const templateId = trimmedTemplateId && templates.some((template) => template.id === trimmedTemplateId) - ? trimmedTemplateId + const explicitTemplateId = typeof options.templateId === "string" ? options.templateId.trim() : ""; + const trimmedTemplateId = explicitTemplateId || (typeof defaultTemplateId === "string" ? defaultTemplateId.trim() : ""); + const templateId = trimmedTemplateId + ? templates.find((template) => template.id === trimmedTemplateId)?.id ?? null : null; + if (explicitTemplateId && !templateId) { + throw new Error(`Setup template "${explicitTemplateId}" was not found.`); + } const progress = templateId ? await connection.action("lane", "applyTemplate", { laneId, templateId }) : await connection.action("lane", "initEnv", { laneId }); @@ -154,17 +159,35 @@ const CHAT_BACKED_TERMINAL_TOOL_TYPES = new Set([ "droid-chat", ]); -const RESUMABLE_TERMINAL_TOOL_TYPES = new Set([ +const TRACKED_CLI_PROVIDERS = new Set([ "claude", - "claude-orchestrated", + "codex", + "cursor", + "droid", + "opencode", ]); -function isClaudeTerminalSession(session: ChatTerminalSession): boolean { +/** + * Resolve the CLI provider backing a tracked terminal session (or null when the + * session is not a provider CLI — e.g. a plain shell). Recognizes provider + * metadata, the tool-type prefix, and, as a legacy fallback, a `claude` resume + * command. Mirrors terminalSessionResumeProvider in app.tsx and + * isTerminalSessionLaunchable in remoteLauncher.ts. Callers should exclude + * CHAT_BACKED_TERMINAL_TOOL_TYPES first (a "cursor" chat vs a "cursor-cli" CLI). + */ +export function trackedCliTerminalProvider(session: ChatTerminalSession): AdeCodeProvider | null { + const metaProvider = session.resumeMetadata?.provider; + if (metaProvider && TRACKED_CLI_PROVIDERS.has(metaProvider as AdeCodeProvider)) { + return metaProvider as AdeCodeProvider; + } const toolType = session.toolType ?? ""; - if (RESUMABLE_TERMINAL_TOOL_TYPES.has(toolType)) return true; - if (session.resumeMetadata?.provider === "claude") return true; + if (toolType.startsWith("codex")) return "codex"; + if (toolType.startsWith("cursor")) return "cursor"; + if (toolType.startsWith("droid")) return "droid"; + if (toolType.startsWith("opencode")) return "opencode"; + if (toolType.startsWith("claude")) return "claude"; const resumeCommand = typeof session.resumeCommand === "string" ? session.resumeCommand.trim().toLowerCase() : ""; - return Boolean(resumeCommand && /\bclaude\b/.test(resumeCommand)); + return resumeCommand && /\bclaude\b/.test(resumeCommand) ? "claude" : null; } export async function listTerminalSessions( @@ -178,7 +201,7 @@ export async function listTerminalSessions( return sessions.filter((session) => { const toolType = session.toolType ?? ""; if (CHAT_BACKED_TERMINAL_TOOL_TYPES.has(toolType)) return false; - return isClaudeTerminalSession(session); + return trackedCliTerminalProvider(session) !== null; }); } @@ -216,8 +239,11 @@ export async function signalTerminal( await connection.action("terminal", "signal", { terminalId, signal }); } -export type StartClaudeTerminalSessionResult = { - provider: "claude"; +/** The five provider CLIs the TUI can launch as a tracked terminal session. */ +export type CliTerminalProvider = Extract; + +export type StartCliTerminalSessionResult = { + provider: string; laneId: string; title: string; permissionMode: AgentChatPermissionMode; @@ -261,25 +287,34 @@ export function normalizeChatTerminalSession( return terminalSummaryToChatSession(session); } -export async function startClaudeTerminalSession(args: { +/** + * Start a tracked provider CLI terminal via the shared `start_cli_session` + * action. The runtime owns launch-command construction (including Cursor CLI + * model-variant resolution) and title/goal derivation, so the TUI only forwards + * the picked provider/model/reasoning/permission plus the pane dimensions. + */ +export async function startCliTerminalSession(args: { connection: AdeCodeConnection; + provider: CliTerminalProvider; laneId: string; title?: string | null; model?: string | null; reasoningEffort?: string | null; + fastMode?: boolean; permissionMode?: AgentChatPermissionMode | null; initialInput?: string | null; cols: number; rows: number; -}): Promise { - const result = await args.connection.tool & { +}): Promise { + const result = await args.connection.tool & { session: ChatTerminalSession | TerminalSessionSummary | null; }>("start_cli_session", { laneId: args.laneId, - provider: "claude", + provider: args.provider, title: args.title ?? undefined, model: args.model ?? undefined, reasoningEffort: args.reasoningEffort ?? undefined, + ...(args.fastMode !== undefined ? { fastMode: args.fastMode } : {}), permissionMode: args.permissionMode ?? "default", initialInput: args.initialInput ?? undefined, cols: args.cols, @@ -413,7 +448,9 @@ export function discoverProjectSlashCommands(workspaceRoot: string): AgentChatSl export async function getAvailableModels( connection: AdeCodeConnection, provider: AgentChatProvider, + options: { interfaceMode?: AdeCodeInterfaceMode } = {}, ): Promise { + const cursorSource = options.interfaceMode === "cli" ? "cli" : "sdk"; return await connection.action("chat", "getAvailableModels", { provider, // Cursor needs a live probe for SDK/CLI service tiers. Droid is also probed @@ -422,9 +459,7 @@ export async function getAvailableModels( // Codex is intentionally NOT here: its tiers come from the app-server, which // loadAvailableModels always queries regardless of activateRuntime. activateRuntime: provider === "cursor" || provider === "droid", - // TUI chats run cursor models through the SDK; probing only that source - // keeps the picker refresh off the slower cursor-agent CLI spawn. - ...(provider === "cursor" ? { cursorSource: "sdk" } : {}), + ...(provider === "cursor" ? { cursorSource } : {}), }); } diff --git a/apps/ade-cli/src/tuiClient/aggregate.ts b/apps/ade-cli/src/tuiClient/aggregate.ts index 0febb5810..bfc17a332 100644 --- a/apps/ade-cli/src/tuiClient/aggregate.ts +++ b/apps/ade-cli/src/tuiClient/aggregate.ts @@ -455,6 +455,14 @@ function runtimeActivityFromEvent(id: string, event: AgentChatEvent): RuntimeAct return null; } +function summarizePreToolUseHookError(event: AgentChatEvent): string | null { + if (event.type !== "system_notice") return null; + if ((event as { noticeKind?: string }).noticeKind !== "hook") return null; + const message = event.message.replace(/\s+/g, " ").trim(); + const match = message.match(/^hook:\s*(PreToolUse:[^\n]+?\s+error)$/i); + return match?.[1]?.trim() ?? null; +} + function appendRuntimeActivityBlock( blocks: AggregatedBlock[], id: string, @@ -539,6 +547,7 @@ export function aggregateChatBlocks(args: { activeSession: AgentChatSessionSummary | null; expandedLineIds?: Set; maxBlocks?: number; + pendingSteers?: PendingSteer[]; }): AggregatedBlock[] { const lines = renderChatLines({ events: args.events, @@ -551,7 +560,7 @@ export function aggregateChatBlocks(args: { for (const line of lines) linesById.set(line.id, line); const blocks: AggregatedBlock[] = []; - const pendingSteerIds = new Set(derivePendingSteers(args.events).map((steer) => steer.steerId)); + const pendingSteerIds = new Set((args.pendingSteers ?? derivePendingSteers(args.events)).map((steer) => steer.steerId)); const subagentParentItemIds = new Set(); for (const envelope of args.events) { const parentItemId = subagentParentItemId(envelope.event); @@ -845,6 +854,17 @@ export function aggregateChatBlocks(args: { if (isSteerLifecycleNotice(event)) { continue; } + const hookError = summarizePreToolUseHookError(event); + if (hookError) { + appendRuntimeActivityBlock(blocks, id, turnId, { + id, + label: "hook", + detail: hookError, + status: "failed", + }); + finishTurnBlocks(blocks, turnId); + continue; + } if (event.type === "activity") { const activity = runtimeActivityFromEvent(id, event); if (activity) appendRuntimeActivityBlock(blocks, id, turnId, activity); diff --git a/apps/ade-cli/src/tuiClient/app.tsx b/apps/ade-cli/src/tuiClient/app.tsx index bc0ed48ec..30956bbfc 100644 --- a/apps/ade-cli/src/tuiClient/app.tsx +++ b/apps/ade-cli/src/tuiClient/app.tsx @@ -5,31 +5,22 @@ import os from "node:os"; import path from "node:path"; import { Box, Text, useApp, useInput } from "ink"; import { - getDefaultModelDescriptor, getModelById, - getRuntimeModelRefForDescriptor, - listModelDescriptorsForProvider, modelSupportsFastMode, resolveModelDescriptor, resolveProviderGroupForModel, - type ModelProviderGroup, } from "../../../desktop/src/shared/modelRegistry"; -import { resolveClaudeCliModelForLaunch } from "../../../desktop/src/shared/cliLaunch"; -import { CURSOR_AVAILABLE_MODE_IDS, CURSOR_MODE_LABELS } from "../../../desktop/src/shared/cursorModes"; +import { LAUNCH_PROFILE_TITLE, LAUNCH_PROFILE_TOOL_TYPE, resolveClaudeCliModelForLaunch } from "../../../desktop/src/shared/cliLaunch"; import { getAgentSkillRootCandidates } from "../../../desktop/src/shared/agentSkillRoots"; import type { - AgentChatCodexApprovalPolicy, - AgentChatCodexConfigSource, - AgentChatCodexSandbox, AgentChatClaudePlugin, AgentChatReloadClaudePluginsResult, AgentChatEventEnvelope, AgentChatFileRef, - AgentChatModelCatalog, - AgentChatModelCatalogModel, - AgentChatModelCatalogRefreshProvider, - AgentChatModelInfo, - AgentChatPermissionMode, + AgentChatModelCatalog, + AgentChatModelCatalogModel, + AgentChatModelCatalogRefreshProvider, + AgentChatModelInfo, AgentChatSession, AgentChatSessionSummary, AgentChatSlashCommand, @@ -37,8 +28,9 @@ import type { } from "../../../desktop/src/shared/types/chat"; import type { AiSettingsStatus, OpenCodeRuntimeSnapshot } from "../../../desktop/src/shared/types/config"; import type { DiffLineStats, GitConflictState } from "../../../desktop/src/shared/types/git"; -import type { LaneDeleteRisk, LaneSummary } from "../../../desktop/src/shared/types/lanes"; +import type { LaneDeleteRisk, LaneLinearIssue, LaneSummary } from "../../../desktop/src/shared/types/lanes"; import type { FeedbackPreparedDraft, FeedbackSubmission } from "../../../desktop/src/shared/types/feedback"; +import type { ProjectSecretsListResult, ProjectSecretValueResult } from "../../../desktop/src/shared/types/projectSecrets"; import type { ChatTerminalPreviewResult, ChatTerminalSession } from "../../../desktop/src/shared/types"; import { DEFAULT_CODEX_REASONING_EFFORT, @@ -91,7 +83,8 @@ import { sendToTerminalSession, signalTerminal, setClaudeOutputStyle, - startClaudeTerminalSession, + startCliTerminalSession, + type CliTerminalProvider, steerChatMessage, tagChat, unarchiveChatSession, @@ -130,9 +123,11 @@ import { } from "./newLaneForm"; import { ChatView, - computeChatScrollMaxOffset, - renderChatSelectableRowTexts, - renderChatVisibleSelectionRows, + chatScrollMaxOffsetFromSelectableRows, + hasConversationContent, + renderChatSelectableRows, + renderChatSelectableRowTextsFromRows, + renderChatVisibleSelectionRowsFromRows, selectedTextFromChatRows, type ChatVisibleSelectionRow, type ChatTextSelection, @@ -151,6 +146,45 @@ import { Header } from "./components/Header"; import { CHAT_INFO_RESUME_ROW_LINES, chatInfoSelectionOffset, computeLaneChatCounts, DETAILS_BODY_MAX_LINES, LANE_DETAIL_ACTIONS, LANE_DETAIL_PR_ACTION_INDEX, laneDetailsInteractionLayout, rightPaneScrollableRowCount, RightPane } from "./components/RightPane"; import { buildModelPickerLayout, defaultSelectionFor, railEntrySelection } from "./components/ModelPicker/modelPickerLayout"; import { modelPickerGeometry } from "./components/ModelPicker/modelPickerGeometry"; +import { buildModelPickerLayoutInput, modelPickerRefreshProvider } from "./modelPickerController"; +import { + CODEX_PRESETS, + CLAUDE_PERMISSION_OPTIONS, + DROID_PERMISSION_OPTIONS, + OPENCODE_PERMISSION_OPTIONS, + applyProviderPermissionMode, + buildSetupRows, + cliProviderForModelStateProvider, + codexApprovalSandboxLabel, + codexPresetPatch, + cursorModeIdsForState, + cursorModelAvailableForInterface, + cursorSourceForInterfaceMode, + defaultSetupSelectionIndex, + droidPermissionToLegacy, + fallbackModelStatePatch, + initialModelState, + modeAccentColor, + modeDescription, + modelCatalogRefreshCacheKey, + modelInfoSupportsFastMode, + modelReasoningEfforts, + modelStatePatchForModel, + permissionSummary, + providerModelsCacheKey, + reconcileCursorModelStateForInterface, + registryModelsForProvider, + resolveCodexPreset, + resolveCursorCliModelForLaunch, + runtimeProviderForUiProvider, +} from "./modelState"; +import { + TUI_PROVIDER_OPTIONS, + TUI_PROVIDERS, + normalizeCatalogProvider, + normalizeProvider, + providerLabel, +} from "./providerMetadata"; import { SlashPalette, slashPaletteReservedRows } from "./components/SlashPalette"; import { MentionPalette, MENTION_PALETTE_ROWS } from "./components/MentionPalette"; import { CommandPalette, COMMAND_PALETTE_ROWS, type CommandPaletteItem } from "./components/CommandPalette"; @@ -162,6 +196,21 @@ import { AddChatModeBanner } from "./components/AddChatMode"; import { theme } from "./theme"; import { resolveTuiChatRefreshTarget } from "./project"; import { chatSelectionCopyText, resolveDrawerChatSelection } from "./drawerSelection"; +import { + RIGHT_CHAT_CLOSED_TOGGLE_ID, + buildDrawerChatItems, + closedCliRightPaneRow, + deriveClosedCliSessions, + deriveOpenDrawerSessions, + drawerChatActionForItem, + isTerminalSessionResumable, + sessionFromDrawerChatItem, + sortSessionsByRecentActivity, + terminalSessionProvider, + terminalSessionToChatSummary, + type DrawerChatAction, + type DrawerChatListItem, +} from "./closedCliSessions"; import { sortLanesForStackGraph } from "./laneTree"; import { latestExpandableFailureId, renderObject, summarizeDiffChanges } from "./format"; import { startTuiHeartbeat, type TuiHeartbeat } from "./heartbeat"; @@ -169,7 +218,21 @@ import { clipboardScratchDir, isImageFilePath, latestOpenableImageTarget, readCl import { appendReservedTuiEvent, dedupeTuiEvents, reserveTuiEventDedupKey, syncTuiEventDedupKeys } from "./eventDedup"; import { advanceOlderHistoryCursor, prependOlderTuiHistory, splitSnapshotForDisplay, takeNewestChunk, TUI_LOADED_EVENT_CAP } from "./olderHistory"; import { coalesceTextDeltaEnvelopes } from "./assistantTextIdentity"; -import { loadAdeCodeState, saveAdeCodeProjectState, scopedAdeCodeState } from "./state"; +import { + EMPTY_BRACKETED_PASTE_STATE, + consumeBracketedPasteInput, + formatTerminalControlForwardedInput, + stripBracketedPasteMarkers, + type BracketedPasteState, +} from "./bracketedPaste"; +import { + codeUnitIndexForDisplayCell, + displayCellForCodeUnitIndex, + displayClusters, + splitByDisplayCells, + terminalDisplayWidth, +} from "./displayWidth"; +import { flushAdeCodeStateWrites, loadAdeCodeState, saveAdeCodeProjectState, scopedAdeCodeState } from "./state"; import { SpinTickProvider } from "./spinTick"; import { ACTIVE_SESSION_PLACEHOLDER, buildLinearToolRequest } from "./linearCommands"; import { @@ -200,6 +263,8 @@ import { import { answerForQuestion, buildPendingInputAnswers, + cancelPendingQuestionDigitSelection, + convertPendingQuestionDigitSelectionToText, createPendingQuestionSelectionState, ensurePendingQuestionSelectionState, latestPendingApproval, @@ -208,6 +273,7 @@ import { optionsForPendingQuestion, pendingQuestionAnsweredCount, pendingQuestionSelectionValue, + selectPendingQuestionDigit, selectPendingQuestionOptionIndex, setPendingQuestionOptionIndex, type PendingQuestionSelectionState, @@ -240,8 +306,10 @@ import { import type { AdeCodeConnection, AdeCodeProvider, + AdeCodeInterfaceMode, AdeCodeModelState, LocalNotice, + LaneSetupStatus, MentionSuggestion, PendingApproval, ProviderReadinessRow, @@ -254,29 +322,18 @@ import type { RuntimeMode, } from "./types"; +export { isTerminalSessionResumable } from "./closedCliSessions"; + const PURPLE = theme.color.accent; -const EFFORTS = ["low", "medium", "high", "xhigh", "max", "ultracode"]; -const PROVIDER_OPTIONS: Array<{ value: AdeCodeProvider; label: string }> = [ - { value: "claude", label: "Claude" }, - { value: "codex", label: "Codex" }, - { value: "cursor", label: "Cursor" }, - { value: "droid", label: "Droid" }, - { value: "opencode", label: "OpenCode" }, - { value: "ollama", label: "Ollama" }, - { value: "lmstudio", label: "LM Studio" }, -]; -const PROVIDERS = new Set(PROVIDER_OPTIONS.map((provider) => provider.value)); -const CODEX_PRESETS = ["default", "edit", "plan", "full-auto", "config-toml"] as const; +const EMPTY_CHAT_EVENTS: AgentChatEventEnvelope[] = []; +const EMPTY_SUBAGENT_SNAPSHOTS: SubagentSnapshot[] = []; +const EMPTY_TERMINAL_CHUNKS: string[] = []; const MODEL_CATALOG_CLIENT_REFRESH_TTL_MS = 5 * 60_000; const MODEL_CATALOG_LOCAL_CLIENT_REFRESH_TTL_MS = 30_000; -const CLAUDE_PERMISSION_OPTIONS = ["default", "auto", "plan", "acceptEdits", "bypassPermissions"] as const; -const OPENCODE_PERMISSION_OPTIONS = ["plan", "edit", "full-auto", "config-toml"] as const; -const DROID_PERMISSION_OPTIONS = ["read-only", "auto-low", "auto-medium", "auto-high"] as const; type PaneFocus = "drawer" | "chat" | "details" | "addMode"; type AddModeState = { cursorLaneId: string; cursorChatId: string | null }; export type FooterControl = "drawer" | "details" | "agents"; type DrawerLaneAction = "new-lane"; -type DrawerChatAction = "new-chat"; // Streaming chat events are coalesced into a single React render per frame // (~40fps) instead of one render per token. Lifecycle edges (turn start/stop, @@ -287,6 +344,7 @@ type DrawerChatAction = "new-chat"; // — fewer, larger renders means far less per-token transcript re-wrap/flicker, // while staying well within smooth-streaming range for text. const CHAT_EVENT_FLUSH_MS = 48; +export const BACKGROUND_REFRESH_DEBOUNCE_MS = 200; const PTY_ATTACHED_FLUSH_MS = 16; const PTY_PREVIEW_FLUSH_MS = 32; const TERMINAL_PREVIEW_POLL_MS = 1_000; @@ -683,15 +741,6 @@ function openExternalUrl(url: string, notice: (message: string, tone?: LocalNoti return true; } -export function isTerminalSessionResumable(session: ChatTerminalSession | null | undefined): boolean { - return Boolean( - session - && session.status !== "running" - && terminalSessionResumeProvider(session) - && (session.resumeMetadata || session.resumeCommand), - ); -} - export function shouldToggleLatestFailedLineOnBlankEnter(args: { pane: PaneFocus; prompt: string; @@ -710,26 +759,8 @@ export function shouldToggleLatestFailedLineOnBlankEnter(args: { && !isTerminalSessionResumable(args.activeTerminalSession); } -function terminalSessionToChatSummary(session: ChatTerminalSession): AgentChatSessionSummary { - const status: AgentChatSessionSummary["status"] = session.status === "running" - ? session.runtimeState === "idle" ? "idle" : "active" - : "ended"; - return { - sessionId: session.terminalId, - laneId: session.laneId, - provider: "claude", - model: "claude-code", - title: session.title, - goal: session.goal, - permissionMode: session.resumeMetadata?.launch?.permissionMode ?? "default", - status, - startedAt: session.startedAt, - endedAt: session.endedAt, - lastActivityAt: session.endedAt ?? session.startedAt, - lastOutputPreview: session.lastOutputPreview, - summary: session.summary, - surface: "work", - }; +function openChatRightPaneRow(session: AgentChatSessionSummary, activeSessionId: string | null): string { + return `${session.sessionId === activeSessionId ? "●" : "○"} ${session.title ?? session.sessionId} · ${session.provider}`; } export function chatSessionToOptimisticSummary( @@ -858,51 +889,6 @@ export function shouldHydrateRefreshHistory(args: { || args.loadedSessionId !== args.nextSessionId; } -function initialModelState(): AdeCodeModelState { - const descriptor = getDefaultModelDescriptor("codex"); - return { - provider: "codex", - model: descriptor?.providerModelId ?? "gpt-5.5", - modelId: descriptor?.id ?? null, - displayName: descriptor?.displayName ?? "GPT-5.5", - reasoningEffort: DEFAULT_CODEX_REASONING_EFFORT, - fastMode: false, - permissionMode: "default", - interactionMode: "default", - claudePermissionMode: "default", - codexApprovalPolicy: "on-request", - codexSandbox: "workspace-write", - codexConfigSource: "flags", - opencodePermissionMode: "edit", - droidPermissionMode: "auto-low", - cursorModeId: "agent", - cursorAvailableModeIds: [], - cursorConfigValues: {}, - }; -} - -type CodexPreset = (typeof CODEX_PRESETS)[number]; - -function providerLabel(provider: AdeCodeProvider): string { - return PROVIDER_OPTIONS.find((entry) => entry.value === provider)?.label ?? provider; -} - -function normalizeProvider(value: string | null | undefined): AdeCodeProvider { - return PROVIDERS.has(value as AdeCodeProvider) ? value as AdeCodeProvider : "codex"; -} - -export function normalizeCatalogProvider(value: string | null | undefined): AdeCodeProvider { - const normalized = (value ?? "").trim().toLowerCase(); - if (normalized === "anthropic") return "claude"; - if (normalized === "openai") return "codex"; - if (normalized === "factory") return "droid"; - return normalizeProvider(normalized); -} - -function runtimeProviderForUiProvider(provider: AdeCodeProvider): ModelProviderGroup { - return provider === "ollama" || provider === "lmstudio" ? "opencode" : provider; -} - function claudeModelCommandKey(state: AdeCodeModelState, terminalId: string | null | undefined): string { return JSON.stringify([ terminalId ?? null, @@ -918,231 +904,6 @@ function modelCatalogClientRefreshTtlMs(provider?: AgentChatModelCatalogRefreshP : MODEL_CATALOG_CLIENT_REFRESH_TTL_MS; } -function firstReasoningEffortForModel(model: AgentChatModelInfo | null | undefined, provider: AdeCodeProvider): string | null { - const efforts = model?.reasoningEfforts?.map((entry) => entry.effort).filter(Boolean) ?? []; - const modelId = `${model?.modelId ?? ""} ${model?.id ?? ""} ${model?.displayName ?? ""}`.toLowerCase(); - if (modelId.includes("fable") && efforts.includes("high")) return "high"; - if (efforts.includes(DEFAULT_CODEX_REASONING_EFFORT)) return DEFAULT_CODEX_REASONING_EFFORT; - if (efforts.length) return efforts[0] ?? null; - const descriptor = model?.modelId || model?.id ? getModelById(model.modelId ?? model.id) : undefined; - const descriptorEfforts = descriptor?.reasoningTiers ?? []; - const descriptorId = `${descriptor?.id ?? ""} ${descriptor?.providerModelId ?? ""} ${descriptor?.displayName ?? ""}`.toLowerCase(); - if (descriptorId.includes("fable") && descriptorEfforts.includes("high")) return "high"; - if (descriptorEfforts.includes(DEFAULT_CODEX_REASONING_EFFORT)) return DEFAULT_CODEX_REASONING_EFFORT; - if (descriptorEfforts.length) return descriptorEfforts[0] ?? null; - return provider === "codex" ? DEFAULT_CODEX_REASONING_EFFORT : null; -} - -function modelStatePatchForModel(provider: AdeCodeProvider, model: AgentChatModelInfo): Pick { - const modelId = model.modelId ?? model.id; - const descriptor = getModelById(modelId); - const resolvedProvider = descriptor ? normalizeProvider(resolveProviderGroupForModel(descriptor)) : provider; - const runtimeProvider = runtimeProviderForUiProvider(resolvedProvider); - return { - provider: resolvedProvider, - model: descriptor ? getRuntimeModelRefForDescriptor(descriptor, runtimeProvider) : model.id, - modelId, - displayName: model.displayName, - reasoningEffort: firstReasoningEffortForModel(model, resolvedProvider), - }; -} - -function modelInfoSupportsFastMode(model: AgentChatModelInfo | null | undefined): boolean { - const descriptor = model?.modelId || model?.id ? getModelById(model.modelId ?? model.id) : undefined; - return Boolean(model?.serviceTiers?.some((tier) => tier.trim().toLowerCase() === "fast")) - || modelSupportsFastMode(descriptor); -} - -function fallbackModelStatePatch(provider: AdeCodeProvider): Pick { - const registryProvider = provider === "ollama" || provider === "lmstudio" ? "opencode" : provider; - const descriptor = getDefaultModelDescriptor(registryProvider) - ?? listModelDescriptorsForProvider(registryProvider)[0] - ?? getDefaultModelDescriptor("codex"); - return { - provider, - model: descriptor ? getRuntimeModelRefForDescriptor(descriptor, registryProvider) : "gpt-5.5", - modelId: descriptor?.id ?? null, - displayName: descriptor?.displayName ?? providerLabel(provider), - reasoningEffort: descriptor?.id.includes("fable") && descriptor.reasoningTiers?.includes("high") - ? "high" - : descriptor?.reasoningTiers?.[0] ?? (provider === "codex" ? DEFAULT_CODEX_REASONING_EFFORT : null), - }; -} - -function registryModelsForProvider(provider: AdeCodeProvider): AgentChatModelInfo[] { - if (provider === "ollama" || provider === "lmstudio") return []; - return listModelDescriptorsForProvider(provider).map((descriptor) => ({ - id: descriptor.id, - modelId: descriptor.id, - displayName: descriptor.displayName, - isDefault: descriptor.id === getDefaultModelDescriptor(provider)?.id, - reasoningEfforts: descriptor.reasoningTiers?.map((effort) => ({ effort, description: effort })), - ...(descriptor.serviceTiers?.length ? { serviceTiers: descriptor.serviceTiers } : {}), - })); -} - -function modelReasoningEfforts(modelState: AdeCodeModelState, models: AgentChatModelInfo[]): string[] { - const model = models.find((entry) => entry.id === modelState.modelId || entry.modelId === modelState.modelId); - const fromModel = model?.reasoningEfforts?.map((entry) => entry.effort).filter(Boolean) ?? []; - if (fromModel.length) return fromModel; - const descriptor = modelState.modelId ? getModelById(modelState.modelId) : undefined; - if (descriptor?.reasoningTiers?.length) return descriptor.reasoningTiers; - return modelState.provider === "codex" ? EFFORTS : []; -} - -function resolveCodexPreset(modelState: AdeCodeModelState): CodexPreset | "custom" { - if (modelState.codexConfigSource === "config-toml") return "config-toml"; - if (modelState.codexApprovalPolicy === "never" && modelState.codexSandbox === "danger-full-access") return "full-auto"; - if (modelState.codexApprovalPolicy === "untrusted" && modelState.codexSandbox === "workspace-write") return "edit"; - if ( - (modelState.codexApprovalPolicy === "on-request" || modelState.codexApprovalPolicy === "untrusted") - && modelState.codexSandbox === "read-only" - ) return "plan"; - if ( - (modelState.codexApprovalPolicy === "on-request" || modelState.codexApprovalPolicy === "on-failure" || modelState.codexApprovalPolicy === "untrusted") - && modelState.codexSandbox === "workspace-write" - ) return "default"; - return "custom"; -} - -export function codexApprovalSandboxLabel(modelState: Pick): string { - return `${modelState.codexApprovalPolicy} · ${modelState.codexSandbox}`; -} - -function codexPresetPatch(preset: CodexPreset): Pick { - if (preset === "full-auto") { - return { - codexApprovalPolicy: "never", - codexSandbox: "danger-full-access", - codexConfigSource: "flags", - permissionMode: "full-auto", - }; - } - if (preset === "plan") { - return { - codexApprovalPolicy: "on-request", - codexSandbox: "read-only", - codexConfigSource: "flags", - permissionMode: "plan", - }; - } - if (preset === "edit") { - return { - codexApprovalPolicy: "untrusted", - codexSandbox: "workspace-write", - codexConfigSource: "flags", - permissionMode: "edit", - }; - } - if (preset === "config-toml") { - return { - codexApprovalPolicy: "on-request", - codexSandbox: "workspace-write", - codexConfigSource: "config-toml", - permissionMode: "config-toml", - }; - } - return { - codexApprovalPolicy: "on-request", - codexSandbox: "workspace-write", - codexConfigSource: "flags", - permissionMode: "default", - }; -} - -function droidPermissionToLegacy(mode: AdeCodeModelState["droidPermissionMode"]): AgentChatPermissionMode { - if (mode === "read-only") return "plan"; - if (mode === "auto-low") return "edit"; - if (mode === "auto-medium") return "default"; - return "full-auto"; -} - -function cursorModeLabel(modeId: string | null | undefined): string { - const normalized = modeId?.trim().toLowerCase() || "agent"; - return CURSOR_MODE_LABELS[normalized] ?? normalized; -} - -export function cursorModeIdsForState(modelState: Pick): string[] { - const snapshotIds = modelState.cursorAvailableModeIds - .map((modeId) => modeId.trim()) - .filter(Boolean); - return snapshotIds.length ? snapshotIds : [...CURSOR_AVAILABLE_MODE_IDS]; -} - -function permissionSummary(modelState: AdeCodeModelState): string { - if (modelState.provider === "codex") return resolveCodexPreset(modelState); - if (modelState.provider === "claude") { - if (modelState.interactionMode === "plan" || modelState.claudePermissionMode === "plan") return "plan"; - if (modelState.claudePermissionMode === "auto") return "auto"; - if (modelState.claudePermissionMode === "acceptEdits") return "accept edits"; - if (modelState.claudePermissionMode === "bypassPermissions") return "bypass"; - return "default"; - } - if (modelState.provider === "opencode") return modelState.opencodePermissionMode; - if (modelState.provider === "droid") return modelState.droidPermissionMode; - return cursorModeLabel(modelState.cursorModeId); -} - -const MODE_DESCRIPTIONS: Record = { - plan: "read-only deliberation", - default: "ask before acting", - auto: "auto-approve safe actions", - "accept edits": "auto-approve file edits", - bypass: "skip permission checks", - "full-auto": "no approvals required", - edit: "edit-mode operations", - agent: "agent-driven actions", - ask: "confirm each action", - "read-only": "no edits or execution", - "auto-low": "low-autonomy ops", - "auto-medium": "medium-autonomy ops", - "auto-high": "high-autonomy ops", - "config-toml": "config-defined mode", -}; - -function modeDescription(summary: string): string { - return MODE_DESCRIPTIONS[summary] ?? "permission mode"; -} - -function modeAccentColor(summary: string): string { - if (summary === "plan" || summary === "read-only") return theme.color.planMode; - if (summary === "bypass" || summary === "full-auto" || summary === "auto-high") return theme.color.warning; - return theme.color.accent; -} - -function permissionOptionsDetail(modelState: AdeCodeModelState): string { - if (modelState.provider === "codex") return CODEX_PRESETS.join(" · "); - if (modelState.provider === "claude") return "default · plan · auto · bypass"; - if (modelState.provider === "opencode") return OPENCODE_PERMISSION_OPTIONS.join(" · "); - if (modelState.provider === "droid") return DROID_PERMISSION_OPTIONS.join(" · "); - return cursorModeIdsForState(modelState).map((modeId) => cursorModeLabel(modeId)).join(" · "); -} - -function applyProviderPermissionMode(modelState: AdeCodeModelState): Partial { - if (modelState.provider === "codex") { - const preset = resolveCodexPreset(modelState); - return { permissionMode: preset === "custom" ? modelState.permissionMode : preset }; - } - if (modelState.provider === "claude") { - if (modelState.interactionMode === "plan" || modelState.claudePermissionMode === "plan") { - return { permissionMode: "plan", interactionMode: "plan", claudePermissionMode: "plan" }; - } - if (modelState.claudePermissionMode === "auto") return { permissionMode: "auto", interactionMode: "default" }; - if (modelState.claudePermissionMode === "acceptEdits") return { permissionMode: "edit", interactionMode: "default" }; - if (modelState.claudePermissionMode === "bypassPermissions") return { permissionMode: "full-auto", interactionMode: "default" }; - return { permissionMode: "default", interactionMode: "default" }; - } - if (modelState.provider === "opencode") return { permissionMode: modelState.opencodePermissionMode }; - if (modelState.provider === "droid") return { permissionMode: droidPermissionToLegacy(modelState.droidPermissionMode) }; - if (modelState.provider === "cursor") { - if (modelState.cursorModeId === "plan") return { permissionMode: "plan" }; - if (modelState.cursorModeId === "ask") return { permissionMode: "edit" }; - if (modelState.cursorModeId === "full-auto") return { permissionMode: "full-auto" }; - return { permissionMode: "default" }; - } - return {}; -} - function noticeId(): string { return `${Date.now()}:${Math.random().toString(36).slice(2)}`; } @@ -1219,7 +980,7 @@ function formatGoalBannerLine(goal: CodexThreadGoal | null): string | null { return right.length ? `◎ ${objective} ${right.join(" · ")}` : `◎ ${objective}`; } -import { subagentSnapshotsFromEvents } from "../../../desktop/src/shared/chatSubagents"; +import { subagentActivitySummaryFromEvents, subagentSnapshotsFromEvents } from "../../../desktop/src/shared/chatSubagents"; export { subagentSnapshotsFromEvents }; const LANE_WORKTREE_AVAILABILITY_CACHE_TTL_MS = 2_000; @@ -1351,6 +1112,77 @@ function seedLaneDetails( }; } +function normalizeLaneLinearIssue(value: unknown): LaneLinearIssue | null { + if (!value || typeof value !== "object" || Array.isArray(value)) return null; + const record = value as Record; + if (typeof record.id !== "string" || typeof record.identifier !== "string" || typeof record.title !== "string") { + return null; + } + return record as LaneLinearIssue; +} + +function exactLinearIssueSearchMatch(input: string, result: unknown): LaneLinearIssue | null { + const normalized = input.trim().toLowerCase(); + const candidates = Array.isArray(result) + ? result + : result && typeof result === "object" && Array.isArray((result as { issues?: unknown[] }).issues) + ? (result as { issues: unknown[] }).issues + : []; + const issues = candidates + .map(normalizeLaneLinearIssue) + .filter((issue): issue is LaneLinearIssue => issue !== null); + return issues.find((issue) => ( + issue.id.toLowerCase() === normalized + || issue.identifier.toLowerCase() === normalized + )) ?? null; +} + +async function resolveLinearIssueForNewLane( + conn: AdeCodeConnection, + input: string, +): Promise { + const trimmed = input.trim(); + if (!trimmed) return null; + const result = await conn.action("cto", "searchLinearIssues", { query: trimmed, first: 5 }); + return exactLinearIssueSearchMatch(trimmed, result); +} + +function authErrorTextFromEvent(event: AgentChatEventEnvelope["event"]): string | null { + const record = event as Record; + if (event.type === "error") { + return typeof record.message === "string" + ? record.message + : typeof record.error === "string" + ? record.error + : null; + } + if (event.type === "system_notice" || event.type === "status") { + return typeof record.message === "string" ? record.message : null; + } + return null; +} + +export function latestAuthFailedPrompt(events: readonly AgentChatEventEnvelope[]): string | null { + let lastUserIndex = -1; + for (let index = events.length - 1; index >= 0; index -= 1) { + if (events[index]?.event.type === "user_message") { + lastUserIndex = index; + break; + } + } + if (lastUserIndex < 0) return null; + const userEvent = events[lastUserIndex]!.event as Extract; + const prompt = (userEvent.displayText ?? userEvent.text ?? "").trim(); + if (!prompt) return null; + const tail = events.slice(lastUserIndex + 1); + const authFailure = tail.some((envelope) => { + const text = authErrorTextFromEvent(envelope.event); + if (!text) return false; + return /\b(auth|authentication|login|log in|api key|unauthorized|401|not authenticated|expired|invalid key)\b/i.test(text); + }); + return authFailure ? prompt : null; +} + function deriveDrawerPreviewChatInfo( session: AgentChatSessionSummary, previewEvents: AgentChatEventEnvelope[], @@ -1681,110 +1513,6 @@ function formatDoctorReport(args: { ].join("\n"); } -function buildSetupRows(args: { - modelState: AdeCodeModelState; - models: AgentChatModelInfo[]; - includeRefresh: boolean; - includeApply: boolean; - outputStyle?: string | null; - outputStyleEditable?: boolean; -}): SetupPaneRow[] { - const efforts = modelReasoningEfforts(args.modelState, args.models); - const descriptor = args.modelState.modelId ? getModelById(args.modelState.modelId) : undefined; - const activeModel = args.models.find((entry) => entry.id === args.modelState.modelId || entry.modelId === args.modelState.modelId); - const fastSupported = - Boolean(activeModel?.serviceTiers?.some((tier) => tier.trim().toLowerCase() === "fast")) - || modelSupportsFastMode(descriptor); - const rows: SetupPaneRow[] = [ - { - kind: "provider", - label: "Provider", - value: providerLabel(args.modelState.provider), - cyclable: true, - }, - { - kind: "model", - label: "Model", - value: args.modelState.displayName, - detail: args.models.length ? `${args.models.length} available` : "using registry default", - cyclable: true, - }, - { - kind: "reasoning", - label: "Reasoning", - value: args.modelState.reasoningEffort ?? "none", - detail: efforts.length ? efforts.join(", ") : "not exposed by this model", - disabled: !efforts.length, - cyclable: true, - }, - { - kind: "permission", - label: "Permissions", - value: permissionSummary(args.modelState), - detail: permissionOptionsDetail(args.modelState), - cyclable: true, - }, - ]; - rows.push({ - kind: "codex-fast", - label: "Fast mode", - value: fastSupported && args.modelState.fastMode ? "on" : "off", - detail: "on · off", - disabled: !fastSupported, - cyclable: true, - }); - if (args.modelState.provider === "claude") { - rows.push({ - kind: "output-style", - label: "Output style", - value: args.outputStyle?.trim() || "default", - detail: args.outputStyleEditable === false - ? "active Claude chat only" - : "default · concise · verbose", - disabled: args.outputStyleEditable === false, - cyclable: true, - }); - } - if (args.includeRefresh) { - rows.push({ - kind: "refresh-status", - label: "Refresh status", - value: "run", - detail: "checks provider auth/runtime state", - }); - } - if (args.includeApply) { - rows.push({ - kind: "apply", - label: "Confirm", - value: "ready", - detail: "returns focus to the chat composer", - }); - } - return rows; -} - -function defaultSetupSelectionIndex(rows: SetupPaneRow[]): number { - const applyIndex = rows.findIndex((row) => row.kind === "apply"); - return applyIndex >= 0 ? applyIndex : 0; -} - -function defaultModelPickerSelectionIndex(rows: SetupPaneRow[]): number { - const modelIndex = rows.findIndex((row) => row.kind === "model" && !row.disabled); - if (modelIndex >= 0) return modelIndex; - const reasoningIndex = rows.findIndex((row) => row.kind === "reasoning" && !row.disabled); - if (reasoningIndex >= 0) return reasoningIndex; - return defaultSetupSelectionIndex(rows); -} - -function setupSelectionIndexForKind(rows: SetupPaneRow[], preferredKind: SetupPaneRowKind | null | undefined): number { - if (preferredKind) { - const preferredIndex = rows.findIndex((row) => row.kind === preferredKind); - if (preferredIndex >= 0) return preferredIndex; - } - return defaultModelPickerSelectionIndex(rows); -} - type ConnectionStatusProvider = Extract; function providerConnectionDetail(status: AiSettingsStatus | null, provider: ConnectionStatusProvider): ProviderReadinessRow { @@ -1920,7 +1648,7 @@ function printableInput(input: string): string { } function printablePromptInput(input: string): string { - return input + return stripBracketedPasteMarkers(input) .replace(/\r\n/g, "\n") .replace(/\r/g, "\n") .replace(/[\u0000-\u0009\u000b-\u001f\u007f]/g, ""); @@ -2109,6 +1837,15 @@ type PromptDisplayRow = PromptVisualRow & { cursorColumn: number | null; }; +type BackgroundLaunchStatus = { + id: number; + laneId: string; + laneName: string; + prompt: string; + status: "running" | "failed"; + error?: string; +}; + export function clampPromptCursor(value: string, cursor: number | null | undefined): number { if (!Number.isFinite(cursor ?? Number.NaN)) return value.length; return Math.max(0, Math.min(value.length, Math.floor(cursor ?? value.length))); @@ -2119,23 +1856,23 @@ function buildPromptVisualRows(value: string, width: number): PromptVisualRow[] const rows: PromptVisualRow[] = []; let start = 0; let text = ""; - for (let index = 0; index < value.length;) { - const char = [...value.slice(index)].at(0) ?? ""; - const nextIndex = index + char.length; - if (char === "\n") { - rows.push({ text, start, end: index }); - start = nextIndex; + let textWidth = 0; + for (const cluster of displayClusters(value)) { + if (cluster.text === "\n") { + rows.push({ text, start, end: cluster.start }); + start = cluster.end; text = ""; - index = nextIndex; + textWidth = 0; continue; } - if ([...text].length >= safeWidth) { - rows.push({ text, start, end: index }); - start = index; + if (text && textWidth + cluster.width > safeWidth) { + rows.push({ text, start, end: cluster.start }); + start = cluster.start; text = ""; + textWidth = 0; } - text += char; - index = nextIndex; + text += cluster.text; + textWidth += cluster.width; } if (text.length > 0) rows.push({ text, start, end: value.length }); if (!rows.length || (start === value.length && text.length === 0)) { @@ -2166,7 +1903,7 @@ export function promptDisplayRowsWithCursor( value.length > 0 && lastRow && lastRow.end === value.length - && lastRow.text.length >= safeWidth + && terminalDisplayWidth(lastRow.text) >= safeWidth ) { allRows.push({ text: "", start: value.length, end: value.length }); } @@ -2177,7 +1914,7 @@ export function promptDisplayRowsWithCursor( const visibleRows = allRows.slice(start, start + visibleCount); const visibleCursorRow = Math.max(0, cursorRowIndex - start); const cursorRow = visibleRows[visibleCursorRow] ?? visibleRows[visibleRows.length - 1] ?? { text: "", start: safeCursor, end: safeCursor }; - const cursorColumn = Math.max(0, Math.min([...cursorRow.text].length, [...value.slice(cursorRow.start, safeCursor)].length)); + const cursorColumn = displayCellForCodeUnitIndex(cursorRow.text, safeCursor - cursorRow.start); return { rows: visibleRows.map((row, index) => ({ ...row, @@ -2200,9 +1937,9 @@ export function movePromptCursorVertical(value: string, width: number, cursor: n if (!row) return safeCursor; const target = rows[rowIndex + delta]; if (!target) return safeCursor; - const column = Math.max(0, Math.min([...row.text].length, [...value.slice(row.start, safeCursor)].length)); - const targetPrefix = [...target.text].slice(0, column).join(""); - return Math.max(target.start, Math.min(target.end, target.start + targetPrefix.length)); + const column = displayCellForCodeUnitIndex(row.text, safeCursor - row.start); + const targetOffset = codeUnitIndexForDisplayCell(target.text, column); + return Math.max(target.start, Math.min(target.end, target.start + targetOffset)); } export function isPromptCursorOnFirstVisualRow(value: string, width: number, cursor: number): boolean { @@ -2334,6 +2071,16 @@ function useTerminalAlternateScreen(): void { }, []); } +function useTerminalBracketedPaste(): void { + useEffect(() => { + if (!process.stdin.isTTY || !process.stdout.isTTY) return; + process.stdout.write(terminalBracketedPasteEnableSequence()); + return () => { + process.stdout.write(terminalBracketedPasteDisableSequence()); + }; + }, []); +} + type TerminalMouseInput = { kind: "wheel" | "click" | "drag" | "release" | "move" | "other"; x: number | null; @@ -2572,8 +2319,16 @@ export function terminalAlternateScreenDisableSequence(): string { return "\x1b[?1049l"; } +export function terminalBracketedPasteEnableSequence(): string { + return "\x1b[?2004h"; +} + +export function terminalBracketedPasteDisableSequence(): string { + return "\x1b[?2004l"; +} + export function terminalInteractiveRestoreSequence(): string { - return `${terminalMouseTrackingDisableSequence()}${terminalAlternateScrollDisableSequence()}${terminalAlternateScreenDisableSequence()}`; + return `${terminalMouseTrackingDisableSequence()}${terminalAlternateScrollDisableSequence()}${terminalBracketedPasteDisableSequence()}${terminalAlternateScreenDisableSequence()}`; } function disableTerminalMouseTracking(): void { @@ -2718,7 +2473,17 @@ export function isClaudePlaceholderTitle(title: string | null | undefined): bool export function splitTerminalControlInput(raw: string): { detach: boolean; forwarded: string } { const forwarded = raw.replace(/[\x14\x1d]/g, ""); - return { detach: forwarded.length !== raw.length, forwarded }; + return { + detach: forwarded.length !== raw.length, + forwarded: formatTerminalControlForwardedInput(forwarded), + }; +} + +export function terminalControlInputAction( + input: string, + key: { ctrl?: boolean; meta?: boolean }, +): "detach" | "ignore" { + return input === "\x1d" || isTerminalControlToggle(input, key) ? "detach" : "ignore"; } function claudeTerminalRowsForPane(rows: number): number { @@ -2729,22 +2494,6 @@ function claudeTerminalRowsForPane(rows: number): number { ); } -function terminalSessionProvider(session: ChatTerminalSession | null | undefined): AdeCodeProvider | null { - return terminalSessionResumeProvider(session) ?? (session ? "claude" : null); -} - -function terminalSessionResumeProvider(session: ChatTerminalSession | null | undefined): AdeCodeProvider | null { - const provider = session?.resumeMetadata?.provider ?? null; - if (provider && PROVIDERS.has(provider as AdeCodeProvider)) return provider as AdeCodeProvider; - const toolType = session?.toolType ?? ""; - if (toolType.startsWith("codex")) return "codex"; - if (toolType.startsWith("cursor")) return "cursor"; - if (toolType.startsWith("droid")) return "droid"; - if (toolType.startsWith("opencode")) return "opencode"; - if (toolType.startsWith("claude")) return "claude"; - return null; -} - export function promptTextForTerminal(text: string, attachments: AgentChatFileRef[]): string { const attachmentPaths = attachments.map((attachment) => attachment.path).filter(Boolean); if (!attachmentPaths.length) return text; @@ -2838,6 +2587,9 @@ function modelInfoFromDescriptor(modelRef: string): AgentChatModelInfo | null { displayName: descriptor.displayName, isDefault: false, reasoningEfforts: descriptor.reasoningTiers?.map((effort) => ({ effort, description: effort })), + ...(descriptor.serviceTiers?.length ? { serviceTiers: descriptor.serviceTiers } : {}), + ...(descriptor.cursorAvailability ? { cursorAvailability: descriptor.cursorAvailability } : {}), + ...(descriptor.cursorCliVariants?.length ? { cursorCliVariants: descriptor.cursorCliVariants } : {}), }; } @@ -2900,6 +2652,7 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath, const rows = stableInkViewportRows(terminalRows); useTerminalAlternateScreen(); useTerminalAlternateScroll(); + useTerminalBracketedPaste(); useTerminalMouseTracking(); useTerminalProcessRestore(); const [connection, setConnection] = useState(null); @@ -2940,6 +2693,9 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath, maxScrollable: 0, visibleText: "", }); + const handleTerminalViewportMetrics = useCallback((metrics: { maxScrollable: number; visibleText: string }) => { + terminalViewportMetricsRef.current = metrics; + }, []); const [activeLaneId, setActiveLaneId] = useState(null); const [activeSessionId, setActiveSessionId] = useState(project.sessionHint); const [events, setEvents] = useState([]); @@ -2947,7 +2703,12 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath, const [slashCommands, setSlashCommands] = useState([]); const [keybindings, setKeybindings] = useState(() => readClaudeKeybindingsFile({ create: false }).bindings); const [models, setModels] = useState([]); - const [modelState, setModelState] = useState(initialModelState); + const [initialAdeCodeState] = useState(() => ( + remoteLaunch + ? { lastChatByLane: {}, lastLaneId: null, draftKind: "chat" as AdeCodeInterfaceMode } + : scopedAdeCodeState(loadAdeCodeState(), project.projectRoot) + )); + const [modelState, setModelState] = useState(() => initialModelState(initialAdeCodeState.draftKind)); const [modeChangeNotice, setModeChangeNotice] = useState<{ summary: string; key: string } | null>(null); const lastPermissionSummaryRef = useRef(null); const modeNoticeTimerRef = useRef(null); @@ -2963,6 +2724,7 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath, const [storedApiKeyProviders, setStoredApiKeyProviders] = useState([]); const [openCodeDiagnostics, setOpenCodeDiagnostics] = useState(null); const [rightPane, setRightPane] = useState({ kind: "empty" }); + const [laneSetupStatusByLaneId, setLaneSetupStatusByLaneId] = useState>({}); // Measured (1-based) content origin of the model picker, reported by // ModelPickerPane via Ink/Yoga so the click hit-test maps to where rows // actually paint — robust to window size, no hardcoded offset. Null until the @@ -2976,11 +2738,13 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath, const [formValues, setFormValues] = useState>({}); const [formFieldIndex, setFormFieldIndex] = useState(0); const [rightSelectionIndex, setRightSelectionIndex] = useState(0); + const [rightChatsClosedExpanded, setRightChatsClosedExpanded] = useState(false); const [drawerOpen, setDrawerOpen] = useState(false); const [rightOpen, setRightOpen] = useState(false); const [activePane, setActivePane] = useState("chat"); const [prompt, setPrompt] = useState(""); const [promptCursor, setPromptCursor] = useState(0); + const [backgroundLaunchStatus, setBackgroundLaunchStatus] = useState(null); const [error, setError] = useState(null); const [contextPercent, setContextPercent] = useState(null); const [tokenSummary, setTokenSummary] = useState(null); @@ -3016,6 +2780,7 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath, // keyed by subagent id so a stale fetch never bleeds into a different agent. Null // ⇒ fall back to the locally-reconstructed transcript. const [realSubagentTranscript, setRealSubagentTranscript] = useState<{ id: string; status: SubagentSnapshot["status"]; envelopes: AgentChatEventEnvelope[] } | null>(null); + const unavailableSubagentTranscriptKeysRef = useRef>(new Set()); const [mentionSuggestions, setMentionSuggestions] = useState([]); const [mentionIndex, setMentionIndex] = useState(0); const [selectedMentions, setSelectedMentions] = useState([]); @@ -3031,6 +2796,7 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath, const [helpRecents, setHelpRecents] = useState([]); const helpRecentsRef = useRef([]); helpRecentsRef.current = helpRecents; + const rightChatsQueryRef = useRef(""); // Indexed (grouped, keybind-enriched) command reference. Rebuilt only when the // user's Claude keybinding registry changes, so keybind chips reflect config. const helpIndexGroups = useMemo(() => buildHelpIndex(BUILTIN_COMMANDS, keybindings), [keybindings]); @@ -3038,6 +2804,7 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath, const [drawerPreviewSessionId, setDrawerPreviewSessionId] = useState(null); const [drawerPreviewEvents, setDrawerPreviewEvents] = useState([]); const [drawerLaneId, setDrawerLaneId] = useState(null); + const [drawerClosedCliExpandedLaneIds, setDrawerClosedCliExpandedLaneIds] = useState>(() => new Set()); const [selectedDrawerLaneId, setSelectedDrawerLaneId] = useState(null); const [selectedDrawerChatId, setSelectedDrawerChatId] = useState(null); const [selectedDrawerLaneAction, setSelectedDrawerLaneAction] = useState(null); @@ -3098,6 +2865,9 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath, }, []); const promptRef = useRef(""); const promptCursorRef = useRef(0); + const bracketedPasteStateRef = useRef(EMPTY_BRACKETED_PASTE_STATE); + const submitRightFormInFlightRef = useRef(false); + const backgroundLaunchSeqRef = useRef(0); const previousPromptValueRef = useRef(""); const promptHistoryRef = useRef([]); const promptHistoryIndexRef = useRef(null); @@ -3109,6 +2879,7 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath, // the CURRENT pane without stale-closure state (see showChatInfoAfterDraftCommit). const rightPaneRef = useRef({ kind: "empty" }); const lastLocalSendAtRef = useRef(0); + const eventsRef = useRef([]); const eventCountRef = useRef(0); const eventDedupKeysRef = useRef>(new Set()); const eventDedupKeyOrderRef = useRef([]); @@ -3116,6 +2887,9 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath, // batched render on a short timer (see flushPendingChatEvents / scheduleChatFlush). const pendingChatEnvelopesRef = useRef([]); const chatFlushTimerRef = useRef | null>(null); + const backgroundRefreshTimerRef = useRef | null>(null); + const backgroundRefreshInFlightRef = useRef(false); + const backgroundRefreshPendingAfterInFlightRef = useRef(false); const refreshGenerationRef = useRef(0); const chatScrollOffsetRowsRef = useRef(0); const chatScrollMaxOffsetRef = useRef(0); @@ -3127,13 +2901,9 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath, const draftSeededFromHistoryRef = useRef(false); const initialNewChatPreviewRef = useRef(true); const attachProbeInFlightRef = useRef(false); - 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 draftKindRef = useRef(initialAdeCodeState.draftKind); const lastChatByLaneWriteTimerRef = useRef(null); const pendingNewChatTitleRef = useRef(null); const lastUserOpenedPaneRef = useRef(null); @@ -3157,10 +2927,11 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath, const claudeTerminalSubmitQueueRef = useRef>(Promise.resolve()); const lastModelPickerClaudeSentKeyRef = useRef(null); const exitRequestedRef = useRef(false); - const modelStateRef = useRef(initialModelState()); + const modelStateRef = useRef(initialModelState(initialAdeCodeState.draftKind)); const chatMouseSelectionRef = useRef(null); const chatSelectionAnchorRef = useRef(null); - const selectableChatRowTextsRef = useRef([]); + const selectableChatRowCountRef = useRef(0); + const selectableChatRowTextBuilderRef = useRef<() => string[]>(() => []); const drawerPreviewGenerationRef = useRef(0); const drawerOpenRef = useRef(false); const drawerSectionRef = useRef<"lanes" | "chats">("lanes"); @@ -3193,9 +2964,9 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath, const olderSnapshotBufferBySessionIdRef = useRef>({}); // Render mirror of the cursor for the "↑ loading earlier…" indicator. const [olderHistoryStatusBySessionId, setOlderHistoryStatusBySessionId] = useState>({}); - const providerModelsCacheRef = useRef>(new Map()); + const providerModelsCacheRef = useRef>(new Map()); const modelCatalogRef = useRef(null); - const modelCatalogProviderRefreshedAtRef = useRef>(new Map()); + const modelCatalogProviderRefreshedAtRef = useRef>(new Map()); const pendingModelCommitTimerRef = useRef(null); const pendingModelCommitStateRef = useRef(null); @@ -3219,6 +2990,10 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath, eventsBySessionIdRef.current = eventsBySessionId; }, [eventsBySessionId]); + useEffect(() => { + eventsRef.current = events; + }, [events]); + useEffect(() => { promptHistoryBySessionIdRef.current = promptHistoryBySessionId; }, [promptHistoryBySessionId]); @@ -3251,6 +3026,28 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath, const streaming = activeSessionId ? !!streamingBySessionId[activeSessionId] : false; + const saveCurrentAdeCodeState = useCallback(() => { + if (remoteLaunch) return; + const lastChatByLane: Record = {}; + for (const [laneId, sessionId] of lastChatByLaneRef.current) { + lastChatByLane[laneId] = sessionId; + } + saveAdeCodeProjectState(project.projectRoot, { + lastChatByLane, + lastLaneId: lastLaneIdRef.current, + draftKind: draftKindRef.current, + }); + }, [project.projectRoot, remoteLaunch]); + + const flushPendingAdeCodeState = useCallback(async () => { + if (lastChatByLaneWriteTimerRef.current) { + clearTimeout(lastChatByLaneWriteTimerRef.current); + lastChatByLaneWriteTimerRef.current = null; + saveCurrentAdeCodeState(); + } + await flushAdeCodeStateWrites(); + }, [saveCurrentAdeCodeState]); + const persistAdeCodeState = useCallback(() => { if (remoteLaunch) return; if (lastChatByLaneWriteTimerRef.current) { @@ -3258,13 +3055,14 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath, } lastChatByLaneWriteTimerRef.current = setTimeout(() => { lastChatByLaneWriteTimerRef.current = null; - const lastChatByLane: Record = {}; - for (const [laneId, sessionId] of lastChatByLaneRef.current) { - lastChatByLane[laneId] = sessionId; - } - saveAdeCodeProjectState(project.projectRoot, { lastChatByLane, lastLaneId: lastLaneIdRef.current }); + saveCurrentAdeCodeState(); }, 500); - }, [project.projectRoot, remoteLaunch]); + }, [remoteLaunch, saveCurrentAdeCodeState]); + + const persistExplicitDraftKind = useCallback((draftKind: AdeCodeInterfaceMode) => { + draftKindRef.current = draftKind; + persistAdeCodeState(); + }, [persistAdeCodeState]); const setChatScrollOffset = useCallback((value: number | ((previous: number) => number)) => { const multiSessionId = (gridViewActiveRef.current ? focusedSessionIdForMultiView(multiViewRef.current) : null); @@ -3320,11 +3118,34 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath, }); }, []); + const mergeHydratedEventsWithLive = useCallback((sessionId: string, displayEvents: AgentChatEventEnvelope[]) => { + const existing = eventsBySessionIdRef.current[sessionId] ?? []; + const pending = pendingChatEnvelopesRef.current.filter((envelope) => envelope.sessionId === sessionId); + if (existing.length === 0 && pending.length === 0) return displayEvents; + return dedupeTuiEvents( + [...displayEvents, ...existing, ...pending], + Math.max(TUI_LOADED_EVENT_CAP, displayEvents.length, existing.length + pending.length), + ); + }, []); + + const commitActiveSessionEvents = useCallback(( + sessionId: string, + nextEvents: AgentChatEventEnvelope[], + eventCount = nextEvents.length, + ) => { + eventDedupKeyOrderRef.current = syncTuiEventDedupKeys(eventDedupKeysRef.current, nextEvents); + eventCountRef.current = eventCount; + eventsRef.current = nextEvents; + setEvents(nextEvents); + setEventsBySessionId((prev) => ({ ...prev, [sessionId]: nextEvents })); + }, []); + const clearTranscriptPreview = useCallback(() => { clearOlderHistoryCursor(activeSessionIdRef.current); eventDedupKeysRef.current.clear(); eventDedupKeyOrderRef.current = []; eventCountRef.current = 0; + eventsRef.current = []; setEvents([]); setClearedAt(null); setCurrentGoal(null); @@ -3496,7 +3317,7 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath, stopChatSelectionEdgeScroll(); return; } - const rowCount = selectableChatRowTextsRef.current.length; + const rowCount = selectableChatRowCountRef.current; if (!rowCount) { stopChatSelectionEdgeScroll(); return; @@ -3698,14 +3519,20 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath, ); const activeTerminalProvider = terminalSessionProvider(activeTerminalSession); const displaySessions = useMemo( - () => [...sessions.filter((session) => !session.archivedAt), ...terminalSessions.map(terminalSessionToChatSummary)] - .sort((left, right) => { - const rightMs = Date.parse(right.lastActivityAt ?? right.startedAt); - const leftMs = Date.parse(left.lastActivityAt ?? left.startedAt); - return (Number.isFinite(rightMs) ? rightMs : 0) - (Number.isFinite(leftMs) ? leftMs : 0); - }), + () => sortSessionsByRecentActivity([ + ...sessions.filter((session) => !session.archivedAt), + ...terminalSessions.map(terminalSessionToChatSummary), + ]), [sessions, terminalSessions], ); + const closedCliSessions = useMemo( + () => deriveClosedCliSessions(terminalSessions), + [terminalSessions], + ); + const openDrawerSessions = useMemo( + () => deriveOpenDrawerSessions(displaySessions, closedCliSessions), + [closedCliSessions, displaySessions], + ); const sessionBySessionId = useMemo(() => { const out: Record = {}; for (const session of displaySessions) out[session.sessionId] = session; @@ -3742,13 +3569,19 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath, return { ...prev, [activeSessionId]: events }; }); }, [activeSessionId, events]); - const claudeTerminalControlAvailable = Boolean( + // Ctrl+T raw control works for any running tracked provider CLI (Claude, + // Codex, Cursor, Droid, OpenCode) — activeTerminalProvider is non-null for + // every terminal the TUI surfaces. + const terminalControlAvailable = Boolean( activeTerminalSession && activeTerminalSession.status === "running" - && activeTerminalProvider === "claude", + && activeTerminalProvider, ); - const claudeTerminalControlActive = claudeTerminalControlAvailable + const terminalControlActive = terminalControlAvailable && attachedTerminalId === activeTerminalSession?.terminalId; + // Provider-neutral label for the terminal control chrome (footer "^t