diff --git a/apps/ade-cli/package-lock.json b/apps/ade-cli/package-lock.json index 7a54ba2b1..29858b2b9 100644 --- a/apps/ade-cli/package-lock.json +++ b/apps/ade-cli/package-lock.json @@ -15,10 +15,15 @@ "@openai/codex": "0.130.0", "@opencode-ai/sdk": "^1.4.2", "@wize-logic/nodejs-rfb": "^4.2.0", + "@xterm/addon-serialize": "^0.14.0", + "@xterm/headless": "^6.0.0", + "@xterm/xterm": "^6.0.0", "bonjour-service": "^1.3.0", "chokidar": "^4.0.3", + "highlight.js": "^11.11.1", "ink": "^5.2.1", "ink-text-input": "^6.0.0", + "marked": "^18.0.3", "node-cron": "^3.0.3", "node-pty": "^1.1.0", "react": "^18.3.1", @@ -1598,6 +1603,30 @@ "js-md4": "^0.3.2" } }, + "node_modules/@xterm/addon-serialize": { + "version": "0.14.0", + "resolved": "https://registry.npmjs.org/@xterm/addon-serialize/-/addon-serialize-0.14.0.tgz", + "integrity": "sha512-uteyTU1EkrQa2Ux6P/uFl2fzmXI46jy5uoQMKEOM0fKTyiW7cSn0WrFenHm5vO5uEXX/GpwW/FgILvv3r0WbkA==", + "license": "MIT" + }, + "node_modules/@xterm/headless": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/@xterm/headless/-/headless-6.0.0.tgz", + "integrity": "sha512-5Yj1QINYCyzrZtf8OFIHi47iQtI+0qYFPHmouEfG8dHNxbZ9Tb9YGSuLcsEwj9Z+OL75GJqPyJbyoFer80a2Hw==", + "license": "MIT", + "workspaces": [ + "addons/*" + ] + }, + "node_modules/@xterm/xterm": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-6.0.0.tgz", + "integrity": "sha512-TQwDdQGtwwDt+2cgKDLn0IRaSxYu1tSUjgKarSDkUM0ZNiSRXFpjxEsvc/Zgc5kq5omJ+V0a8/kIM2WD3sMOYg==", + "license": "MIT", + "workspaces": [ + "addons/*" + ] + }, "node_modules/abbrev": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz", @@ -3099,6 +3128,15 @@ "node": ">= 0.4" } }, + "node_modules/highlight.js": { + "version": "11.11.1", + "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-11.11.1.tgz", + "integrity": "sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/hono": { "version": "4.12.18", "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.18.tgz", @@ -3642,6 +3680,18 @@ "node": ">= 10" } }, + "node_modules/marked": { + "version": "18.0.3", + "resolved": "https://registry.npmjs.org/marked/-/marked-18.0.3.tgz", + "integrity": "sha512-7VT90JOkDeaRWpfjOReRGPEKn0ecdARBkDGL+tT1wZY0efPPqkUxLUSmzy/C7TIylQYJC9STISEsCHrqb/7VIA==", + "license": "MIT", + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 20" + } + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -7266,6 +7316,21 @@ "js-md4": "^0.3.2" } }, + "@xterm/addon-serialize": { + "version": "0.14.0", + "resolved": "https://registry.npmjs.org/@xterm/addon-serialize/-/addon-serialize-0.14.0.tgz", + "integrity": "sha512-uteyTU1EkrQa2Ux6P/uFl2fzmXI46jy5uoQMKEOM0fKTyiW7cSn0WrFenHm5vO5uEXX/GpwW/FgILvv3r0WbkA==" + }, + "@xterm/headless": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/@xterm/headless/-/headless-6.0.0.tgz", + "integrity": "sha512-5Yj1QINYCyzrZtf8OFIHi47iQtI+0qYFPHmouEfG8dHNxbZ9Tb9YGSuLcsEwj9Z+OL75GJqPyJbyoFer80a2Hw==" + }, + "@xterm/xterm": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-6.0.0.tgz", + "integrity": "sha512-TQwDdQGtwwDt+2cgKDLn0IRaSxYu1tSUjgKarSDkUM0ZNiSRXFpjxEsvc/Zgc5kq5omJ+V0a8/kIM2WD3sMOYg==" + }, "abbrev": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz", @@ -8229,6 +8294,11 @@ "function-bind": "^1.1.2" } }, + "highlight.js": { + "version": "11.11.1", + "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-11.11.1.tgz", + "integrity": "sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==" + }, "hono": { "version": "4.12.18", "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.18.tgz", @@ -8581,6 +8651,11 @@ "ssri": "^8.0.0" } }, + "marked": { + "version": "18.0.3", + "resolved": "https://registry.npmjs.org/marked/-/marked-18.0.3.tgz", + "integrity": "sha512-7VT90JOkDeaRWpfjOReRGPEKn0ecdARBkDGL+tT1wZY0efPPqkUxLUSmzy/C7TIylQYJC9STISEsCHrqb/7VIA==" + }, "math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", diff --git a/apps/ade-cli/package.json b/apps/ade-cli/package.json index 65b724e8a..409724926 100644 --- a/apps/ade-cli/package.json +++ b/apps/ade-cli/package.json @@ -31,10 +31,15 @@ "@openai/codex": "0.130.0", "@opencode-ai/sdk": "^1.4.2", "@wize-logic/nodejs-rfb": "^4.2.0", + "@xterm/addon-serialize": "^0.14.0", + "@xterm/headless": "^6.0.0", + "@xterm/xterm": "^6.0.0", "bonjour-service": "^1.3.0", "chokidar": "^4.0.3", + "highlight.js": "^11.11.1", "ink": "^5.2.1", "ink-text-input": "^6.0.0", + "marked": "^18.0.3", "node-cron": "^3.0.3", "node-pty": "^1.1.0", "react": "^18.3.1", diff --git a/apps/ade-cli/pnpm-workspace.yaml b/apps/ade-cli/pnpm-workspace.yaml new file mode 100644 index 000000000..b000fdf61 --- /dev/null +++ b/apps/ade-cli/pnpm-workspace.yaml @@ -0,0 +1,4 @@ +allowBuilds: + esbuild: set this to true or false + node-pty: set this to true or false + sqlite3: set this to true or false diff --git a/apps/ade-cli/src/adeRpcServer.test.ts b/apps/ade-cli/src/adeRpcServer.test.ts index 879688467..e8d9ebea8 100644 --- a/apps/ade-cli/src/adeRpcServer.test.ts +++ b/apps/ade-cli/src/adeRpcServer.test.ts @@ -2,7 +2,12 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; -import { createAdeRpcRequestHandler, _resetGlobalAskUserRateLimit, resolveComputerUseOwners } from "./adeRpcServer"; +import { + CLAUDE_INITIAL_INPUT_CONFIRM_DELAY_MS, + createAdeRpcRequestHandler, + _resetGlobalAskUserRateLimit, + resolveComputerUseOwners, +} from "./adeRpcServer"; import { JsonRpcError, JsonRpcErrorCode } from "./jsonrpc"; type RuntimeFixture = ReturnType; @@ -318,6 +323,14 @@ function createRuntime() { }, ptyService: { create: vi.fn(async () => ({ ptyId: "pty-1", sessionId: "session-1" })), + sendToSession: vi.fn(async () => ({ + ptyId: "pty-1", + sessionId: "session-1", + pid: 123, + session: null, + resumed: false, + reusedExistingRuntime: true, + })), dispose: vi.fn(), writeBySessionId: vi.fn((sessionId: string, data: string): boolean => { void sessionId; @@ -1337,6 +1350,7 @@ describe("adeRpcServer", () => { "record_environment", "run_tests", "start_cli_session", + "send_to_session", "get_lane_status", "list_lanes", "commit_changes", @@ -2433,148 +2447,177 @@ describe("adeRpcServer", () => { }); }); - it("preserves the initial input write error if cleanup fails", async () => { + it("preserves wide start_cli_session terminal dimensions", async () => { const fixture = createRuntime(); - fixture.runtime.ptyService.writeBySessionId.mockReturnValueOnce(false); - fixture.runtime.ptyService.dispose.mockImplementationOnce(() => { - throw new Error("already disposed"); - }); - const handler = createAdeRpcRequestHandler({ runtime: fixture.runtime, serverVersion: "test" }); - - await initialize(handler, { role: "orchestrator" }); - const response = await callTool(handler, "start_cli_session", { + fixture.runtime.sessionService.get.mockReturnValue({ + id: "session-1", laneId: "lane-1", - provider: "codex", - initialInput: "fix failing tests", + ptyId: "pty-1", + tracked: true, + toolType: "codex", + title: "Codex", + status: "running", + resumeCommand: null, + resumeMetadata: null, }); - - expect(response.isError).toBe(true); - expect(fixture.runtime.ptyService.dispose).toHaveBeenCalledWith({ ptyId: "pty-1", sessionId: "session-1" }); - expect(JSON.stringify(response.error ?? response.structuredContent ?? {})).toContain( - "Created terminal session could not receive the initial input.", - ); - }); - - it("preassigns Claude session ids for start_cli_session launches", async () => { - const fixture = createRuntime(); const handler = createAdeRpcRequestHandler({ runtime: fixture.runtime, serverVersion: "test" }); await initialize(handler, { role: "orchestrator" }); const response = await callTool(handler, "start_cli_session", { laneId: "lane-1", - provider: "claude", - permissionMode: "default", + provider: "codex", + cols: 999, + rows: 999, }); expect(response?.isError).toBeUndefined(); - const createCall = fixture.runtime.ptyService.create.mock.calls.at(-1)?.[0]; - expect(createCall.sessionId).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/); - expect(createCall.allowNewSessionId).toBe(true); - expect(createCall.startupCommand).toContain("--session-id"); - expect(createCall.startupCommand).toContain(createCall.sessionId); - expect(createCall.toolType).toBe("claude"); + expect(fixture.runtime.ptyService.create).toHaveBeenCalledWith( + expect.objectContaining({ + cols: 400, + rows: 200, + }), + ); }); - it("resumes start_cli_session from stored terminal metadata", async () => { + it("passes selected models to fresh Claude Code terminal launches", async () => { const fixture = createRuntime(); fixture.runtime.sessionService.get.mockReturnValue({ - id: "session-existing", + id: "session-1", laneId: "lane-1", - ptyId: "pty-existing", + ptyId: "pty-1", tracked: true, - toolType: "codex", - title: "Codex", - status: "exited", - resumeCommand: "codex resume picker", - resumeMetadata: { - provider: "codex", - targetKind: "thread", - targetId: "thread-77", - launch: { permissionMode: "edit" }, - }, + toolType: "claude", + title: "Claude Code", + status: "running", + resumeCommand: null, + resumeMetadata: null, }); const handler = createAdeRpcRequestHandler({ runtime: fixture.runtime, serverVersion: "test" }); await initialize(handler, { role: "orchestrator" }); const response = await callTool(handler, "start_cli_session", { laneId: "lane-1", - provider: "codex", - resumeSessionId: "session-existing", + provider: "claude", + permissionMode: "default", + model: "anthropic/claude-opus-4-7-1m", + cols: 90, + rows: 24, }); expect(response?.isError).toBeUndefined(); expect(fixture.runtime.ptyService.create).toHaveBeenCalledWith( expect.objectContaining({ - sessionId: "session-existing", - startupCommand: "codex --no-alt-screen --sandbox workspace-write --ask-for-approval untrusted resume thread-77", + laneId: "lane-1", + title: "Claude Code", + toolType: "claude", + startupCommand: expect.stringContaining("--model"), }), ); + const createCall = fixture.runtime.ptyService.create.mock.calls[0]?.[0] as { args?: string[]; startupCommand?: string }; + expect(createCall.args).toEqual(expect.arrayContaining(["--model", "opus[1m]"])); + expect(createCall.startupCommand).toContain("opus[1m]"); + expect(response.structuredContent.model).toBe("anthropic/claude-opus-4-7-1m"); }); - it("sanitizes start_cli_session resume target ids before building commands", async () => { + it("confirms Claude Code initial input after startup", async () => { + vi.useFakeTimers(); + try { + const fixture = createRuntime(); + fixture.runtime.sessionService.get.mockReturnValue({ + id: "session-1", + laneId: "lane-1", + ptyId: "pty-1", + tracked: true, + toolType: "claude", + title: "Claude Code", + status: "running", + resumeCommand: null, + resumeMetadata: null, + }); + const handler = createAdeRpcRequestHandler({ runtime: fixture.runtime, serverVersion: "test" }); + + await initialize(handler, { role: "orchestrator" }); + const response = await callTool(handler, "start_cli_session", { + laneId: "lane-1", + provider: "claude", + permissionMode: "default", + initialInput: "hello?", + }); + + expect(response?.isError).toBeUndefined(); + expect(fixture.runtime.ptyService.writeBySessionId).toHaveBeenCalledTimes(1); + expect(fixture.runtime.ptyService.writeBySessionId).toHaveBeenNthCalledWith(1, "session-1", "hello?\r"); + + await vi.advanceTimersByTimeAsync(CLAUDE_INITIAL_INPUT_CONFIRM_DELAY_MS); + + expect(fixture.runtime.ptyService.writeBySessionId).toHaveBeenCalledTimes(2); + expect(fixture.runtime.ptyService.writeBySessionId).toHaveBeenNthCalledWith(2, "session-1", "\r"); + } finally { + vi.useRealTimers(); + } + }); + + it("preserves the initial input write error if cleanup fails", async () => { const fixture = createRuntime(); + fixture.runtime.ptyService.writeBySessionId.mockReturnValueOnce(false); + fixture.runtime.ptyService.dispose.mockImplementationOnce(() => { + throw new Error("already disposed"); + }); const handler = createAdeRpcRequestHandler({ runtime: fixture.runtime, serverVersion: "test" }); await initialize(handler, { role: "orchestrator" }); const response = await callTool(handler, "start_cli_session", { laneId: "lane-1", provider: "codex", - resumeTargetId: "thread-1\n--danger", + initialInput: "fix failing tests", }); - expect(response?.isError).toBeUndefined(); - const createCall = fixture.runtime.ptyService.create.mock.calls.at(-1)?.[0]; - expect(createCall.startupCommand).toContain("thread-1 --danger"); - expect(createCall.startupCommand).not.toContain("\n"); + expect(response.isError).toBe(true); + expect(fixture.runtime.ptyService.dispose).toHaveBeenCalledWith({ ptyId: "pty-1", sessionId: "session-1" }); + expect(JSON.stringify(response.error ?? response.structuredContent ?? {})).toContain( + "Created terminal session could not receive the initial input.", + ); }); - it("rejects start_cli_session resume when the session id is missing", async () => { + it("preassigns Claude session ids for start_cli_session launches", async () => { const fixture = createRuntime(); - fixture.runtime.sessionService.get.mockReturnValue(null); const handler = createAdeRpcRequestHandler({ runtime: fixture.runtime, serverVersion: "test" }); await initialize(handler, { role: "orchestrator" }); const response = await callTool(handler, "start_cli_session", { laneId: "lane-1", - provider: "codex", - resumeSessionId: "missing-session", + provider: "claude", + permissionMode: "default", }); - expect(response.isError).toBe(true); - expect(JSON.stringify(response.error ?? response.structuredContent ?? {})).toContain("missing-session"); - expect(fixture.runtime.ptyService.create).not.toHaveBeenCalled(); + expect(response?.isError).toBeUndefined(); + const createCall = fixture.runtime.ptyService.create.mock.calls.at(-1)?.[0]; + expect(createCall.sessionId).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/); + expect(createCall.allowNewSessionId).toBe(true); + expect(createCall.startupCommand).toContain("--session-id"); + expect(createCall.startupCommand).toContain(createCall.sessionId); + expect(createCall.toolType).toBe("claude"); }); - it("rejects start_cli_session resume for a different provider", async () => { + it("sends to an existing CLI session through the durable session continuation path", async () => { const fixture = createRuntime(); - fixture.runtime.sessionService.get.mockReturnValue({ - id: "session-existing", - laneId: "lane-1", - ptyId: "pty-existing", - tracked: true, - toolType: "claude", - title: "Claude", - status: "exited", - resumeCommand: "claude --resume old", - resumeMetadata: { - provider: "claude", - targetKind: "session", - targetId: "old", - launch: { permissionMode: "default" }, - }, - }); const handler = createAdeRpcRequestHandler({ runtime: fixture.runtime, serverVersion: "test" }); await initialize(handler, { role: "orchestrator" }); - const response = await callTool(handler, "start_cli_session", { - laneId: "lane-1", - provider: "codex", - resumeSessionId: "session-existing", + const response = await callTool(handler, "send_to_session", { + sessionId: "session-existing", + text: "continue here", + cols: 999, + rows: 999, }); - expect(response.isError).toBe(true); - expect(JSON.stringify(response.error ?? response.structuredContent ?? {})).toContain("belongs to claude, not codex"); - expect(fixture.runtime.ptyService.create).not.toHaveBeenCalled(); + expect(response?.isError).toBeUndefined(); + expect(fixture.runtime.ptyService.sendToSession).toHaveBeenCalledWith({ + sessionId: "session-existing", + text: "continue here", + cols: 400, + rows: 200, + }); }); it("rejects invalid start_cli_session permission modes", async () => { diff --git a/apps/ade-cli/src/adeRpcServer.ts b/apps/ade-cli/src/adeRpcServer.ts index 2378e45e3..8450906dd 100644 --- a/apps/ade-cli/src/adeRpcServer.ts +++ b/apps/ade-cli/src/adeRpcServer.ts @@ -48,12 +48,10 @@ import type { LinearConnectionStatus } from "../../desktop/src/shared/types/line import { resolveAdeLayout } from "../../desktop/src/shared/adeLayout"; import { buildTrackedCliLaunchCommand, - buildTrackedCliResumeCommand, isLaunchProfile, isTrackedCliPermissionMode, LAUNCH_PROFILE_TITLE, LAUNCH_PROFILE_TOOL_TYPE, - launchProfileForTerminalSession, validateLaunchProfilePermissionMode, type CliProvider, type LaunchProfile, @@ -172,6 +170,7 @@ type SessionState = { const DEFAULT_PROTOCOL_VERSION = "2025-06-18"; const DEFAULT_PTY_COLS = 120; const DEFAULT_PTY_ROWS = 36; +export const CLAUDE_INITIAL_INPUT_CONFIRM_DELAY_MS = 1200; const RESOURCE_MIME_JSON = "application/json"; @@ -301,7 +300,7 @@ const TOOL_SPECS: ToolSpec[] = [ }, { name: "start_cli_session", - description: "Start or resume a tracked ADE Work CLI terminal for an allowlisted provider, using the same launch helpers as desktop and mobile.", + description: "Start a tracked ADE Work CLI terminal for an allowlisted provider, using the same launch helpers as desktop and mobile.", inputSchema: { type: "object", required: ["laneId", "provider"], @@ -312,16 +311,31 @@ const TOOL_SPECS: ToolSpec[] = [ permissionMode: { type: "string", enum: ["default", "plan", "edit", "full-auto", "config-toml"], default: "default" }, title: { type: "string" }, initialInput: { type: "string" }, - cols: { type: "number", minimum: 20, maximum: 240, default: 120 }, - rows: { type: "number", minimum: 4, maximum: 120, default: 36 }, + cols: { type: "number", minimum: 20, maximum: 400, default: 120 }, + rows: { type: "number", minimum: 4, maximum: 200, default: 36 }, + model: { type: "string" }, + modelId: { type: "string" }, cwd: { type: "string" }, chatSessionId: { type: "string" }, - resumeSessionId: { type: "string" }, - resumeTargetId: { type: "string" }, tracked: { type: "boolean", default: true } } } }, + { + name: "send_to_session", + description: "Send text to an ADE Work CLI session. If the session is ended and resumable, ADE starts the provider continuation internally and attaches it to the same durable session.", + inputSchema: { + type: "object", + required: ["sessionId", "text"], + additionalProperties: false, + properties: { + sessionId: { type: "string", minLength: 1 }, + text: { type: "string", minLength: 1 }, + cols: { type: "number", minimum: 20, maximum: 400, default: 120 }, + rows: { type: "number", minimum: 4, maximum: 200, default: 36 } + } + } + }, { name: "get_ade_action_status", description: "Check status/progress for long-running ADE actions by operation/test/chat/run/mission identifiers.", @@ -1769,30 +1783,6 @@ const CTO_OPERATOR_TOOL_SPECS: ToolSpec[] = [ } } }, - { - name: "resumeChat", - description: "Resume an ended ADE Work chat session.", - inputSchema: { - type: "object", - required: ["sessionId"], - additionalProperties: false, - properties: { - sessionId: { type: "string", minLength: 1 } - } - } - }, - { - name: "endChat", - description: "End an ADE Work chat session.", - inputSchema: { - type: "object", - required: ["sessionId"], - additionalProperties: false, - properties: { - sessionId: { type: "string", minLength: 1 } - } - } - }, { name: "listLinearWorkflows", description: "List active and queued Linear workflow runs managed by ADE.", @@ -2197,6 +2187,7 @@ const MUTATION_TOOLS = new Set([ "create_lane", "run_ade_action", "start_cli_session", + "send_to_session", "import_lane", "merge_lane", "git_fetch", @@ -2231,8 +2222,6 @@ const MUTATION_TOOLS = new Set([ "spawnChat", "sendChatMessage", "interruptChat", - "resumeChat", - "endChat", "resolveLinearRunAction", "cancelLinearRun", "routeLinearIssueToCto", @@ -2501,25 +2490,6 @@ function isCliProvider(provider: LaunchProfile): provider is CliProvider { return provider !== "shell"; } -function requireCliResumeSession( - runtime: AdeRuntime, - sessionId: string, - provider: LaunchProfile, -): TerminalSessionSummary { - const session = runtime.sessionService.get(sessionId) as TerminalSessionSummary | null; - if (!session) { - throw new JsonRpcError(JsonRpcErrorCode.invalidParams, `resumeSessionId '${sessionId}' was not found.`); - } - const existingProvider = launchProfileForTerminalSession(session); - if (existingProvider && existingProvider !== provider) { - throw new JsonRpcError( - JsonRpcErrorCode.invalidParams, - `resumeSessionId '${sessionId}' belongs to ${existingProvider}, not ${provider}.`, - ); - } - return session; -} - export function resolveComputerUseOwners(session: SessionState, toolArgs: Record): ComputerUseArtifactOwner[] { const owners: ComputerUseArtifactOwner[] = []; const add = ( @@ -3149,8 +3119,6 @@ async function runCtoOperatorBridgeTool( previewSessionToolNames: agentChatService.previewSessionToolNames, sendChatMessage: agentChatService.sendMessage, interruptChat: agentChatService.interrupt, - resumeChat: agentChatService.resumeSession, - disposeChat: agentChatService.dispose, ensureCtoSession: async ({ laneId, modelId, reasoningEffort, reuseExisting }) => agentChatService.ensureIdentitySession({ identityKey: "cto", @@ -5138,36 +5106,21 @@ async function runTool(args: { } catch (err) { throw new JsonRpcError(JsonRpcErrorCode.invalidParams, err instanceof Error ? err.message : String(err)); } - const cols = clampInteger(toolArgs.cols, DEFAULT_PTY_COLS, 20, 240); - const rows = clampInteger(toolArgs.rows, DEFAULT_PTY_ROWS, 4, 120); + const cols = clampInteger(toolArgs.cols, DEFAULT_PTY_COLS, 20, 400); + const rows = clampInteger(toolArgs.rows, DEFAULT_PTY_ROWS, 4, 200); const title = asOptionalTrimmedString(toolArgs.title) ?? LAUNCH_PROFILE_TITLE[provider]; - const resumeSessionId = asOptionalTrimmedString(toolArgs.resumeSessionId); - const resumeTargetIdRaw = asOptionalTrimmedString(toolArgs.resumeTargetId); - const resumeTargetId = resumeTargetIdRaw ? stripInjectionChars(resumeTargetIdRaw) : null; const initialInput = asOptionalTrimmedString(toolArgs.initialInput)?.slice(0, 20_000) ?? null; - const resumeSession = resumeSessionId ? requireCliResumeSession(runtime, resumeSessionId, provider) : null; + const model = asOptionalTrimmedString(toolArgs.model) ?? asOptionalTrimmedString(toolArgs.modelId); const ptyService = runtime.ptyService; - const preassignedSessionId = provider === "claude" && !resumeSessionId ? randomUUID() : undefined; + const preassignedSessionId = provider === "claude" ? randomUUID() : undefined; const launchFields: { startupCommand?: string; command?: string; args?: string[]; env?: Record } = (() => { if (!isCliProvider(provider)) return {}; - if (resumeSessionId || resumeTargetId) { - const startupCommand = resumeSession?.resumeMetadata - ? buildTrackedCliResumeCommand(resumeSession.resumeMetadata) - : resumeSession?.resumeCommand?.trim() - || buildTrackedCliResumeCommand({ - provider, - targetKind: "session", - targetId: resumeTargetId, - launch: { permissionMode }, - }); - return { startupCommand }; - } - return buildTrackedCliLaunchCommand({ provider, permissionMode, sessionId: preassignedSessionId }); + return buildTrackedCliLaunchCommand({ provider, permissionMode, sessionId: preassignedSessionId, model }); })(); const created = await ptyService.create({ - ...(resumeSessionId || preassignedSessionId ? { sessionId: resumeSessionId ?? preassignedSessionId } : {}), + ...(preassignedSessionId ? { sessionId: preassignedSessionId } : {}), ...(preassignedSessionId ? { allowNewSessionId: true } : {}), laneId, cols, @@ -5194,6 +5147,18 @@ async function runTool(args: { "Created terminal session could not receive the initial input.", ); } + if (provider === "claude") { + const confirmTimer = setTimeout(() => { + const confirmWritten = ptyService.writeBySessionId(created.sessionId, "\r"); + if (!confirmWritten) { + runtime.logger.warn("rpc.start_cli_session_claude_initial_input_confirm_failed", { + sessionId: created.sessionId, + ptyId: created.ptyId, + }); + } + }, CLAUDE_INITIAL_INPUT_CONFIRM_DELAY_MS); + confirmTimer.unref?.(); + } } const session = runtime.sessionService.get(created.sessionId) as TerminalSessionSummary | null; @@ -5203,6 +5168,7 @@ async function runTool(args: { laneId, title, permissionMode, + model: provider === "claude" ? model ?? null : null, ptyId: created.ptyId, sessionId: created.sessionId, startupCommand: launchFields.startupCommand ?? null, @@ -5211,6 +5177,17 @@ async function runTool(args: { }; } + if (name === "send_to_session") { + const sessionId = assertNonEmptyString(toolArgs.sessionId, "sessionId"); + const text = assertNonEmptyString(toolArgs.text, "text"); + return await runtime.ptyService.sendToSession({ + sessionId, + text, + cols: clampInteger(toolArgs.cols, DEFAULT_PTY_COLS, 20, 400), + rows: clampInteger(toolArgs.rows, DEFAULT_PTY_ROWS, 4, 200), + }); + } + if (name === "get_ade_action_status") { const operationId = asOptionalTrimmedString(toolArgs.operationId); const testRunId = asOptionalTrimmedString(toolArgs.testRunId); diff --git a/apps/ade-cli/src/cli.test.ts b/apps/ade-cli/src/cli.test.ts index 2fb9df651..6faed0c80 100644 --- a/apps/ade-cli/src/cli.test.ts +++ b/apps/ade-cli/src/cli.test.ts @@ -97,11 +97,11 @@ describe("ADE CLI", () => { }); it("keeps global version on the version surface", () => { - expect(buildCliPlan(["--version"])).toEqual({ - kind: "help", - text: "ade 0.0.0\n", - }); - expect(buildCliPlan(["-v"])).toEqual({ kind: "help", text: "ade 0.0.0\n" }); + const version = buildCliPlan(["--version"]); + expect(version.kind).toBe("help"); + if (version.kind !== "help") return; + expect(version.text).toMatch(/^ade \S+\n$/); + expect(buildCliPlan(["-v"])).toEqual(version); }); it("builds runtime daemon and stdio RPC commands", () => { @@ -271,7 +271,13 @@ describe("ADE CLI", () => { }); it("forwards resolved roots and socket intent to ade code", () => { - const previous = process.env.ADE_RUNTIME_SOCKET_PATH; + const previousRuntimeSocket = process.env.ADE_RUNTIME_SOCKET_PATH; + const previousRpcSocket = process.env.ADE_RPC_SOCKET_PATH; + const previousRpcUrl = process.env.ADE_RPC_URL; + const previousWorkspace = process.env.ADE_WORKSPACE_ROOT; + delete process.env.ADE_RPC_SOCKET_PATH; + delete process.env.ADE_RPC_URL; + delete process.env.ADE_WORKSPACE_ROOT; process.env.ADE_RUNTIME_SOCKET_PATH = "/tmp/ade-runtime.sock"; try { const args = buildAdeCodeArgs(["--print-state"], { @@ -293,8 +299,17 @@ describe("ADE CLI", () => { "--print-state", ]); } finally { - if (previous === undefined) delete process.env.ADE_RUNTIME_SOCKET_PATH; - else process.env.ADE_RUNTIME_SOCKET_PATH = previous; + if (previousRuntimeSocket === undefined) + delete process.env.ADE_RUNTIME_SOCKET_PATH; + else process.env.ADE_RUNTIME_SOCKET_PATH = previousRuntimeSocket; + if (previousRpcSocket === undefined) + delete process.env.ADE_RPC_SOCKET_PATH; + else process.env.ADE_RPC_SOCKET_PATH = previousRpcSocket; + if (previousRpcUrl === undefined) delete process.env.ADE_RPC_URL; + else process.env.ADE_RPC_URL = previousRpcUrl; + if (previousWorkspace === undefined) + delete process.env.ADE_WORKSPACE_ROOT; + else process.env.ADE_WORKSPACE_ROOT = previousWorkspace; } }); @@ -418,11 +433,6 @@ describe("ADE CLI", () => { }); }); - it("builds the stdio MCP server command", () => { - expect(buildCliPlan(["mcp"])).toEqual({ kind: "mcp" }); - expect(buildCliPlan(["mcp-server"])).toEqual({ kind: "mcp" }); - }); - it("builds nested generic ADE action args", () => { const plan = buildCliPlan([ "actions", @@ -936,6 +946,24 @@ describe("ADE CLI", () => { ); }); + it("builds chat slash commands for a positional session id", () => { + const plan = buildCliPlan(["chat", "slash", "chat-1"]); + expect(plan.kind).toBe("execute"); + if (plan.kind !== "execute") return; + expect(plan.label).toBe("chat slash commands"); + expect(plan.steps[0]?.params).toEqual({ + name: "run_ade_action", + arguments: { + domain: "chat", + action: "getSlashCommands", + args: { sessionId: "chat-1" }, + }, + }); + expect(() => buildCliPlan(["chat", "slash"])).toThrow( + /sessionId is required/, + ); + }); + it("rejects prototype-sensitive generic ADE action arg paths", () => { expect(({} as Record).polluted).toBeUndefined(); @@ -2074,36 +2102,26 @@ describe("ADE CLI", () => { }); }); - it("finds a start-cli provider after resume value-taking options", () => { - const cases: Array<[string, string, string]> = [ - ["--resume-session", "session-1", "resumeSessionId"], - ["--resume-session-id", "session-2", "resumeSessionId"], - ["--resume-target", "target-1", "resumeTargetId"], - ["--resume-target-id", "target-2", "resumeTargetId"], - ["--initial-input", "hello agent", "initialInput"], - ]; - - for (const [flag, value, field] of cases) { - const plan = buildCliPlan([ - "shell", - "start-cli", - "--lane", - "lane-1", - flag, - value, - "codex", - ]); - expect(plan.kind).toBe("execute"); - if (plan.kind !== "execute") return; - expect(plan.steps[0]?.params).toMatchObject({ - name: "start_cli_session", - arguments: { - laneId: "lane-1", - provider: "codex", - [field]: value, - }, - }); - } + it("finds a start-cli provider after initial-input value-taking options", () => { + const plan = buildCliPlan([ + "shell", + "start-cli", + "--lane", + "lane-1", + "--initial-input", + "hello agent", + "codex", + ]); + expect(plan.kind).toBe("execute"); + if (plan.kind !== "execute") return; + expect(plan.steps[0]?.params).toMatchObject({ + name: "start_cli_session", + arguments: { + laneId: "lane-1", + provider: "codex", + initialInput: "hello agent", + }, + }); }); it("accepts --provider on shell start as the CLI-session launcher", () => { @@ -2114,8 +2132,6 @@ describe("ADE CLI", () => { "claude", "--lane", "lane-1", - "--resume-session", - "session-1", ]); expect(plan.kind).toBe("execute"); if (plan.kind !== "execute") return; @@ -2125,7 +2141,6 @@ describe("ADE CLI", () => { laneId: "lane-1", provider: "claude", permissionMode: "default", - resumeSessionId: "session-1", }, }); }); diff --git a/apps/ade-cli/src/cli.ts b/apps/ade-cli/src/cli.ts index ab8f4dce3..e6153df42 100644 --- a/apps/ade-cli/src/cli.ts +++ b/apps/ade-cli/src/cli.ts @@ -17,15 +17,13 @@ import { realpathIfExists, } from "./services/projects/projectRoots"; import { - JsonRpcError, - JsonRpcErrorCode, startJsonRpcServer, type JsonRpcHandler, type JsonRpcId, type JsonRpcRequest, type JsonRpcTransport, } from "./jsonrpc"; -import { isAdeMcpNamedPipePath } from "../../desktop/src/shared/adeMcpIpc"; +import { isAdeRuntimeNamedPipePath } from "../../desktop/src/shared/adeRuntimeIpc"; import { isLaunchProfile, isTrackedCliPermissionMode, @@ -131,8 +129,7 @@ type CliPlan = | { kind: "serve"; rest: string[] } | { kind: "rpc-stdio"; rest: string[] } | { kind: "init"; targetPath: string | null } - | { kind: "cursor-cloud"; rest: string[] } - | { kind: "mcp" }; + | { kind: "cursor-cloud"; rest: string[] }; type CliConnection = { mode: "desktop-socket" | "runtime-socket" | "headless"; @@ -186,47 +183,6 @@ const CLI_ENTRY_PATH = typeof process.argv[1] === "string" ? path.resolve(process.argv[1]) : ""; const CLI_PACKAGE_ROOT = resolveCliPackageRoot(CLI_ENTRY_PATH); const CLI_DIST_PATH = path.join(CLI_PACKAGE_ROOT, "dist", "cli.cjs"); -const COORDINATOR_MCP_TOOL_NAMES = new Set([ - "spawn_worker", - "insert_milestone", - "request_specialist", - "delegate_to_subagent", - "delegate_parallel", - "stop_worker", - "send_message", - "message_worker", - "broadcast", - "get_worker_output", - "list_workers", - "report_status", - "report_result", - "report_validation", - "read_mission_status", - "read_mission_state", - "update_mission_state", - "revise_plan", - "update_tool_profiles", - "transfer_lane", - "provision_lane", - "set_current_phase", - "create_task", - "update_task", - "assign_task", - "list_tasks", - "skip_step", - "mark_step_complete", - "mark_step_failed", - "retry_step", - "complete_mission", - "fail_mission", - "get_budget_status", - "request_user_input", - "read_file", - "read_step_output", - "search_files", - "get_project_context", -]); - const WORKER_MISSION_TOOL_CLI_NAMES = new Set([ "get_mission", "get_run_graph", @@ -431,7 +387,6 @@ const TOP_LEVEL_HELP = `${ADE_BANNER} $ ade settings action Call project config actions $ ade update status | check | install | dismiss Read auto-update state and drive install $ ade actions list | run | status | wait Escape hatch for every ADE service action - $ ade mcp Expose ADE actions over stdio MCP $ ade cursor cloud agents | runs | artifacts | repos | models | me Drive Cursor Cloud agents via @cursor/sdk @@ -1097,7 +1052,7 @@ const HELP_BY_COMMAND: Record = { $ ade chat create --lane --provider codex --model [--fast] $ ade chat send --text "next step" Send a message $ ade chat interrupt Stop an active turn - $ ade chat resume Resume a session + $ ade chat slash --text List slash commands for a session $ ade agent spawn --lane --prompt "fix" Start a new agent work session `, agent: `${ADE_BANNER} @@ -1173,7 +1128,7 @@ const HELP_BY_COMMAND: Record = { $ ade ios-sim stream-stop Stop preview/live streaming (stopStream) Input and selection: - $ ade --socket ios-sim select --x 120 --y 420 Add UI context to drawer chat (selectPoint) + $ ade --socket ios-sim select --x 120 --y 420 Return/select simulator UI context (chat-owned sessions auto-attach) $ ade ios-sim tap 120 420 Tap active simulator app (tap) $ ade ios-sim drag 120 700 120 250 Drag active simulator app (drag) $ ade ios-sim swipe 120 700 120 250 Swipe active simulator app (swipe) @@ -1257,7 +1212,7 @@ const HELP_BY_COMMAND: Record = { $ ade app-control screenshot --text Capture the active renderer screenshot $ ade app-control snapshot --text Screenshot + DOM element refs $ ade app-control inspect --x 120 --y 420 Hit-test a point without committing context - $ ade app-control select --x 120 --y 420 Add selected app context to the drawer chat + $ ade app-control select --x 120 --y 420 Return/select app context (chat-owned sessions auto-attach) Input: $ ade app-control click 120 420 Click screenshot coordinates @@ -3672,22 +3627,6 @@ function buildPrPlan(args: string[]): CliPlan { label: "PR mobile snapshot", steps: [actionArgsListStep("result", "pr", "getMobileSnapshot", [])], }; - if (sub === "snapshots") { - const mode = firstPositional(args) ?? "list"; - const action = mode === "refresh" ? "refreshSnapshots" : "listSnapshots"; - return { - kind: "execute", - label: `PR snapshots ${mode}`, - steps: [ - actionStep( - "result", - "pr", - action, - withPr({ prId: prId ?? firstPositional(args) }), - ), - ], - }; - } if (sub === "github-snapshot") return { kind: "execute", @@ -4894,15 +4833,6 @@ function buildCliSessionStartPlan( rows: readIntOption(args, ["--rows"], 36), cwd: readValue(args, ["--cwd"]), chatSessionId: readValue(args, ["--chat-session", "--chat-session-id"]), - resumeSessionId: readValue(args, [ - "--resume-session", - "--resume-session-id", - ]), - resumeTargetId: readValue(args, [ - "--resume-target", - "--resume-target-id", - "--target", - ]), tracked: !readFlag(args, ["--untracked"]), }); @@ -5183,12 +5113,6 @@ function buildChatPlan(args: string[]): CliPlan { ), ], }; - if (sub === "resume") - return { - kind: "execute", - label: "chat resume", - steps: [actionStep("result", "chat", "resumeSession", withSession())], - }; if (sub === "delete" || sub === "rm") return { kind: "execute", @@ -5217,7 +5141,7 @@ function buildChatPlan(args: string[]): CliPlan { "result", "chat", "getSlashCommands", - collectGenericObjectArgs(args), + withSession({ sessionId: requireValue(sessionId, "sessionId") }), ), ], }; @@ -7661,13 +7585,11 @@ function buildCtoPlan(args: string[]): CliPlan { transcript: "readChatTranscript", send: "sendChatMessage", interrupt: "interruptChat", - resume: "resumeChat", - end: "endChat", }; const tool = toolByMode[mode]; if (!tool) throw new CliUsageError( - "cto chats supports list, spawn, status, transcript, send, interrupt, resume, or end.", + "cto chats supports list, spawn, status, transcript, send, or interrupt.", ); return { kind: "execute", @@ -8503,10 +8425,6 @@ const VALUE_CARRIER_FLAGS: ReadonlySet = new Set([ "--reasoning", "--recent-limit", "--ref", - "--resume-session", - "--resume-session-id", - "--resume-target", - "--resume-target-id", "--role", "--root", "--root-lane", @@ -8838,7 +8756,6 @@ function buildCliPlan(command: string[]): CliPlan { primary === "updates" ) return buildUpdatePlan(args); - if (primary === "mcp" || primary === "mcp-server") return { kind: "mcp" }; if (primary === "cursor") return buildCursorPlan(args); throw new CliUsageError(`Unknown command '${primary}'. Run 'ade help'.`); } @@ -9255,7 +9172,7 @@ function buildReadinessSnapshot(args: { connection.mode === "runtime-socket" || connection.mode === "desktop-socket"; const desktopSocketAvailable = connection.mode === "desktop-socket"; - const socketExists = isAdeMcpNamedPipePath(connection.socketPath) + const socketExists = isAdeRuntimeNamedPipePath(connection.socketPath) ? attachedSocketAvailable : fs.existsSync(connection.socketPath); const checks = { @@ -9657,7 +9574,7 @@ async function startHeadlessRpcSocketServer(args: { createHandler: () => JsonRpcHandler & { dispose?: () => void }; }): Promise<(() => void) | null> { if ( - isAdeMcpNamedPipePath(args.socketPath) || + isAdeRuntimeNamedPipePath(args.socketPath) || fs.existsSync(args.socketPath) ) { return null; @@ -9883,7 +9800,7 @@ async function startHeadlessRpcSocketServers(args: { export function shouldAttemptDesktopSocketConnection( socketPath: string, ): boolean { - return isAdeMcpNamedPipePath(socketPath) || fs.existsSync(socketPath); + return isAdeRuntimeNamedPipePath(socketPath) || fs.existsSync(socketPath); } async function initializeConnection( @@ -10251,201 +10168,6 @@ function buildInitializeParams( }; } -function normalizeMcpAdeToolName(name: string): string { - const trimmed = name.trim(); - const prefixPatterns = [ - /^ade[_:.](.+)$/i, - /^mcp[_:.]ade[_:.](.+)$/i, - /^mcp__ade__(.+)$/i, - ]; - for (const pattern of prefixPatterns) { - const match = pattern.exec(trimmed); - if (match?.[1]) return match[1].trim(); - } - return trimmed; -} - -function mcpToolScope(): "all" | "coordinator" { - return process.env.ADE_MCP_TOOL_SCOPE === "coordinator" - ? "coordinator" - : "all"; -} - -function isMcpToolVisible(name: string): boolean { - if (mcpToolScope() !== "coordinator") return true; - return COORDINATOR_MCP_TOOL_NAMES.has(normalizeMcpAdeToolName(name)); -} - -function formatMcpToolText(value: unknown): string { - if (typeof value === "string") return value; - try { - return JSON.stringify(value ?? null, null, 2); - } catch { - return String(value); - } -} - -async function runMcpServer(options: GlobalOptions): Promise { - const roots = resolveRoots({ - ...options, - headless: true, - requireSocket: false, - }); - const previousRole = process.env.ADE_DEFAULT_ROLE; - process.env.ADE_DEFAULT_ROLE = options.role; - const [{ createAdeRuntime }, { createAdeRpcRequestHandler }] = - await Promise.all([import("./bootstrap"), import("./adeRpcServer")]); - const runtime = await createAdeRuntime({ - projectRoot: roots.projectRoot, - workspaceRoot: roots.workspaceRoot, - }); - const adeHandler = createAdeRpcRequestHandler({ - runtime, - serverVersion: VERSION, - onActionsListChanged: () => {}, - }); - let initialized = false; - let nextAdeRequestId = 1; - const callAde = async ( - method: string, - params?: JsonObject, - ): Promise => { - return await adeHandler({ - jsonrpc: "2.0", - id: nextAdeRequestId++, - method, - ...(params !== undefined ? { params } : {}), - }); - }; - const ensureInitialized = async (): Promise => { - if (initialized) return; - await callAde("ade/initialize", buildInitializeParams(options, "ade-mcp")); - initialized = true; - }; - - const mcpHandler: JsonRpcHandler = async (request) => { - const method = typeof request.method === "string" ? request.method : ""; - const params = isRecord(request.params) ? request.params : {}; - if (method === "initialize") { - await ensureInitialized(); - const requestedVersion = - asString(params.protocolVersion) ?? PROTOCOL_VERSION; - return { - protocolVersion: requestedVersion, - capabilities: { - tools: { - listChanged: false, - }, - }, - serverInfo: { - name: "ade", - version: VERSION, - }, - }; - } - if (method === "notifications/initialized" || method === "initialized") { - await ensureInitialized(); - return null; - } - await ensureInitialized(); - if (method === "tools/list") { - const listed = await callAde("ade/actions/list"); - const actions = - isRecord(listed) && Array.isArray(listed.actions) - ? listed.actions.filter(isRecord) - : []; - return { - tools: actions - .map((action) => ({ - name: asString(action.name) ?? "", - description: asString(action.description) ?? "", - inputSchema: isRecord(action.inputSchema) - ? action.inputSchema - : { type: "object", properties: {} }, - })) - .filter( - (tool) => tool.name.length > 0 && isMcpToolVisible(tool.name), - ), - }; - } - if (method === "tools/call") { - const rawName = asString(params.name); - if (!rawName) { - throw new JsonRpcError( - JsonRpcErrorCode.invalidParams, - "tools/call requires a tool name.", - ); - } - if (!isMcpToolVisible(rawName)) { - throw new JsonRpcError( - JsonRpcErrorCode.methodNotFound, - `Tool not available in this MCP scope: ${rawName}`, - ); - } - const result = await callAde("ade/actions/call", { - name: normalizeMcpAdeToolName(rawName), - arguments: isRecord(params.arguments) ? params.arguments : {}, - }); - const isError = isRecord(result) && result.ok === false; - return { - content: [ - { - type: "text", - text: formatMcpToolText(result), - }, - ], - structuredContent: result ?? null, - isError, - }; - } - if (method === "shutdown") { - return {}; - } - if (method === "exit") { - process.nextTick(() => process.exit(0)); - return {}; - } - throw new JsonRpcError( - JsonRpcErrorCode.methodNotFound, - `Method not found: ${method}`, - ); - }; - - const transport: JsonRpcTransport = { - onData(callback) { - process.stdin.on("data", (chunk) => - callback(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)), - ); - }, - write(data) { - process.stdout.write(data); - }, - close() { - process.stdin.pause(); - }, - }; - const stop = startJsonRpcServer(mcpHandler, transport, { nonFatal: true }); - await new Promise((resolve) => { - let done = false; - const finish = () => { - if (done) return; - done = true; - resolve(); - }; - process.stdin.once("end", finish); - process.stdin.once("close", finish); - }); - stop(); - try { - adeHandler.dispose?.(); - } catch {} - try { - runtime.dispose(); - } catch {} - if (previousRole == null) delete process.env.ADE_DEFAULT_ROLE; - else process.env.ADE_DEFAULT_ROLE = previousRole; -} - function parseOptionalPort(value: string | null, label: string): number | null { if (value == null) return null; const parsed = Number.parseInt(value, 10); @@ -10457,7 +10179,7 @@ function parseOptionalPort(value: string | null, label: string): number | null { function normalizeRuntimeSocketPath(rawSocketPath: string): string { return rawSocketPath.startsWith("tcp://") || - isAdeMcpNamedPipePath(rawSocketPath) + isAdeRuntimeNamedPipePath(rawSocketPath) ? rawSocketPath : path.resolve(rawSocketPath); } @@ -10875,7 +10597,7 @@ async function runServe( readValue(args, ["--socket"]) ?? process.env.ADE_RPC_SOCKET_PATH?.trim() ?? layout.socketPath; - const socketPath = isAdeMcpNamedPipePath(rawSocketPath) + const socketPath = isAdeRuntimeNamedPipePath(rawSocketPath) ? rawSocketPath : path.resolve(rawSocketPath); const port = parseOptionalPort(readValue(args, ["--port"]), "--port"); @@ -11072,7 +10794,7 @@ async function runServe( }; fs.mkdirSync(layout.adeDir, { recursive: true, mode: 0o700 }); - if (!isAdeMcpNamedPipePath(socketPath)) { + if (!isAdeRuntimeNamedPipePath(socketPath)) { fs.mkdirSync(path.dirname(socketPath), { recursive: true, mode: 0o700 }); try { fs.unlinkSync(socketPath); @@ -11082,7 +10804,7 @@ async function runServe( const socketState = createHeadlessRpcServer(createHandler); states.push(socketState); await listen(socketState.server, socketPath); - if (!isAdeMcpNamedPipePath(socketPath)) { + if (!isAdeRuntimeNamedPipePath(socketPath)) { try { fs.chmodSync(socketPath, 0o600); } catch {} @@ -11118,7 +10840,7 @@ async function runServe( stopHeadlessRpcServer(state); } await scopeRegistry.disposeAll(); - if (!isAdeMcpNamedPipePath(socketPath)) { + if (!isAdeRuntimeNamedPipePath(socketPath)) { try { fs.unlinkSync(socketPath); } catch {} @@ -13209,14 +12931,6 @@ async function runCli( throw error; } } - if (plan.kind === "mcp") { - await runMcpServer({ - ...parsed.options, - headless: true, - requireSocket: false, - }); - return { output: "", exitCode: 0 }; - } if (plan.kind === "rpc-stdio") { await runNativeRpcStdio(parsed.options); return { output: "", exitCode: 0 }; diff --git a/apps/ade-cli/src/headlessLinearServices.test.ts b/apps/ade-cli/src/headlessLinearServices.test.ts index 11a9c09da..bb239218b 100644 --- a/apps/ade-cli/src/headlessLinearServices.test.ts +++ b/apps/ade-cli/src/headlessLinearServices.test.ts @@ -89,7 +89,8 @@ vi.mock("../../desktop/src/main/services/automations/automationSecretService", ( createAutomationSecretService: vi.fn(() => ({})), })); -import { createHeadlessLinearServices } from "./headlessLinearServices"; +import { EncryptedFileCredentialStore } from "./services/credentials/credentialStore"; +import { createHeadlessGitHubService, createHeadlessLinearServices } from "./headlessLinearServices"; function createDeps() { return { @@ -434,6 +435,31 @@ describe("headlessLinearServices", () => { } }); + it("reads Linear and GitHub credentials from the shared machine store", () => { + const previousAdeHome = process.env.ADE_HOME; + process.env.ADE_HOME = fs.mkdtempSync(path.join(os.tmpdir(), "ade-headless-shared-credentials-")); + const credentialStore = new EncryptedFileCredentialStore(); + credentialStore.setSync("linear.token.v1", "lin_shared_token"); + credentialStore.setSync("github.token.v1", "ghp_shared_token"); + + const services = createHeadlessLinearServices(createDeps()); + try { + expect(services.linearCredentialService.getTokenOrThrow()).toBe("lin_shared_token"); + const githubService = createHeadlessGitHubService( + "/tmp/ade-project", + { debug() {}, info() {}, warn() {}, error() {} } as any, + ); + expect(githubService.getTokenOrThrow()).toBe("ghp_shared_token"); + } finally { + services.dispose(); + if (previousAdeHome == null) { + delete process.env.ADE_HOME; + } else { + process.env.ADE_HOME = previousAdeHome; + } + } + }); + it("assigns CTO default title for cto identityKey", async () => { const services = createHeadlessLinearServices(createDeps()); diff --git a/apps/ade-cli/src/services/sync/syncHostService.ts b/apps/ade-cli/src/services/sync/syncHostService.ts index 03fd2d585..4bf1045ac 100644 --- a/apps/ade-cli/src/services/sync/syncHostService.ts +++ b/apps/ade-cli/src/services/sync/syncHostService.ts @@ -203,14 +203,14 @@ const PERSISTED_MOBILE_COMMAND_ACTIONS = new Set([ "notification_prefs", "work.runQuickCommand", "work.startCliSession", - "work.closeSession", + "work.sendToSession", + "work.stopRuntime", "processes.start", "processes.stop", "processes.kill", "chat.interrupt", "chat.approve", "chat.respondToInput", - "chat.dispose", "chat.archive", "chat.unarchive", "chat.delete", @@ -254,12 +254,14 @@ function persistedMobileCommandResult(action: string, result: SyncCommandResultP }, }; } - if (action === "work.runQuickCommand" || action === "work.startCliSession") { + if (action === "work.runQuickCommand" || action === "work.startCliSession" || action === "work.sendToSession") { const raw = safeObjectValue(result.result); const replayResult: Record = {}; if (typeof raw?.sessionId === "string") replayResult.sessionId = raw.sessionId; if (typeof raw?.ptyId === "string") replayResult.ptyId = raw.ptyId; - if (action === "work.startCliSession" && safeObjectValue(raw?.session)) replayResult.session = raw?.session; + if ((action === "work.startCliSession" || action === "work.sendToSession") && safeObjectValue(raw?.session)) { + replayResult.session = raw?.session; + } return { commandId: result.commandId, ok: true, diff --git a/apps/ade-cli/src/services/sync/syncRemoteCommandService.ts b/apps/ade-cli/src/services/sync/syncRemoteCommandService.ts index 5ae03b47a..c4a8a2b29 100644 --- a/apps/ade-cli/src/services/sync/syncRemoteCommandService.ts +++ b/apps/ade-cli/src/services/sync/syncRemoteCommandService.ts @@ -3,13 +3,11 @@ import type { AgentChatCreateArgs, AgentChatArchiveArgs, AgentChatApproveArgs, - AgentChatDisposeArgs, AgentChatFileRef, AgentChatGetSummaryArgs, AgentChatListArgs, AgentChatProvider, AgentChatRespondToInputArgs, - AgentChatResumeArgs, AgentChatSendArgs, AgentChatSession, AgentChatSessionSummary, @@ -95,6 +93,8 @@ import type { SyncRemoteCommandAction, SyncRemoteCommandDescriptor, SyncRemoteCommandPolicy, + SyncSendToSessionArgs, + SyncSendToSessionResult, SyncStartCliSessionArgs, SyncStartCliSessionResult, SyncRunQuickCommandArgs, @@ -503,6 +503,9 @@ function parseQuickCommandArgs(value: Record): SyncRunQuickComm const DEFAULT_CLI_COLS = 120; const DEFAULT_CLI_ROWS = 36; +const MAX_CLI_COLS = 400; +const MAX_CLI_ROWS = 200; +const CLAUDE_INITIAL_INPUT_CONFIRM_DELAY_MS = 1200; function clampCliDimension(value: number | undefined, fallback: number, min: number, max: number): number { return Math.max(min, Math.min(max, Math.floor(value ?? fallback))); @@ -533,24 +536,11 @@ function parseStartCliSessionArgs(value: Record): SyncStartCliS initialInput, cols: asOptionalNumber(value.cols), rows: asOptionalNumber(value.rows), - resumeSessionId: asTrimmedString(value.resumeSessionId), + model: asTrimmedString(value.model), + modelId: asTrimmedString(value.modelId), }; } -function requireResumeSessionForProvider( - sessionService: ReturnType, - sessionId: string, - provider: SyncStartCliSessionArgs["provider"], -): TerminalSessionSummary { - const session = sessionService.get(sessionId) as TerminalSessionSummary | null; - if (!session) throw new Error(`work.startCliSession resumeSessionId '${sessionId}' was not found.`); - const existingProvider = launchProfileForTerminalSession(session); - if (existingProvider && existingProvider !== provider) { - throw new Error(`work.startCliSession resumeSessionId '${sessionId}' belongs to ${existingProvider}, not ${provider}.`); - } - return session; -} - function isChatToolType(toolType: string | null | undefined): boolean { if (!toolType) return false; const t = toolType.trim().toLowerCase(); @@ -590,9 +580,19 @@ async function listRemoteWorkSessions( }); } -function parseCloseSessionArgs(value: Record): { sessionId: string } { +function parseSendToSessionArgs(value: Record): SyncSendToSessionArgs { + const text = requireString(value.text, "work.sendToSession requires text."); + return { + sessionId: requireString(value.sessionId, "work.sendToSession requires sessionId."), + text, + cols: asOptionalNumber(value.cols), + rows: asOptionalNumber(value.rows), + }; +} + +function parseStopRuntimeArgs(value: Record): { sessionId: string } { return { - sessionId: requireString(value.sessionId, "work.closeSession requires sessionId."), + sessionId: requireString(value.sessionId, "work.stopRuntime requires sessionId."), }; } @@ -698,12 +698,6 @@ function parseAgentChatInterruptArgs(value: Record): AgentChatI }; } -function parseAgentChatResumeArgs(value: Record): AgentChatResumeArgs { - return { - sessionId: requireString(value.sessionId, "chat.resume requires sessionId."), - }; -} - function parseAgentChatApproveArgs(value: Record): AgentChatApproveArgs { return { sessionId: requireString(value.sessionId, "chat.approve requires sessionId."), @@ -763,12 +757,6 @@ function parseAgentChatUpdateSessionArgs(value: Record): AgentC return parsed; } -function parseAgentChatDisposeArgs(value: Record): AgentChatDisposeArgs { - return { - sessionId: requireString(value.sessionId, "chat.dispose requires sessionId."), - }; -} - function parseAgentChatArchiveArgs(value: Record, action: string): AgentChatArchiveArgs { return { sessionId: requireString(value.sessionId, `${action} requires sessionId.`), @@ -1788,38 +1776,27 @@ export function createSyncRemoteCommandService(args: SyncRemoteCommandServiceArg }); register("work.startCliSession", { viewerAllowed: true, queueable: true }, async (payload) => { const parsed = parseStartCliSessionArgs(payload); - const cols = clampCliDimension(parsed.cols, DEFAULT_CLI_COLS, 20, 240); - const rows = clampCliDimension(parsed.rows, DEFAULT_CLI_ROWS, 4, 120); - const resumeSessionId = parsed.resumeSessionId?.trim() || undefined; + const cols = clampCliDimension(parsed.cols, DEFAULT_CLI_COLS, 20, MAX_CLI_COLS); + const rows = clampCliDimension(parsed.rows, DEFAULT_CLI_ROWS, 4, MAX_CLI_ROWS); const { provider } = parsed; const permissionMode = parsed.permissionMode ?? "default"; validateLaunchProfilePermissionMode(provider, permissionMode); - const resumeSession = resumeSessionId - ? requireResumeSessionForProvider(args.sessionService, resumeSessionId, provider) - : null; const toolType = LAUNCH_PROFILE_TOOL_TYPE[provider] as TerminalToolType; const title = parsed.title?.trim() || LAUNCH_PROFILE_TITLE[provider]; - const preassignedSessionId = provider === "claude" && !resumeSessionId ? randomUUID() : undefined; + const preassignedSessionId = provider === "claude" ? randomUUID() : undefined; function resolveLaunch(): { startupCommand?: string; command?: string; args?: string[]; env?: Record } { if (provider === "shell") return {}; - if (resumeSessionId) { - if (!resumeSession) throw new Error(`work.startCliSession resumeSessionId '${resumeSessionId}' was not found.`); - const startupCommand = resolveTrackedCliResumeCommand(resumeSession) - ?? buildTrackedCliResumeCommand({ - provider, - targetKind: "session", - targetId: null, - launch: { permissionMode }, - }); - return { startupCommand }; - } - return buildTrackedCliLaunchCommand({ provider, permissionMode, sessionId: preassignedSessionId }); + return buildTrackedCliLaunchCommand({ + provider, + permissionMode, + sessionId: preassignedSessionId, + model: parsed.modelId ?? parsed.model ?? undefined, + }); } - const sessionId = resumeSessionId ?? preassignedSessionId; const result = await args.ptyService.create({ - ...(sessionId ? { sessionId } : {}), + ...(preassignedSessionId ? { sessionId: preassignedSessionId } : {}), allowNewSessionId: Boolean(preassignedSessionId), laneId: parsed.laneId, title, @@ -1843,6 +1820,18 @@ export function createSyncRemoteCommandService(args: SyncRemoteCommandServiceArg } throw new Error("work.startCliSession created a terminal session but could not write initialInput."); } + if (provider === "claude") { + const confirmTimer = setTimeout(() => { + const confirmWritten = args.ptyService.writeBySessionId(result.sessionId, "\r"); + if (!confirmWritten) { + args.logger.warn("sync_remote.start_cli_session_claude_initial_input_confirm_failed", { + sessionId: result.sessionId, + ptyId: result.ptyId, + }); + } + }, CLAUDE_INITIAL_INPUT_CONFIRM_DELAY_MS); + confirmTimer.unref?.(); + } } const session = args.sessionService.get(result.sessionId); @@ -1853,15 +1842,24 @@ export function createSyncRemoteCommandService(args: SyncRemoteCommandServiceArg session: enriched, } satisfies SyncStartCliSessionResult; }); - register("work.closeSession", { viewerAllowed: true, queueable: true }, async (payload) => { - const { sessionId } = parseCloseSessionArgs(payload); + register("work.sendToSession", { viewerAllowed: true, queueable: true }, async (payload) => { + const parsed = parseSendToSessionArgs(payload); + const result = await args.ptyService.sendToSession({ + sessionId: parsed.sessionId, + text: parsed.text, + ...(parsed.cols != null ? { cols: parsed.cols } : {}), + ...(parsed.rows != null ? { rows: parsed.rows } : {}), + }); + return result satisfies SyncSendToSessionResult; + }); + register("work.stopRuntime", { viewerAllowed: true, queueable: true }, async (payload) => { + const { sessionId } = parseStopRuntimeArgs(payload); const session = args.sessionService.get(sessionId); if (session?.ptyId) { await args.ptyService.dispose({ ptyId: session.ptyId, sessionId }); } return { ok: true }; }); - register("processes.listDefinitions", { viewerAllowed: true }, async () => requireService(args.processService, "Process service not available.").listDefinitions()); register("processes.listRuntime", { viewerAllowed: true }, async (payload) => @@ -1935,20 +1933,15 @@ export function createSyncRemoteCommandService(args: SyncRemoteCommandServiceArg await requireService(args.agentChatService, "Agent chat service not available.").respondToInput(parseAgentChatRespondToInputArgs(payload)); return { ok: true }; }); - register("chat.resume", { viewerAllowed: true, queueable: true }, async (payload) => - requireService(args.agentChatService, "Agent chat service not available.").resumeSession(parseAgentChatResumeArgs(payload))); // Restart: fired by iOS Live Activity + Attention Drawer "Restart" pill on - // a failed agent. Alias to resumeSession — same runtime-rewire behaviour. - // Keep as a distinct action name so telemetry can distinguish explicit - // restart intent from ordinary resume. + // a failed agent. Keep this explicit failure-recovery action distinct from + // ordinary continuation, which is handled by chat.send. register("chat.restart", { viewerAllowed: true, queueable: true }, async (payload) => - requireService(args.agentChatService, "Agent chat service not available.").resumeSession(parseAgentChatResumeArgs(payload))); + requireService(args.agentChatService, "Agent chat service not available.").resumeSession({ + sessionId: requireString(payload.sessionId, "chat.restart requires sessionId."), + })); register("chat.updateSession", { viewerAllowed: true, queueable: true }, async (payload) => requireService(args.agentChatService, "Agent chat service not available.").updateSession(parseAgentChatUpdateSessionArgs(payload))); - register("chat.dispose", { viewerAllowed: true, queueable: true }, async (payload) => { - await requireService(args.agentChatService, "Agent chat service not available.").dispose(parseAgentChatDisposeArgs(payload)); - return { ok: true }; - }); register("chat.archive", { viewerAllowed: true, queueable: true }, async (payload) => { await requireService(args.agentChatService, "Agent chat service not available.").archiveSession(parseAgentChatArchiveArgs(payload, "chat.archive")); return { ok: true }; diff --git a/apps/ade-cli/src/tuiClient/__tests__/ChatView.test.tsx b/apps/ade-cli/src/tuiClient/__tests__/ChatView.test.tsx index c93bcc81e..4d3976b72 100644 --- a/apps/ade-cli/src/tuiClient/__tests__/ChatView.test.tsx +++ b/apps/ade-cli/src/tuiClient/__tests__/ChatView.test.tsx @@ -1,8 +1,18 @@ import React from "react"; import { describe, expect, it } from "vitest"; import { render } from "ink-testing-library"; -import { ChatView, computeChatScrollMaxOffset, renderChatTranscriptPlainText } from "../components/ChatView"; +import { + ChatView, + computeChatScrollMaxOffset, + renderChatTranscriptPlainText, + selectedTextFromVisibleChatRows, +} from "../components/ChatView"; import { buildSubagentTranscriptEvents } from "../subagentPane"; +import { + parseAssistantMarkdown, + parseInlineRuns, + type AssistantMarkdownBlock, +} from "../format"; import type { AgentChatEventEnvelope, AgentChatSessionSummary } from "../../../../desktop/src/shared/types/chat"; const session: AgentChatSessionSummary = { @@ -24,7 +34,7 @@ function stripAnsi(value: string): string { function renderEvents( events: AgentChatEventEnvelope[], - options: { maxRows?: number; scrollOffsetRows?: number; width?: number; streaming?: boolean } = {}, + options: { maxRows?: number; scrollOffsetRows?: number; width?: number; streaming?: boolean; interrupted?: boolean } = {}, ): string { const result = render( { + it("copies only the selected chat row columns", () => { + expect(selectedTextFromVisibleChatRows( + ["alpha bravo", "charlie delta", "echo"], + { startRow: 0, startColumn: 6, endRow: 1, endColumn: 6 }, + )).toBe("bravo\ncharlie"); + }); + it("renders a bordered hero card with the ADE wordmark when the chat is empty", () => { const frame = renderEvents([]); // Hero card uses a bordered box @@ -115,6 +133,21 @@ describe("ChatView", () => { expect(frame).toContain("model working"); }); + it("shows interrupted state where the working indicator normally appears", () => { + const frame = renderEvents([ + { + sessionId: "s1", + timestamp: "2026-01-01T12:00:00.000Z", + sequence: 1, + event: { type: "user_message", text: "stop this", turnId: "turn-active" }, + }, + ], { interrupted: true, width: 80 }); + + expect(frame).toContain("stop this"); + expect(frame).toContain("Interrupted · chat to continue"); + expect(frame).not.toContain("model working"); + }); + it("renders context compaction as an explicit active state", () => { const frame = renderEvents([ { @@ -412,7 +445,7 @@ describe("ChatView", () => { expect(transcriptLines(frame).at(-1)).toContain("↓ newer messages"); }); - it("renders single command in a work block with bash header and command arg", () => { + it("renders single command in a tool-calls-group with shell label and command", () => { const frame = renderEvents([ { sessionId: "s1", @@ -421,12 +454,12 @@ describe("ChatView", () => { event: { type: "command", command: "git branch", cwd: "/repo", output: "main", itemId: "cmd-1", status: "completed", exitCode: 0, durationMs: 12 }, }, ], { width: 100 }); - expect(frame).toMatch(/▸\s+working/); - expect(frame).toMatch(/✓ bash/); + expect(frame).toMatch(/▸\s+Tool calls \(1\)/); + expect(frame).toContain("shell"); expect(frame).toContain("git branch"); }); - it("groups consecutive tool calls into a live work block with counter and last tool row", () => { + it("splits consecutive tool-calls and file_changes into typed groups within one turn", () => { const turnId = "turn-live"; const events: AgentChatEventEnvelope[] = [ { @@ -449,9 +482,12 @@ describe("ChatView", () => { }, ]; const frame = renderEvents(events, { width: 100 }); - expect(frame).toMatch(/working · 3 tools so far/); - // Newest tool (typecheck) is on top of the live list. + // Two tool-calls-groups (one before the file_change, one after) plus the files group. + expect(frame).toContain("Tool calls (1)"); + expect(frame).toContain("1 file changed"); + expect(frame).toContain("npm test"); expect(frame).toContain("npm run typecheck"); + expect(frame).toContain("auth.ts"); }); it("keeps subagent lifecycle and child tool chatter out of the center transcript", () => { @@ -534,7 +570,7 @@ describe("ChatView", () => { }, ], { width: 100 }); - expect(frame).toContain("working · 1 tool so far"); + expect(frame).toContain("Tool calls (1)"); expect(frame).toContain("spawn_agent"); expect(frame).toContain("Explore renderer"); expect(frame).not.toContain("child launch spam"); @@ -605,7 +641,7 @@ describe("ChatView", () => { expect(transcriptBody).not.toContain("unrelated agent result"); }); - it("collapses work block on done with failed tool surfaced alongside last ok", () => { + it("collapses tool-calls-group on done with failed and ok summary", () => { const turnId = "turn-done"; const events: AgentChatEventEnvelope[] = [ { @@ -640,13 +676,44 @@ describe("ChatView", () => { }, ]; const frame = renderEvents(events, { width: 100 }); - expect(frame).toMatch(/▸\s+4 tools/); + expect(frame).toMatch(/▸\s+Tool calls \(4\)/); expect(frame).toMatch(/3 ok/); expect(frame).toMatch(/1 failed/); - // Failed tool surfaced. + // Most recent shell commands visible. expect(frame).toContain("npm test"); - // Last successful tool also surfaced. expect(frame).toContain("echo two"); + expect(frame).not.toContain("8.3s"); + }); + + it("hides missing and zero tool durations while preserving valid per-call durations", () => { + const turnId = "turn-durations"; + const events: AgentChatEventEnvelope[] = [ + { + sessionId: "s1", + timestamp: "2026-01-01T12:00:00.000Z", + sequence: 1, + event: { type: "command", command: "instant", cwd: "/repo", output: "", itemId: "c1", status: "completed", exitCode: 0, durationMs: 0, turnId }, + }, + { + sessionId: "s1", + timestamp: "2026-01-01T12:00:01.000Z", + sequence: 2, + event: { type: "command", command: "measured", cwd: "/repo", output: "", itemId: "c2", status: "completed", exitCode: 0, durationMs: 12, turnId }, + }, + { + sessionId: "s1", + timestamp: "2026-01-01T12:24:00.000Z", + sequence: 3, + event: { type: "done", turnId, status: "completed" }, + }, + ]; + + const frame = renderEvents(events, { width: 100 }); + expect(frame).toContain("instant"); + expect(frame).toContain("measured"); + expect(frame).toContain("12ms"); + expect(frame).not.toContain("0ms"); + expect(frame).not.toContain("24m"); }); it("hides reasoning-only events from the quiet transcript", () => { @@ -713,6 +780,238 @@ describe("ChatView", () => { expect(text).not.toContain("gpt"); }); + it("caps tool-calls-group at 3 visible entries with a + N more affordance", () => { + const turnId = "turn-many"; + const events: AgentChatEventEnvelope[] = Array.from({ length: 12 }, (_, index): AgentChatEventEnvelope => ({ + sessionId: "s1", + timestamp: `2026-01-01T12:00:${String(index).padStart(2, "0")}.000Z`, + sequence: index + 1, + event: { + type: "command", + command: `cmd-${index + 1}`, + cwd: "/repo", + output: "", + itemId: `c${index + 1}`, + status: "completed", + exitCode: 0, + durationMs: 100, + turnId, + }, + })); + const frame = renderEvents(events, { width: 120 }); + expect(frame).toContain("Tool calls (12)"); + expect(frame).toContain("+ 9 more"); + // Most recent 3 visible (cmd-12, cmd-11, cmd-10); older ones in the "+N more" tail. + expect(frame).toContain("cmd-12"); + expect(frame).toContain("cmd-10"); + expect(frame).not.toContain("cmd-5"); + }); + + it("strips the /bin/zsh -lc launcher wrapper from shell commands", () => { + const events: AgentChatEventEnvelope[] = [ + { + sessionId: "s1", + timestamp: "2026-01-01T12:00:00.000Z", + sequence: 1, + event: { + type: "command", + command: "/bin/zsh -lc \"git status --short\"", + cwd: "/repo", + output: "", + itemId: "c1", + status: "completed", + exitCode: 0, + durationMs: 12, + }, + }, + { + sessionId: "s1", + timestamp: "2026-01-01T12:00:01.000Z", + sequence: 2, + event: { + type: "command", + command: "/bin/bash -c 'npm test'", + cwd: "/repo", + output: "", + itemId: "c2", + status: "completed", + exitCode: 0, + durationMs: 1500, + }, + }, + ]; + const frame = renderEvents(events, { width: 120 }); + expect(frame).toContain("git status --short"); + expect(frame).toContain("npm test"); + // Launcher prefix is gone. + expect(frame).not.toContain("/bin/zsh -lc"); + expect(frame).not.toContain("/bin/bash -c"); + }); + + it("renders files-changed-group with typed badges per extension", () => { + const events: AgentChatEventEnvelope[] = [ + { + sessionId: "s1", + timestamp: "2026-01-01T12:00:00.000Z", + sequence: 1, + event: { type: "file_change", path: "src/Component.tsx", diff: "+a\n+b\n-c", kind: "modify", itemId: "f1", status: "completed", turnId: "t1" }, + }, + { + sessionId: "s1", + timestamp: "2026-01-01T12:00:01.000Z", + sequence: 2, + event: { type: "file_change", path: "src/legacy.js", diff: "", kind: "delete", itemId: "f2", status: "completed", turnId: "t1" }, + }, + { + sessionId: "s1", + timestamp: "2026-01-01T12:00:02.000Z", + sequence: 3, + event: { type: "file_change", path: "docs/notes.md", diff: "+line1", kind: "create", itemId: "f3", status: "completed", turnId: "t1" }, + }, + ]; + const frame = renderEvents(events, { width: 120 }); + expect(frame).toContain("3 files changed"); + expect(frame).toContain("TSX"); + expect(frame).toContain("JS"); + expect(frame).toContain("MD"); + expect(frame).toContain("Component.tsx"); + expect(frame).toContain("Deleted"); + }); + + it("renders fenced code with highlight.js-derived per-line tokens", () => { + const events: AgentChatEventEnvelope[] = [ + { + sessionId: "s1", + timestamp: "2026-01-01T12:00:00.000Z", + sequence: 1, + event: { type: "text", text: "Here's a snippet:\n\n```ts\nconst x = 1;\nconst y = \"hi\";\n```" }, + }, + ]; + const frame = renderEvents(events, { width: 80 }); + // Code-fence border + expect(frame).toMatch(/┌.*ts/); + expect(frame).toContain("const x = 1;"); + expect(frame).toContain('const y = "hi";'); + // Closing border + expect(frame).toContain("└"); + }); + + it("preserves inline-code markers when flattening list-item / paragraph text", () => { + // Regression guard: flattenInlineTokensToText used to drop backticks when + // reconstructing bullet text, so downstream re-tokenization saw plain text + // and the inline-code violet styling never fired. Verify the parsed + // AssistantMarkdownBlock text still contains the backticks so the renderer + // can re-detect codespans. + const blocks = parseAssistantMarkdown( + "What changed:\n- Split `LaneRuntimeBar` refresh work\n- Updated `cli.ts` parity\n\nAlso ran `npm test`.", + ); + const bullets = blocks.filter((b) => b.kind === "bullet") as Array>; + expect(bullets).toHaveLength(2); + expect(bullets[0]!.text).toContain("`LaneRuntimeBar`"); + expect(bullets[1]!.text).toContain("`cli.ts`"); + const trailingParagraph = [...blocks].reverse().find((b) => b.kind === "paragraph") as Extract | undefined; + expect(trailingParagraph?.text).toContain("`npm test`"); + + // And confirm parseInlineRuns produces a run with code: true for those. + const runs = parseInlineRuns(bullets[0]!.text); + expect(runs.some((r) => r.code && r.text === "LaneRuntimeBar")).toBe(true); + }); + + it("does not render the left vertical rail bar on assistant lines", () => { + const events: AgentChatEventEnvelope[] = [ + { + sessionId: "s1", + timestamp: "2026-01-01T12:00:00.000Z", + sequence: 1, + event: { type: "text", text: "plain assistant text" }, + }, + ]; + const frame = renderEvents(events, { width: 80 }); + expect(frame).toContain("plain assistant text"); + // The rail prefix was "▎ " on every assistant line; assert it's gone. + expect(frame).not.toMatch(/▎\s+plain assistant/); + }); + + it("adds a single blank line between bullets for readability", () => { + const events: AgentChatEventEnvelope[] = [ + { + sessionId: "s1", + timestamp: "2026-01-01T12:00:00.000Z", + sequence: 1, + event: { type: "text", text: "What changed:\n- First item\n- Second item\n- Third item" }, + }, + ]; + const frame = renderEvents(events, { width: 80 }); + const lines = transcriptLines(frame).map((line) => line.trimEnd()); + const bulletLines = lines.filter((line) => line.trim().startsWith("•")); + expect(bulletLines.length).toBe(3); + // Each consecutive bullet should sit two lines apart (one blank between). + const indices = bulletLines.map((bullet) => lines.indexOf(bullet)); + for (let i = 1; i < indices.length; i += 1) { + expect(indices[i]! - indices[i - 1]!).toBe(2); + } + }); + + it("shows the \"↓ N new messages\" pill when scrolled up and new events arrived", () => { + // 200 user/assistant alternating events generates enough rows to overflow the + // 10-row viewport at width=80, so the offset path in sliceRows fires. + const events = Array.from({ length: 200 }, (_, index): AgentChatEventEnvelope => ({ + sessionId: "s1", + timestamp: `2026-01-01T12:00:${String(Math.floor(index / 60)).padStart(2, "0")}:${String(index % 60).padStart(2, "0")}.000Z`, + sequence: index + 1, + event: index % 2 === 0 + ? { type: "user_message", text: `user row ${String(index + 1).padStart(3, "0")}` } + : { type: "text", text: `assistant row ${String(index + 1).padStart(3, "0")}` }, + })); + const maxRows = 10; + const maxOffset = computeChatScrollMaxOffset({ + events, + notices: [], + activeSession: session, + maxRows, + width: 80, + }); + expect(maxOffset).toBeGreaterThan(50); + const result = render( + , + ); + const frame = stripAnsi(result.lastFrame() ?? ""); + expect(frame).toMatch(/↓ 4 new messages/); + expect(frame).toContain("press End"); + }); + + it("falls back to the generic \"↓ newer messages\" indicator when no unseen count is supplied", () => { + const events = Array.from({ length: 200 }, (_, index): AgentChatEventEnvelope => ({ + sessionId: "s1", + timestamp: `2026-01-01T12:00:${String(Math.floor(index / 60)).padStart(2, "0")}:${String(index % 60).padStart(2, "0")}.000Z`, + sequence: index + 1, + event: index % 2 === 0 + ? { type: "user_message", text: `user row ${String(index + 1).padStart(3, "0")}` } + : { type: "text", text: `assistant row ${String(index + 1).padStart(3, "0")}` }, + })); + const maxRows = 10; + const maxOffset = computeChatScrollMaxOffset({ + events, + notices: [], + activeSession: session, + maxRows, + width: 80, + }); + const frame = renderEvents(events, { maxRows, scrollOffsetRows: maxOffset, width: 80 }); + expect(frame).toContain("↓ newer messages"); + expect(frame).not.toMatch(/↓ \d+ new/); + }); + it("omits scroll affordances from copied transcript text", () => { const events = Array.from({ length: 8 }, (_, index): AgentChatEventEnvelope => ({ sessionId: "s1", diff --git a/apps/ade-cli/src/tuiClient/__tests__/HeaderFooter.test.tsx b/apps/ade-cli/src/tuiClient/__tests__/HeaderFooter.test.tsx index 3abc5ad00..b3f534812 100644 --- a/apps/ade-cli/src/tuiClient/__tests__/HeaderFooter.test.tsx +++ b/apps/ade-cli/src/tuiClient/__tests__/HeaderFooter.test.tsx @@ -6,7 +6,7 @@ import { FooterControls } from "../components/FooterControls"; import type { LaneSummary } from "../../../../desktop/src/shared/types/lanes"; function stripAnsi(text: string): string { - return text.replace(/\u001b\[[0-?]*[ -/]*[@-~]/g, ""); + return text.replace(/\[[0-?]*[ -/]*[@-~]/g, ""); } function lane(overrides: Partial = {}): LaneSummary { @@ -78,14 +78,9 @@ describe("Header", () => { }); describe("FooterControls", () => { - it("labels the right pane as info and keeps fast with model mode info", () => { + it("renders the flat picker cells with provider, model, permission, and fast indicator", () => { const result = render( { ); const frame = stripAnsi(result.lastFrame() ?? ""); - expect(frame).toContain("info"); expect(frame).toContain("GPT-5.5"); expect(frame).toContain("fast"); expect(frame).toContain("full-auto"); - expect(frame).not.toContain("setup"); }); - it("shows chat scroll controls when the transcript is focused", () => { + it("renders the resting hint strip with lanes/info/subagents/cmds/help", () => { const result = render( , ); const frame = stripAnsi(result.lastFrame() ?? ""); - expect(frame).toContain("scroll"); - expect(frame).toContain("Tab lanes"); - expect(frame).not.toContain("↑↓ lanes"); + expect(frame).toContain("^o"); + expect(frame).toContain("lanes"); + expect(frame).toContain("^p"); + expect(frame).toContain("info"); + expect(frame).toContain("^a"); + expect(frame).toContain("subagents"); + expect(frame).toContain("cmds"); + expect(frame).toContain("help"); }); - it("shows the clamped chat scroll position in the footer", () => { + it("renders a focus indicator and cell hints when the inline row is focused", () => { const result = render( , ); const frame = stripAnsi(result.lastFrame() ?? ""); - expect(frame).toContain("42/42"); + expect(frame).toContain("▸"); + // The focused cell is wrapped in brackets. + expect(frame).toContain("[GPT-5.5]"); + // Focused row shows up/cells/cycle hints instead of the resting strip. + expect(frame).toContain("prompt"); + expect(frame).toContain("cells"); + expect(frame).toContain("cycle"); + expect(frame).not.toContain("^o lanes"); }); - it("surfaces staged steer state in the footer", () => { + it("renders reasoning and permission picker cells", () => { const result = render( , ); const frame = stripAnsi(result.lastFrame() ?? ""); - expect(frame).toContain("staged 2"); - expect(frame).toContain("/steer"); + expect(frame).toContain("claude-opus"); + expect(frame).toContain("high"); + expect(frame).toContain("acceptEdits"); }); - it("shows an agents pane toggle when the active chat has subagent history", () => { + it("renders the subagents button when visible and counts agents", () => { const result = render( , - ); - const frame = stripAnsi(result.lastFrame() ?? ""); - - expect(frame).toContain("agents"); - expect(frame).toContain("^a"); - expect(frame.indexOf("agents")).toBeLessThan(frame.indexOf("lanes")); - expect(frame.indexOf("lanes")).toBeLessThan(frame.indexOf("info")); - expect(frame).not.toContain("● 2 agents"); - }); - - it("shows lane navigation controls when the drawer lane list is focused", () => { - const result = render( - , ); const frame = stripAnsi(result.lastFrame() ?? ""); - expect(frame).toContain("↑↓ lanes"); - expect(frame).toContain("↵ open"); - expect(frame).not.toContain("scroll"); + expect(frame).toContain("2 subagents"); }); - it("shows chat list controls when drawer chats are focused", () => { + it("renders the approval prompt hints when an approval is active", () => { const result = render( , ); const frame = stripAnsi(result.lastFrame() ?? ""); - expect(frame).toContain("↑↓ chats"); - expect(frame).toContain("Esc lanes"); - expect(frame).not.toContain("scroll"); + expect(frame).toContain("approve"); + expect(frame).toContain("deny"); + expect(frame).not.toContain("^o lanes"); }); }); diff --git a/apps/ade-cli/src/tuiClient/__tests__/Palettes.test.tsx b/apps/ade-cli/src/tuiClient/__tests__/Palettes.test.tsx index dea8d1b0f..42c0a5de4 100644 --- a/apps/ade-cli/src/tuiClient/__tests__/Palettes.test.tsx +++ b/apps/ade-cli/src/tuiClient/__tests__/Palettes.test.tsx @@ -103,4 +103,26 @@ describe("SlashPalette", () => { expect(frame).not.toContain("Group"); expect(frame.split("\n")[0]?.length).toBeGreaterThanOrEqual(90); }); + + it("keeps slash palette rows boxed instead of wrapping mid-row", () => { + const frame = stripAnsi(render( + , + ).lastFrame() ?? ""); + const lines = frame.split("\n").filter(Boolean); + + expect(lines).toHaveLength(8); + expect(lines.every((line) => /^[┌│└]/.test(line) && /[┐│┘]$/.test(line))).toBe(true); + expect(frame).toContain("/feedback"); + expect(frame).toContain("Submit ADE feedback to GitHub issues"); + expect(frame).not.toContain("Runtime feedback command"); + }); }); diff --git a/apps/ade-cli/src/tuiClient/__tests__/RightPane.test.tsx b/apps/ade-cli/src/tuiClient/__tests__/RightPane.test.tsx index 7b08a15c8..8bc0e9e65 100644 --- a/apps/ade-cli/src/tuiClient/__tests__/RightPane.test.tsx +++ b/apps/ade-cli/src/tuiClient/__tests__/RightPane.test.tsx @@ -3,129 +3,11 @@ import { describe, expect, it } from "vitest"; import { render } from "ink-testing-library"; import { RightPane } from "../components/RightPane"; import type { LaneSummary } from "../../../../desktop/src/shared/types/lanes"; -import type { ProviderReadinessRow, RightPaneContent, SetupPaneRow } from "../types"; - -const setupRows: SetupPaneRow[] = [ - { kind: "provider", label: "Provider", value: "Codex", cyclable: true }, - { kind: "model", label: "Model", value: "GPT-5.5", cyclable: true, detail: "5 available" }, - { kind: "reasoning", label: "Reasoning", value: "medium", cyclable: true, detail: "low, medium, high" }, - { kind: "permission", label: "Permissions", value: "default", cyclable: true }, - { kind: "codex-fast", label: "Fast mode", value: "off", cyclable: true, detail: "Codex service tier" }, - { kind: "refresh-status", label: "Refresh status", value: "run", detail: "checks provider auth/runtime state" }, - { kind: "open-settings", label: "Full settings", value: "open desktop", detail: "Settings > AI Providers" }, -]; - -const providerRows: ProviderReadinessRow[] = [ - { provider: "codex", label: "Codex", status: "ready", detail: "ready at /usr/local/bin/codex", modelCount: 6 }, - { provider: "claude", label: "Claude", status: "ready", detail: "ready at /usr/local/bin/claude", modelCount: 4 }, - { provider: "cursor", label: "Cursor", status: "unknown", detail: "API key store not yet readable", modelCount: 0 }, - { provider: "droid", label: "Droid", status: "unavailable", detail: "no Factory Droid CLI or FACTORY_API_KEY", modelCount: 0 }, - { provider: "opencode", label: "OpenCode", status: "ready", detail: "user-installed · 0 shared runtime", modelCount: 4442 }, -]; function stripAnsi(text: string): string { - return text.replace(/\u001b\[[0-?]*[ -/]*[@-~]/g, ""); -} - -function content(overrides: Partial> = {}): RightPaneContent { - return { - kind: "model-setup", - rows: setupRows, - providerRows, - activeProvider: "codex", - checkedAt: "2026-05-09T19:57:09.000Z", - desktopAttached: true, - ...overrides, - }; + return text.replace(/\[[0-?]*[ -/]*[@-~]/g, ""); } -function renderModelSetup(selectedIndex: number, overrides: Partial> = {}): string { - const result = render( - , - ); - return result.lastFrame() ?? ""; -} - -describe("RightPane model-setup", () => { - it("renders PROVIDER tab strip + MODEL section header", () => { - const frame = renderModelSetup(0); - expect(frame).toContain("PROVIDER"); - expect(frame).toContain("MODEL"); - }); - - it("renders all five providers as compact brand chips", () => { - const frame = stripAnsi(renderModelSetup(0)); - expect(frame).toContain("[● Codex]"); - expect(frame).toContain("[● Claude]"); - expect(frame).toContain("[● Cursor]"); - expect(frame).toContain("[● Droid]"); - expect(frame).toContain("[● OpenCode]"); - expect(frame).not.toContain("◇ Codex"); - expect(frame).not.toContain("◆ Claude"); - }); - - it("renders the STATUS readiness section for the active provider", () => { - const frame = renderModelSetup(0); - expect(frame).toContain("STATUS · CODEX"); - }); - - it("uses the compact target title and active-first provider order in wide mode", () => { - const result = render( - , - ); - const frame = stripAnsi(result.lastFrame() ?? ""); - - expect(frame).toContain("SETUP · MODEL"); - expect(frame.indexOf("Claude")).toBeLessThan(frame.indexOf("Codex")); - }); - - it("renders output style as a model setup row when provided", () => { - const result = render( - , - ); - const frame = stripAnsi(result.lastFrame() ?? ""); - - expect(frame).toContain("Output style"); - expect(frame).toContain("default · concise · verbose"); - }); - - it("renders provider tab readiness legend", () => { - const frame = renderModelSetup(0); - expect(frame).toContain("ready"); - expect(frame).toContain("active"); - expect(frame).toContain("needs login"); - }); - - it("renders the footer hint row", () => { - const frame = renderModelSetup(0); - expect(frame).toContain("provider"); - expect(frame).toContain("apply"); - expect(frame).toContain("login"); - }); -}); - describe("RightPane subagents", () => { it("renders an agents process table with main, subagents, and teammates", () => { const result = render( @@ -155,6 +37,23 @@ describe("RightPane subagents", () => { expect(frame).toContain("transcript follows"); }); + it("renders the empty-state copy when no subagents have run yet", () => { + const result = render( + , + ); + const frame = stripAnsi(result.lastFrame() ?? ""); + + expect(frame).toContain("No subagents yet."); + }); + it("renders a single tab + placeholder for Droid (no subagents in ACP)", () => { const result = render( ({ + text: char, + fg: null, + bg: null, + fgMode: "default", + bgMode: "default", + })), + }; +} + +function preview( + rows: TerminalSnapshotRow[], + overrides: Partial> & { + status?: "running" | "completed" | "failed" | "disposed"; + runtimeState?: "running" | "waiting-input" | "idle" | "exited" | "killed"; + resumeCommand?: string | null; + } = {}, +): ChatTerminalPreviewResult { + return { + terminalId: "terminal-1", + source: "snapshot", + snapshot: { + version: 1, + terminalId: "terminal-1", + cols: 80, + rows: rows.length, + capturedAt: "2026-05-13T12:00:00.000Z", + status: "running", + runtimeState: "running", + bufferType: "normal", + cursorX: 0, + cursorY: 0, + baseY: 0, + viewportY: 0, + serialized: "", + visibleRows: rows, + }, + transcript: overrides.transcript ?? null, + capturedAt: "2026-05-13T12:00:00.000Z", + session: { + terminalId: "terminal-1", + ptyId: "pty-1", + chatSessionId: null, + laneId: "lane-1", + laneName: "Lane 1", + title: "Claude Code", + toolType: "claude", + goal: null, + status: overrides.status ?? "running", + runtimeState: overrides.runtimeState ?? "running", + active: (overrides.status ?? "running") === "running", + startedAt: "2026-05-13T12:00:00.000Z", + endedAt: overrides.status && overrides.status !== "running" ? "2026-05-13T12:01:00.000Z" : null, + exitCode: null, + pid: 123, + resumeCommand: overrides.resumeCommand ?? null, + lastOutputPreview: null, + summary: null, + }, + }; +} + +describe("TerminalPane", () => { + it("renders the visible snapshot rows instead of Claude's hidden prompt rows", () => { + const result = render( + stuck prompt"), + row("auto mode on"), + ])} + liveChunks={[]} + attached={false} + width={80} + height={4} + hiddenBottomRows={2} + />, + ); + const frame = stripAnsi(result.lastFrame() ?? ""); + + expect(frame).toContain("visible one"); + expect(frame).toContain("visible four"); + expect(frame).not.toContain("> stuck prompt"); + expect(frame).not.toContain("auto mode on"); + }); + + it("groups snapshot cells by color and style", () => { + const rows = styledRowsFromSnapshotRows([ + { + text: "go", + wrapped: false, + cells: [ + { text: "g", fg: 1, bg: null, fgMode: "palette", bgMode: "default", bold: true }, + { text: "o", fg: 1, bg: null, fgMode: "palette", bgMode: "default", bold: true }, + ], + }, + ], 1); + + expect(rows[0]?.runs).toHaveLength(1); + expect(rows[0]?.runs[0]?.text).toBe("go"); + expect(rows[0]?.runs[0]?.style.color).toBe("#cd3131"); + expect(rows[0]?.runs[0]?.style.bold).toBe(true); + }); + + it("uses transcript history for closed terminal sessions instead of the final resume-only snapshot", async () => { + const result = render( + , + ); + await new Promise((resolve) => setTimeout(resolve, 0)); + const frame = stripAnsi(result.lastFrame() ?? ""); + + expect(frame).toContain("older output"); + expect(frame).toContain("final answer"); + }); + + it("strips short terminal save and restore escapes from transcript fallback", async () => { + const result = render( + , + ); + await new Promise((resolve) => setTimeout(resolve, 0)); + const frame = stripAnsi(result.lastFrame() ?? ""); + + expect(frame).toContain("older output"); + expect(frame).toContain("final answer"); + expect(frame).not.toContain("\u001b7"); + expect(frame).not.toContain("\u001b8"); + }); +}); diff --git a/apps/ade-cli/src/tuiClient/__tests__/adeApi.test.ts b/apps/ade-cli/src/tuiClient/__tests__/adeApi.test.ts index 4d7f6e492..168309532 100644 --- a/apps/ade-cli/src/tuiClient/__tests__/adeApi.test.ts +++ b/apps/ade-cli/src/tuiClient/__tests__/adeApi.test.ts @@ -3,7 +3,7 @@ import os from "node:os"; import path from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; import type { AgentChatEventEnvelope } from "../../../../desktop/src/shared/types/chat"; -import { cancelSteerMessage, createChatSession, DEFAULT_CODEX_REASONING_EFFORT, dispatchSteerMessage, discoverProjectSlashCommands, editSteerMessage, latestGoal, latestTokenStats, listLaneDiffStats, listPrsByLane, sendChatMessage, steerChatMessage } from "../adeApi"; +import { cancelSteerMessage, createChatSession, DEFAULT_CODEX_REASONING_EFFORT, dispatchSteerMessage, discoverProjectSlashCommands, editSteerMessage, latestGoal, latestTokenStats, listLaneDiffStats, listPrsByLane, sendChatMessage, signalTerminal, steerChatMessage } from "../adeApi"; import type { AdeCodeConnection } from "../types"; const tmpPaths: string[] = []; @@ -345,6 +345,27 @@ describe("listPrsByLane", () => { }); }); +describe("signalTerminal", () => { + it("routes terminal signals through the terminal action domain", async () => { + const calls: Array<{ domain: string; action: string; args?: Record }> = []; + const connection = { + action: async (domain: string, action: string, args?: Record) => { + calls.push({ domain, action, args }); + }, + } as unknown as AdeCodeConnection; + + await signalTerminal(connection, "terminal-1", "SIGTERM"); + + expect(calls).toEqual([ + { + domain: "terminal", + action: "signal", + args: { terminalId: "terminal-1", signal: "SIGTERM" }, + }, + ]); + }); +}); + describe("sendChatMessage", () => { it("forwards chat text unchanged and waits until the shared runtime has accepted the turn", async () => { const calls: Array<{ domain: string; action: string; argsList: unknown[] }> = []; diff --git a/apps/ade-cli/src/tuiClient/__tests__/aggregate.test.ts b/apps/ade-cli/src/tuiClient/__tests__/aggregate.test.ts new file mode 100644 index 000000000..b7b609f51 --- /dev/null +++ b/apps/ade-cli/src/tuiClient/__tests__/aggregate.test.ts @@ -0,0 +1,271 @@ +import { describe, expect, it } from "vitest"; +import { aggregateChatBlocks, type AggregatedBlock } from "../aggregate"; +import type { AgentChatEvent, AgentChatEventEnvelope } from "../../../../desktop/src/shared/types/chat"; + +let sequenceCounter = 0; +function env(timestamp: string, event: AgentChatEvent): AgentChatEventEnvelope { + sequenceCounter += 1; + return { sessionId: "s1", timestamp, sequence: sequenceCounter, event }; +} + +function aggregate(events: AgentChatEventEnvelope[]): AggregatedBlock[] { + return aggregateChatBlocks({ + events, + notices: [], + activeSession: null, + }); +} + +describe("aggregateChatBlocks typed groups", () => { + it("groups tool_calls + commands together and keeps file_changes as a separate group", () => { + const events: AgentChatEventEnvelope[] = [ + env("2026-01-01T12:00:00.000Z", { type: "tool_call", tool: "read", args: { path: "a.ts" }, itemId: "t1", turnId: "turn-1" }), + env("2026-01-01T12:00:01.000Z", { type: "tool_call", tool: "read", args: { path: "b.ts" }, itemId: "t2", turnId: "turn-1" }), + env("2026-01-01T12:00:02.000Z", { type: "tool_call", tool: "grep", args: { pattern: "foo" }, itemId: "t3", turnId: "turn-1" }), + env("2026-01-01T12:00:03.000Z", { + type: "file_change", + path: "src/app.ts", + kind: "modify", + diff: "+added line\n-removed line", + itemId: "f1", + turnId: "turn-1", + status: "completed", + }), + env("2026-01-01T12:00:04.000Z", { + type: "file_change", + path: "src/util.ts", + kind: "create", + diff: "+brand new\n+second new", + itemId: "f2", + turnId: "turn-1", + status: "completed", + }), + env("2026-01-01T12:00:05.000Z", { + type: "command", + command: "npm test", + cwd: "/tmp", + output: "", + itemId: "c1", + turnId: "turn-1", + status: "completed", + exitCode: 0, + durationMs: 1500, + }), + ]; + + const blocks = aggregate(events); + const groupKinds = blocks + .filter((b) => b.kind === "tool-calls-group" || b.kind === "files-changed-group") + .map((b) => b.kind); + + // Tool calls first, then files (file_changes interrupted the run), then a fresh + // tool-calls-group continues with the trailing command. + expect(groupKinds).toEqual(["tool-calls-group", "files-changed-group", "tool-calls-group"]); + + const toolGroups = blocks.filter((b) => b.kind === "tool-calls-group") as Array>; + const fileGroup = blocks.find((b) => b.kind === "files-changed-group") as Extract | undefined; + + expect(toolGroups[0]!.entries.map((e) => e.tool)).toEqual(["read", "read", "grep"]); + // The trailing command event lands in a fresh tool-calls-group as tool="shell". + expect(toolGroups[1]!.entries).toHaveLength(1); + expect(toolGroups[1]!.entries[0]).toMatchObject({ + tool: "shell", + arg: "npm test", + status: "ok", + durationMs: 1500, + }); + + expect(fileGroup!.entries).toHaveLength(2); + expect(fileGroup!.entries[0]).toMatchObject({ + path: "src/app.ts", + kind: "modify", + additions: 1, + deletions: 1, + status: "ok", + }); + expect(fileGroup!.entries[1]).toMatchObject({ + path: "src/util.ts", + kind: "create", + additions: 2, + deletions: 0, + status: "ok", + }); + }); + + it("folds consecutive tool_calls and a command into one tool-calls-group", () => { + const events: AgentChatEventEnvelope[] = [ + env("2026-01-01T12:00:00.000Z", { type: "tool_call", tool: "read", args: {}, itemId: "t1", turnId: "turn-1" }), + env("2026-01-01T12:00:01.000Z", { type: "command", command: "ls", cwd: "/tmp", output: "", itemId: "c1", turnId: "turn-1", status: "completed", exitCode: 0 }), + env("2026-01-01T12:00:02.000Z", { type: "tool_call", tool: "grep", args: {}, itemId: "t2", turnId: "turn-1" }), + ]; + const blocks = aggregate(events); + const toolGroups = blocks.filter((b) => b.kind === "tool-calls-group") as Array>; + expect(toolGroups).toHaveLength(1); + expect(toolGroups[0]!.entries.map((e) => e.tool)).toEqual(["read", "shell", "grep"]); + }); + + it("propagates failed tool_result status to the matching entry", () => { + const events: AgentChatEventEnvelope[] = [ + env("2026-01-01T12:00:00.000Z", { type: "tool_call", tool: "read", args: { path: "a.ts" }, itemId: "t1", turnId: "turn-1" }), + env("2026-01-01T12:00:01.000Z", { type: "tool_result", tool: "read", result: "boom", itemId: "t1", turnId: "turn-1", status: "failed" }), + ]; + const blocks = aggregate(events); + const toolGroup = blocks.find((b) => b.kind === "tool-calls-group") as Extract | undefined; + expect(toolGroup).toBeDefined(); + expect(toolGroup!.entries).toHaveLength(1); + expect(toolGroup!.entries[0]).toMatchObject({ itemId: "t1", status: "failed" }); + }); + + it("separates events with different turnIds into new group blocks per turn", () => { + const events: AgentChatEventEnvelope[] = [ + env("2026-01-01T12:00:00.000Z", { type: "tool_call", tool: "read", args: {}, itemId: "t1", turnId: "turn-1" }), + env("2026-01-01T12:00:01.000Z", { type: "user_message", text: "next" }), + env("2026-01-01T12:00:02.000Z", { type: "tool_call", tool: "read", args: {}, itemId: "t2", turnId: "turn-2" }), + ]; + const blocks = aggregate(events); + const toolGroups = blocks.filter((b) => b.kind === "tool-calls-group") as Array>; + expect(toolGroups).toHaveLength(2); + expect(toolGroups[0]!.turnId).toBe("turn-1"); + expect(toolGroups[1]!.turnId).toBe("turn-2"); + expect(toolGroups[0]!.entries.map((e) => e.itemId)).toEqual(["t1"]); + expect(toolGroups[1]!.entries.map((e) => e.itemId)).toEqual(["t2"]); + }); + + it("filters out subagent-child work events", () => { + const events: AgentChatEventEnvelope[] = [ + env("2026-01-01T12:00:00.000Z", { + type: "subagent_started", + sessionId: "sub-1", + teammateId: "tm-1", + name: "child", + parentToolUseId: "parent-1", + } as unknown as AgentChatEvent), + env("2026-01-01T12:00:01.000Z", { type: "tool_call", tool: "read", args: {}, itemId: "child-1", parentItemId: "parent-1", turnId: "turn-1" }), + env("2026-01-01T12:00:02.000Z", { type: "tool_call", tool: "read", args: {}, itemId: "kept-1", turnId: "turn-1" }), + ]; + const blocks = aggregate(events); + const toolGroup = blocks.find((b) => b.kind === "tool-calls-group") as Extract | undefined; + expect(toolGroup).toBeDefined(); + expect(toolGroup!.entries.map((e) => e.itemId)).toEqual(["kept-1"]); + }); + + it("marks tool-calls-group and files-changed-group as not-live without stamping turn duration", () => { + const events: AgentChatEventEnvelope[] = [ + env("2026-01-01T12:00:00.000Z", { type: "tool_call", tool: "read", args: {}, itemId: "t1", turnId: "turn-1" }), + env("2026-01-01T12:00:01.000Z", { + type: "file_change", + path: "src/a.ts", + kind: "modify", + diff: "+x", + itemId: "f1", + turnId: "turn-1", + status: "completed", + }), + env("2026-01-01T12:00:02.000Z", { type: "command", command: "ls", cwd: "/tmp", output: "", itemId: "c1", turnId: "turn-1", status: "completed", exitCode: 0 }), + env("2026-01-01T12:00:05.000Z", { type: "done", turnId: "turn-1", status: "completed" }), + ]; + const blocks = aggregate(events); + const groupBlocks = blocks.filter((b) => + b.kind === "tool-calls-group" || b.kind === "files-changed-group", + ) as Array>; + expect(groupBlocks.length).toBeGreaterThanOrEqual(2); + for (const g of groupBlocks) { + expect(g.live).toBe(false); + expect(g.durationMs).toBeUndefined(); + } + }); + + it("emits an error passthrough on failed status and stops live for all groups", () => { + const events: AgentChatEventEnvelope[] = [ + env("2026-01-01T12:00:00.000Z", { type: "tool_call", tool: "read", args: {}, itemId: "t1", turnId: "turn-1" }), + env("2026-01-01T12:00:05.000Z", { type: "status", turnStatus: "failed", turnId: "turn-1" }), + ]; + const blocks = aggregate(events); + const toolGroup = blocks.find((b) => b.kind === "tool-calls-group") as Extract | undefined; + expect(toolGroup).toBeDefined(); + expect(toolGroup!.live).toBe(false); + expect(toolGroup!.durationMs).toBeUndefined(); + const errorBlock = blocks.find((b) => b.kind === "error"); + expect(errorBlock).toBeDefined(); + }); + + it("derives per-tool duration from matching tool_call and tool_result timestamps", () => { + const events: AgentChatEventEnvelope[] = [ + env("2026-01-01T12:00:00.000Z", { type: "tool_call", tool: "read", args: { path: "a.ts" }, itemId: "t1", turnId: "turn-1" }), + env("2026-01-01T12:00:01.250Z", { type: "tool_result", tool: "read", result: "ok", itemId: "t1", turnId: "turn-1", status: "completed" }), + env("2026-01-01T12:12:00.000Z", { type: "done", turnId: "turn-1", status: "completed" }), + ]; + + const blocks = aggregate(events); + const toolGroup = blocks.find((b) => b.kind === "tool-calls-group") as Extract | undefined; + + expect(toolGroup).toBeDefined(); + expect(toolGroup!.durationMs).toBeUndefined(); + expect(toolGroup!.entries[0]).toMatchObject({ itemId: "t1", status: "ok", durationMs: 1250 }); + }); + + it("derives command duration from running and completed command events when provider duration is missing", () => { + const events: AgentChatEventEnvelope[] = [ + env("2026-01-01T12:00:00.000Z", { type: "command", command: "npm test", cwd: "/tmp", output: "", itemId: "c1", turnId: "turn-1", status: "running" }), + env("2026-01-01T12:00:02.400Z", { type: "command", command: "npm test", cwd: "/tmp", output: "", itemId: "c1", turnId: "turn-1", status: "completed", exitCode: 0 }), + ]; + + const blocks = aggregate(events); + const toolGroup = blocks.find((b) => b.kind === "tool-calls-group") as Extract | undefined; + + expect(toolGroup).toBeDefined(); + expect(toolGroup!.entries[0]).toMatchObject({ itemId: "c1", status: "ok", durationMs: 2400 }); + }); + + it("sets precededByHeavy on assistant-text that follows a tool or file group", () => { + const events: AgentChatEventEnvelope[] = [ + env("2026-01-01T12:00:00.000Z", { type: "tool_call", tool: "read", args: {}, itemId: "t1", turnId: "turn-1" }), + env("2026-01-01T12:00:01.000Z", { type: "text", text: "after tools", turnId: "turn-1" }), + ]; + const blocks = aggregate(events); + const assistantText = blocks.find((b) => b.kind === "assistant-text") as Extract | undefined; + expect(assistantText).toBeDefined(); + expect(assistantText!.precededByHeavy).toBe(true); + + const events2: AgentChatEventEnvelope[] = [ + env("2026-01-01T12:00:10.000Z", { + type: "file_change", + path: "src/x.ts", + kind: "modify", + diff: "+y", + itemId: "f1", + turnId: "turn-2", + status: "completed", + }), + env("2026-01-01T12:00:11.000Z", { type: "text", text: "after files", turnId: "turn-2" }), + ]; + const blocks2 = aggregate(events2); + const assistantText2 = blocks2.find((b) => b.kind === "assistant-text") as Extract | undefined; + expect(assistantText2).toBeDefined(); + expect(assistantText2!.precededByHeavy).toBe(true); + }); + + it("marks file_change as deleted when diff is empty and kind is delete", () => { + const events: AgentChatEventEnvelope[] = [ + env("2026-01-01T12:00:00.000Z", { + type: "file_change", + path: "src/gone.ts", + kind: "delete", + diff: "", + itemId: "f1", + turnId: "turn-1", + status: "completed", + }), + ]; + const blocks = aggregate(events); + const fileGroup = blocks.find((b) => b.kind === "files-changed-group") as Extract | undefined; + expect(fileGroup).toBeDefined(); + expect(fileGroup!.entries[0]).toMatchObject({ + path: "src/gone.ts", + kind: "delete", + additions: 0, + deletions: 0, + deleted: true, + }); + }); +}); diff --git a/apps/ade-cli/src/tuiClient/__tests__/appInput.test.ts b/apps/ade-cli/src/tuiClient/__tests__/appInput.test.ts index e2abe7e32..75c163883 100644 --- a/apps/ade-cli/src/tuiClient/__tests__/appInput.test.ts +++ b/apps/ade-cli/src/tuiClient/__tests__/appInput.test.ts @@ -1,12 +1,20 @@ import { describe, expect, it } from "vitest"; import { clampChatScrollOffsetRows, + deletePreviousPromptLine, deletePreviousPromptWord, + encodeTerminalPromptSubmit, footerControlsForAvailability, + isPromptLineBackspace, isPromptWordBackspace, + isTerminalMouseTrackingEnabled, parseTerminalMouseInput, + promptDisplayRows, + resolveChatWrapWidth, + resolveTerminalPaneWidth, subagentSnapshotsFromEvents, } from "../app"; +import { clampTerminalPaneCols } from "../components/TerminalPane"; describe("parseTerminalMouseInput", () => { it("parses SGR mouse wheel events from Ink input", () => { @@ -56,6 +64,19 @@ describe("parseTerminalMouseInput", () => { }); }); + it("parses primary drags and releases for chat text selection", () => { + expect(parseTerminalMouseInput("[<32;7;8M")).toEqual({ + kind: "drag", + x: 7, + y: 8, + }); + expect(parseTerminalMouseInput("[<0;7;8m")).toEqual({ + kind: "release", + x: 7, + y: 8, + }); + }); + it("swallows batched SGR mouse events from fast scrolling", () => { expect(parseTerminalMouseInput("[<64;104;32M[<64;104;32M[<65;104;31M")).toEqual({ kind: "wheel", @@ -77,6 +98,22 @@ describe("footer control ordering", () => { }); }); +describe("pane width helpers", () => { + it("caps prose chat width but lets embedded terminals use the full center pane", () => { + expect(resolveChatWrapWidth(180, false, 0)).toBe(110); + expect(resolveChatWrapWidth(Number.NaN, false, 0)).toBe(24); + expect(resolveTerminalPaneWidth(180)).toBe(180); + expect(resolveTerminalPaneWidth(Number.NaN)).toBe(24); + expect(clampTerminalPaneCols(360)).toBe(360); + expect(clampTerminalPaneCols(999)).toBe(400); + expect(clampTerminalPaneCols(Number.POSITIVE_INFINITY)).toBe(20); + }); + + it("does not further cap chat text when both side panes already narrow the center", () => { + expect(resolveChatWrapWidth(72, true, 34)).toBe(72); + }); +}); + describe("subagentSnapshotsFromEvents", () => { it("uses agentId as the stable row id when runtimes provide both ids", () => { const snapshots = subagentSnapshotsFromEvents([ @@ -145,4 +182,47 @@ describe("prompt editing helpers", () => { expect(isPromptWordBackspace("\x1b\u007f", { meta: true })).toBe(true); expect(isPromptWordBackspace("x", {})).toBe(false); }); + + it("deletes the current prompt line from the end", () => { + expect(deletePreviousPromptLine("one two")).toBe(""); + expect(deletePreviousPromptLine("one\ntwo")).toBe("one\n"); + expect(deletePreviousPromptLine("one\ntwo\n")).toBe("one\ntwo"); + expect(deletePreviousPromptLine("")).toBe(""); + }); + + it("recognizes common line-backspace key encodings", () => { + expect(isPromptLineBackspace("u", { ctrl: true })).toBe(true); + expect(isPromptLineBackspace("", { meta: true, backspace: true })).toBe(false); + expect(isPromptLineBackspace("", { meta: true, delete: true })).toBe(false); + expect(isPromptLineBackspace("\x1b\u007f", { meta: true })).toBe(false); + expect(isPromptLineBackspace("\x1b[3;3~", { meta: true })).toBe(false); + expect(isPromptLineBackspace("", { ctrl: true, backspace: true } as { ctrl: boolean; backspace: boolean })).toBe(false); + }); + + it("caps prompt display rows at the newest visual lines", () => { + expect(promptDisplayRows("one\ntwo", 20)).toEqual(["one", "two"]); + expect(promptDisplayRows("abcdef", 2, 5)).toEqual(["ab", "cd", "ef"]); + expect(promptDisplayRows("1\n2\n3\n4\n5\n6", 20, 5)).toEqual(["2", "3", "4", "5", "6"]); + }); +}); + +describe("terminal mouse tracking", () => { + it("is enabled by default for pane-safe chat selection and can be disabled", () => { + expect(isTerminalMouseTrackingEnabled(undefined)).toBe(true); + expect(isTerminalMouseTrackingEnabled("")).toBe(true); + expect(isTerminalMouseTrackingEnabled("0")).toBe(false); + expect(isTerminalMouseTrackingEnabled("false")).toBe(false); + expect(isTerminalMouseTrackingEnabled("1")).toBe(true); + expect(isTerminalMouseTrackingEnabled("yes")).toBe(true); + }); +}); + +describe("encodeTerminalPromptSubmit", () => { + it("submits single-line prompts with return", () => { + expect(encodeTerminalPromptSubmit("hello")).toBe("hello\r"); + }); + + it("uses bracketed paste for multiline prompts", () => { + expect(encodeTerminalPromptSubmit("one\r\ntwo")).toBe("\x1b[200~one\ntwo\x1b[201~\r"); + }); }); diff --git a/apps/ade-cli/src/tuiClient/__tests__/commands.test.ts b/apps/ade-cli/src/tuiClient/__tests__/commands.test.ts index 1a5f4753a..55f3b2f7e 100644 --- a/apps/ade-cli/src/tuiClient/__tests__/commands.test.ts +++ b/apps/ade-cli/src/tuiClient/__tests__/commands.test.ts @@ -17,6 +17,17 @@ describe("commands", () => { expect(parsed ? commandPlacement(parsed) : null).toBe("right"); }); + it("routes /feedback to the ADE Code right pane", () => { + const parsed = parseCommand("/feedback"); + expect(parsed?.spec?.name).toBe("/feedback"); + expect(parsed ? commandPlacement(parsed) : null).toBe("right"); + expect(paletteCommands("/feed")).toContainEqual(expect.objectContaining({ + name: "/feedback", + source: "ade", + description: "Submit ADE feedback to GitHub issues", + })); + }); + it("routes runtime commands to chat", () => { const parsed = parseCommand("/ship now", [ { name: "/ship", description: "Ship it", source: "sdk" }, @@ -35,13 +46,20 @@ describe("commands", () => { expect(parsed ? commandPlacement(parsed) : null).toBe("chat"); }); - it("lets runtime commands override single-word ADE built-ins on exact name", () => { + it("keeps single-word ADE pane commands local on exact name", () => { const parsed = parseCommand("/status please", [ { name: "/status", description: "Runtime status", source: "sdk" }, ]); - expect(parsed?.spec).toBeNull(); - expect(parsed?.userCommand?.name).toBe("/status"); - expect(parsed ? commandPlacement(parsed) : null).toBe("chat"); + expect(parsed?.spec?.name).toBe("/status"); + expect(parsed?.userCommand).toBeNull(); + expect(parsed ? commandPlacement(parsed) : null).toBe("right"); + + const feedback = parseCommand("/feedback", [ + { name: "/feedback", description: "Runtime feedback", source: "sdk" }, + ]); + expect(feedback?.spec?.name).toBe("/feedback"); + expect(feedback?.userCommand).toBeNull(); + expect(feedback ? commandPlacement(feedback) : null).toBe("right"); }); it("keeps provider login as an ADE-code terminal command", () => { @@ -130,7 +148,7 @@ describe("commands", () => { ]); }); - it("filters Claude-only ADE commands outside Claude chats", () => { + it("filters provider-specific ADE commands outside supported chats", () => { expect(paletteCommands("/context", [], { provider: "codex" })).not.toContainEqual( expect.objectContaining({ name: "/context" }), ); @@ -149,7 +167,7 @@ describe("commands", () => { expect(paletteCommands("/output-style", [], { provider: "claude" })).toContainEqual( expect.objectContaining({ name: "/output-style" }), ); - expect(paletteCommands("/mcp", [], { provider: "claude" })).toContainEqual( + expect(paletteCommands("/mcp", [], { provider: "claude" })).not.toContainEqual( expect.objectContaining({ name: "/mcp" }), ); expect(paletteCommands("/plugin", [], { provider: "claude" })).toContainEqual( @@ -226,6 +244,22 @@ describe("commands", () => { const rows = paletteCommands("/resume", []); expect(rows.find((row) => row.name === "/resume" && row.source === "ade")).toBeUndefined(); }); + + it("registers /subagents as a right-pane builtin", () => { + const parsed = parseCommand("/subagents"); + expect(parsed?.spec?.name).toBe("/subagents"); + expect(parsed?.spec?.placement).toBe("right"); + expect(parsed ? commandPlacement(parsed) : null).toBe("right"); + }); + + it("surfaces /subagents in the palette and not /effort or /plan", () => { + const subagentRows = paletteCommands("subagents"); + expect(subagentRows.some((row) => row.name === "/subagents")).toBe(true); + + const allRows = paletteCommands(""); + expect(allRows.some((row) => row.name === "/effort" && row.source === "ade")).toBe(false); + expect(allRows.some((row) => row.name === "/plan" && row.source === "ade")).toBe(false); + }); }); describe("linear command routing", () => { diff --git a/apps/ade-cli/src/tuiClient/__tests__/feedback.test.ts b/apps/ade-cli/src/tuiClient/__tests__/feedback.test.ts new file mode 100644 index 000000000..fd1dbbcf2 --- /dev/null +++ b/apps/ade-cli/src/tuiClient/__tests__/feedback.test.ts @@ -0,0 +1,136 @@ +import { describe, expect, it } from "vitest"; +import { + buildFeedbackDraftInput, + buildFeedbackEnvironment, + feedbackFormFields, + feedbackSubmissionNotice, + normalizeFeedbackCategory, +} from "../feedback"; +import type { LaneSummary } from "../../../../desktop/src/shared/types/lanes"; + +function lane(overrides: Partial = {}): LaneSummary { + return { + id: "lane-1", + name: "Feedback lane", + laneType: "worktree", + baseRef: "main", + branchRef: "feature/feedback", + worktreePath: "/tmp/project/.ade/worktrees/feedback", + parentLaneId: null, + childCount: 0, + stackDepth: 0, + parentStatus: null, + isEditProtected: false, + status: { + dirty: false, + ahead: 0, + behind: 0, + remoteBehind: 0, + rebaseInProgress: false, + }, + color: null, + icon: null, + tags: [], + createdAt: "2026-05-13T12:00:00.000Z", + ...overrides, + }; +} + +describe("ADE Code feedback helpers", () => { + it("normalizes unsupported categories to bug", () => { + expect(normalizeFeedbackCategory("question")).toBe("question"); + expect(normalizeFeedbackCategory("Feature")).toBe("feature"); + expect(normalizeFeedbackCategory("something else")).toBe("bug"); + }); + + it("builds a bug draft from the compact TUI form fields", () => { + const draft = buildFeedbackDraftInput({ + category: "bug", + summary: "Submitting feedback should close the pane.", + details: "1. Run /feedback\n2. Fill in the summary\n3. Press Enter", + expected: "The issue posts and the pane closes.", + actual: "The pane stays open.", + environment: "ADE Code", + additionalContext: "Seen in the TUI.", + }); + + expect(draft).toEqual({ + category: "bug", + summary: "Submitting feedback should close the pane.", + stepsToReproduce: "1. Run /feedback\n2. Fill in the summary\n3. Press Enter", + expectedBehavior: "The issue posts and the pane closes.", + actualBehavior: "The pane stays open.", + environment: "ADE Code", + additionalContext: "Seen in the TUI.", + }); + }); + + it("keeps environment context when mapping non-bug feedback", () => { + const draft = buildFeedbackDraftInput({ + category: "enhancement", + summary: "Show the feedback hub count in ADE Code.", + details: "I want to see previous submissions without opening desktop.", + expected: "Add a small feedback history command.", + actual: "No TUI history surface exists.", + environment: "Source: ADE Code", + }); + + expect(draft.category).toBe("enhancement"); + expect(draft.additionalContext).toContain("Environment:"); + expect(draft.additionalContext).toContain("Source: ADE Code"); + }); + + it("prepopulates form environment from the active ADE Code context", () => { + const environment = buildFeedbackEnvironment({ + launchCwd: "/tmp/project", + projectRoot: "/tmp/project", + workspaceRoot: "/tmp/project", + laneHint: null, + }, lane()); + const fields = feedbackFormFields(environment); + + expect(environment).toContain("Source: ADE Code"); + expect(environment).toContain("Lane: Feedback lane"); + expect(fields.find((field) => field.name === "environment")?.initialValue).toBe(environment); + }); + + it("formats posted and failed submission notices", () => { + expect(feedbackSubmissionNotice({ + id: "sub-1", + category: "bug", + userDescription: "", + modelId: null, + reasoningEffort: null, + status: "posted", + generationMode: "deterministic", + generationWarning: null, + generatedTitle: "Title", + generatedBody: "Body", + issueUrl: "https://github.com/arul28/ADE/issues/123", + issueNumber: 123, + issueState: "open", + error: null, + createdAt: "2026-05-13T12:00:00.000Z", + completedAt: "2026-05-13T12:00:01.000Z", + })).toEqual({ text: "Feedback posted as GitHub issue #123.", tone: "success" }); + + expect(feedbackSubmissionNotice({ + id: "sub-2", + category: "bug", + userDescription: "", + modelId: null, + reasoningEffort: null, + status: "failed", + generationMode: "deterministic", + generationWarning: null, + generatedTitle: "Title", + generatedBody: "Body", + issueUrl: null, + issueNumber: null, + issueState: null, + error: "Posting failed: GitHub token missing", + createdAt: "2026-05-13T12:00:00.000Z", + completedAt: "2026-05-13T12:00:01.000Z", + })).toEqual({ text: "Posting failed: GitHub token missing", tone: "error" }); + }); +}); diff --git a/apps/ade-cli/src/tuiClient/__tests__/format.test.ts b/apps/ade-cli/src/tuiClient/__tests__/format.test.ts index 413a763c3..15385b036 100644 --- a/apps/ade-cli/src/tuiClient/__tests__/format.test.ts +++ b/apps/ade-cli/src/tuiClient/__tests__/format.test.ts @@ -1,9 +1,9 @@ import { describe, expect, it } from "vitest"; -import { latestExpandableFailureId, parseAssistantMarkdown, renderChatLines, renderObject } from "../format"; +import { latestExpandableFailureId, parseAssistantMarkdown, parseInlineRuns, renderChatLines, renderObject } from "../format"; describe("renderChatLines", () => { it("parses assistant markdown into stable blocks", () => { - expect(parseAssistantMarkdown([ + const blocks = parseAssistantMarkdown([ "# Heading", "", "Paragraph text", @@ -15,14 +15,116 @@ describe("renderChatLines", () => { "```sh", "npm test", "```", - ].join("\n"))).toEqual([ + ].join("\n")); + expect(blocks).toEqual([ { kind: "heading", level: 1, text: "Heading" }, { kind: "paragraph", text: "Paragraph text" }, { kind: "bullet", text: "Bullet" }, { kind: "numbered", number: "1", text: "Numbered" }, { kind: "quote", text: "Quote" }, - { kind: "code", language: "sh", lines: ["npm test"] }, + expect.objectContaining({ kind: "code", language: "sh", lines: ["npm test"] }), ]); + const code = blocks[5]; + expect(code?.kind).toBe("code"); + if (code?.kind === "code") { + // sh is aliased → bash, so highlighting populates per-line tokens. + expect(code.tokens).toBeDefined(); + expect(code.tokens?.length).toBe(1); + expect(code.tokens?.[0]?.length).toBeGreaterThan(0); + } + }); + + it("parses fenced code without language as raw lines and no tokens", () => { + const blocks = parseAssistantMarkdown(["```", "plain line 1", "plain line 2", "```"].join("\n")); + expect(blocks).toEqual([ + { kind: "code", lines: ["plain line 1", "plain line 2"] }, + ]); + const code = blocks[0]; + if (code?.kind === "code") { + expect(code.language).toBeUndefined(); + expect(code.tokens).toBeUndefined(); + } + }); + + it("parses multi-line typescript code block with per-line token arrays", () => { + const blocks = parseAssistantMarkdown([ + "```ts", + "const x = 1;", + "const y = \"two\";", + "```", + ].join("\n")); + expect(blocks).toHaveLength(1); + const block = blocks[0]; + expect(block?.kind).toBe("code"); + if (block?.kind === "code") { + expect(block.lines).toEqual(["const x = 1;", "const y = \"two\";"]); + expect(block.tokens).toBeDefined(); + expect(block.tokens?.length).toBe(2); + // At least one keyword token (`const`) must be tagged. + const allTokens = block.tokens?.flat() ?? []; + expect(allTokens.some((t) => t.category === "keyword")).toBe(true); + expect(allTokens.some((t) => t.category === "string")).toBe(true); + } + }); + + it("parses GFM tables", () => { + const blocks = parseAssistantMarkdown([ + "| Name | Status |", + "|------|--------|", + "| Alice | OK |", + "| Bob | Bad |", + ].join("\n")); + expect(blocks).toEqual([ + { kind: "table", headers: ["Name", "Status"], rows: [["Alice", "OK"], ["Bob", "Bad"]] }, + ]); + }); + + it("parses thematic break (hr)", () => { + expect(parseAssistantMarkdown("paragraph\n\n---\n\nafter")).toEqual([ + { kind: "paragraph", text: "paragraph" }, + { kind: "hr" }, + { kind: "paragraph", text: "after" }, + ]); + }); + + it("parses numbered list with explicit starting number", () => { + expect(parseAssistantMarkdown(["3. Three", "4. Four"].join("\n"))).toEqual([ + { kind: "numbered", number: "3", text: "Three" }, + { kind: "numbered", number: "4", text: "Four" }, + ]); + }); + + it("parses task list checkboxes into bullets", () => { + const blocks = parseAssistantMarkdown(["- [ ] Open", "- [x] Done"].join("\n")); + expect(blocks).toEqual([ + { kind: "bullet", text: "[ ] Open" }, + { kind: "bullet", text: "[x] Done" }, + ]); + }); + + it("parses inline markdown into bold/italic/code/link runs", () => { + const runs = parseInlineRuns("Hello **bold** and *italic* and `code` plus [the docs](https://x)"); + // First plain text, then bold, then plain, then italic, then plain, then code, then plain, then link. + expect(runs.map((r) => ({ text: r.text, bold: !!r.bold, italic: !!r.italic, code: !!r.code, link: !!r.link }))) + .toEqual([ + { text: "Hello ", bold: false, italic: false, code: false, link: false }, + { text: "bold", bold: true, italic: false, code: false, link: false }, + { text: " and ", bold: false, italic: false, code: false, link: false }, + { text: "italic", bold: false, italic: true, code: false, link: false }, + { text: " and ", bold: false, italic: false, code: false, link: false }, + { text: "code", bold: false, italic: false, code: true, link: false }, + { text: " plus ", bold: false, italic: false, code: false, link: false }, + { text: "the docs", bold: false, italic: false, code: false, link: true }, + ]); + }); + + it("parses nested bold-italic correctly (marked handles nesting)", () => { + const runs = parseInlineRuns("**bold _both_ rest**"); + // First "bold ", then "both" with bold+italic, then " rest" with bold. + expect(runs).toHaveLength(3); + expect(runs[0]).toMatchObject({ text: "bold ", bold: true }); + expect(runs[1]).toMatchObject({ text: "both", bold: true, italic: true }); + expect(runs[2]).toMatchObject({ text: " rest", bold: true }); }); it("renders compact chat turns without speaker metadata spam", () => { diff --git a/apps/ade-cli/src/tuiClient/__tests__/highlightCache.test.ts b/apps/ade-cli/src/tuiClient/__tests__/highlightCache.test.ts new file mode 100644 index 000000000..621be315f --- /dev/null +++ b/apps/ade-cli/src/tuiClient/__tests__/highlightCache.test.ts @@ -0,0 +1,88 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { + __clearHighlightCacheForTests, + __getHighlightCacheStatsForTests, + highlightCode, +} from "../highlightCache"; + +describe("highlightCache", () => { + beforeEach(() => { + __clearHighlightCacheForTests(); + }); + + it("returns plain single-token lines for unknown languages", () => { + const result = highlightCode("hello\nworld", "klingon"); + expect(result).toEqual([ + [{ text: "hello" }], + [{ text: "world" }], + ]); + }); + + it("returns plain lines when no language is provided", () => { + const result = highlightCode("alpha", undefined); + expect(result).toEqual([[{ text: "alpha" }]]); + }); + + it("returns one HighlightedToken array per line of code", () => { + const code = ["const a = 1;", "const b = 2;"].join("\n"); + const lines = highlightCode(code, "typescript"); + expect(lines.length).toBe(2); + // Each line must have at least one token. + expect(lines[0]?.length).toBeGreaterThan(0); + expect(lines[1]?.length).toBeGreaterThan(0); + }); + + it("classifies typescript keywords, strings, and numbers", () => { + const lines = highlightCode("const x = \"hi\";\nconst n = 42;", "typescript"); + const flat = lines.flat(); + const categories = new Set(flat.map((t) => t.category).filter(Boolean)); + expect(categories.has("keyword")).toBe(true); + expect(categories.has("string")).toBe(true); + expect(categories.has("number")).toBe(true); + }); + + it("resolves language aliases (tsx → typescript)", () => { + const a = highlightCode("const x = 1;", "tsx"); + const b = highlightCode("const x = 1;", "typescript"); + expect(a).toEqual(b); + }); + + it("resolves sh and shell aliases to bash", () => { + const sh = highlightCode("echo hi", "sh"); + const bash = highlightCode("echo hi", "bash"); + const shell = highlightCode("echo hi", "shell"); + expect(sh).toEqual(bash); + expect(shell).toEqual(bash); + }); + + it("returns identical reference on cache hit", () => { + const a = highlightCode("const x = 1;", "typescript"); + const b = highlightCode("const x = 1;", "typescript"); + expect(b).toBe(a); + }); + + it("decodes HTML entities produced by highlight.js", () => { + const lines = highlightCode("if (a < b && c > d) {}", "typescript"); + const text = lines.flat().map((t) => t.text).join(""); + expect(text).toBe("if (a < b && c > d) {}"); + }); + + it("preserves empty lines as empty token arrays", () => { + const lines = highlightCode("const a = 1;\n\nconst b = 2;", "typescript"); + expect(lines.length).toBe(3); + expect(lines[1]).toEqual([]); + }); + + it("caps cache size at 500 entries with insertion-order (LRU) eviction", () => { + for (let i = 0; i < 600; i += 1) { + highlightCode(`const v${i} = ${i};`, "typescript"); + } + expect(__getHighlightCacheStatsForTests().entries).toBe(500); + // The first 100 entries should have been evicted — re-highlighting v0 + // creates a fresh entry, so it doesn't return the original reference. + const replayed = highlightCode("const v0 = 0;", "typescript"); + const again = highlightCode("const v0 = 0;", "typescript"); + expect(again).toBe(replayed); + expect(__getHighlightCacheStatsForTests().entries).toBe(500); + }); +}); diff --git a/apps/ade-cli/src/tuiClient/__tests__/keybindings.test.ts b/apps/ade-cli/src/tuiClient/__tests__/keybindings.test.ts index ae9baa183..943b1c836 100644 --- a/apps/ade-cli/src/tuiClient/__tests__/keybindings.test.ts +++ b/apps/ade-cli/src/tuiClient/__tests__/keybindings.test.ts @@ -48,6 +48,18 @@ describe("keybindings", () => { expect(dispatchKeybinding(diagnostics.bindings, "Help", "", { upArrow: true })).toBeUndefined(); }); + it("dispatches the background chat launch shortcut", () => { + const diagnostics = validateClaudeKeybindingsConfig({ + bindings: [ + { context: "Chat", bindings: { "cmd+return": "chat:launchBackground" } }, + ], + }); + + expect(diagnostics.bindingCount).toBe(1); + expect(diagnostics.warnings).toEqual([]); + expect(dispatchKeybinding(diagnostics.bindings, "Chat", "", { meta: true, return: true })).toBe("chat:launchBackground"); + }); + it("dispatches documented multi-key chords", () => { const diagnostics = validateClaudeKeybindingsConfig({ bindings: [ diff --git a/apps/ade-cli/src/tuiClient/__tests__/planMode.test.ts b/apps/ade-cli/src/tuiClient/__tests__/planMode.test.ts new file mode 100644 index 000000000..90705882d --- /dev/null +++ b/apps/ade-cli/src/tuiClient/__tests__/planMode.test.ts @@ -0,0 +1,140 @@ +import { describe, expect, it } from "vitest"; +import { paletteCommands, parseCommand } from "../commands"; +import { hasFirstUserMessage, isPlanMode } from "../planMode"; +import type { AdeCodeModelState } from "../types"; + +function baseModelState(overrides: Partial): AdeCodeModelState { + return { + provider: "codex", + model: "gpt-5.5", + modelId: null, + displayName: "GPT-5.5", + reasoningEffort: "medium", + codexFastMode: false, + permissionMode: "default", + interactionMode: "default", + claudePermissionMode: "default", + codexApprovalPolicy: "on-request", + codexSandbox: "workspace-write", + codexConfigSource: "flags", + opencodePermissionMode: "edit", + droidPermissionMode: "auto-low", + cursorModeId: "agent", + cursorConfigValues: {}, + ...overrides, + }; +} + +describe("parseCommand /model", () => { + it("returns right placement with no args", () => { + const parsed = parseCommand("/model"); + expect(parsed?.spec?.name).toBe("/model"); + expect(parsed?.spec?.placement).toBe("right"); + expect(parsed?.args).toBe(""); + }); + + it("wins over a user/SDK /model command", () => { + const parsed = parseCommand("/model", [ + { name: "/model", description: "Select or change the AI model.", source: "sdk", argumentHint: "[model]" }, + ]); + expect(parsed?.spec?.name).toBe("/model"); + expect(parsed?.spec?.placement).toBe("right"); + expect(parsed?.userCommand).toBeNull(); + }); +}); + +describe("paletteCommands SDK overrides", () => { + it("/model dedupes user/SDK /model out of the palette", () => { + const results = paletteCommands("/model", [ + { name: "/model", description: "Select or change the AI model.", source: "sdk", argumentHint: "[model]" }, + ]); + const modelEntries = results.filter((entry) => entry.name === "/model"); + expect(modelEntries).toHaveLength(1); + expect(modelEntries[0]?.source).toBe("ade"); + }); +}); + +describe("paletteCommands placement", () => { + it("includes /model with right placement when filtering by /m", () => { + const results = paletteCommands("/m"); + const model = results.find((entry) => entry.name === "/model"); + expect(model).toBeDefined(); + expect(model?.placement).toBe("right"); + }); +}); + +describe("isPlanMode", () => { + it("returns true for Claude with claudePermissionMode plan", () => { + expect(isPlanMode(baseModelState({ provider: "claude", claudePermissionMode: "plan" }))).toBe(true); + }); + + it("returns false for Claude with claudePermissionMode default", () => { + expect(isPlanMode(baseModelState({ provider: "claude", claudePermissionMode: "default" }))).toBe(false); + }); + + it("returns true for Codex on-request + read-only", () => { + expect(isPlanMode(baseModelState({ + provider: "codex", + codexApprovalPolicy: "on-request", + codexSandbox: "read-only", + }))).toBe(true); + }); + + it("returns false for Codex on-request + workspace-write", () => { + expect(isPlanMode(baseModelState({ + provider: "codex", + codexApprovalPolicy: "on-request", + codexSandbox: "workspace-write", + }))).toBe(false); + }); + + it("returns true for OpenCode with opencodePermissionMode plan", () => { + expect(isPlanMode(baseModelState({ provider: "opencode", opencodePermissionMode: "plan" }))).toBe(true); + }); + + it("returns true for Cursor cursorModeId plan", () => { + expect(isPlanMode(baseModelState({ provider: "cursor", cursorModeId: "plan" }))).toBe(true); + }); + + it("returns true for Droid read-only", () => { + expect(isPlanMode(baseModelState({ provider: "droid", droidPermissionMode: "read-only" }))).toBe(true); + }); + + it("returns false for Droid auto-medium", () => { + expect(isPlanMode(baseModelState({ provider: "droid", droidPermissionMode: "auto-medium" }))).toBe(false); + }); +}); + +describe("hasFirstUserMessage", () => { + it("is false when there are no events", () => { + expect(hasFirstUserMessage([])).toBe(false); + }); + + it("is false when no envelope carries a user_message", () => { + expect(hasFirstUserMessage([ + { + sessionId: "s1", + timestamp: "2026-01-01T12:00:00.000Z", + sequence: 1, + event: { type: "text", text: "hi" }, + }, + ])).toBe(false); + }); + + it("is true once a user_message event appears", () => { + expect(hasFirstUserMessage([ + { + sessionId: "s1", + timestamp: "2026-01-01T12:00:00.000Z", + sequence: 1, + event: { type: "text", text: "hi" }, + }, + { + sessionId: "s1", + timestamp: "2026-01-01T12:00:01.000Z", + sequence: 2, + event: { type: "user_message", text: "hello" }, + }, + ])).toBe(true); + }); +}); diff --git a/apps/ade-cli/src/tuiClient/adeApi.ts b/apps/ade-cli/src/tuiClient/adeApi.ts index 42ae37afa..ca5fb1190 100644 --- a/apps/ade-cli/src/tuiClient/adeApi.ts +++ b/apps/ade-cli/src/tuiClient/adeApi.ts @@ -9,7 +9,6 @@ import type { AgentChatClaudeOutputStyle, AgentChatClaudePlugin, AgentChatReloadClaudePluginsResult, - AgentChatClaudeMcpServerStatus, AgentChatClaudePermissionMode, AgentChatCodexApprovalPolicy, AgentChatCodexConfigSource, @@ -37,6 +36,12 @@ import type { AiSettingsStatus, OpenCodeRuntimeSnapshot } from "../../../desktop import type { DiffLineStats } from "../../../desktop/src/shared/types/git"; import type { LaneSummary } from "../../../desktop/src/shared/types/lanes"; import type { PrLaneSummary } from "../../../desktop/src/shared/types/prs"; +import type { + ChatTerminalPreviewResult, + ChatTerminalSession, + PtySendToSessionResult, + TerminalSessionSummary, +} from "../../../desktop/src/shared/types"; import { discoverClaudeSlashCommands } from "../../../desktop/src/main/services/chat/claudeSlashCommandDiscovery"; import { discoverCodexSlashCommands } from "../../../desktop/src/main/services/chat/codexSlashCommandDiscovery"; import type { AdeCodeConnection, ChatHistorySnapshot, CreatedChat, NavigateRequest, NavigateResult } from "./types"; @@ -67,6 +72,161 @@ export async function listChatSessions( return await connection.actionList("chat", "listSessions", argsList); } +const CHAT_BACKED_TERMINAL_TOOL_TYPES = new Set([ + "codex-chat", + "claude-chat", + "opencode-chat", + "cursor", + "droid-chat", +]); + +const RESUMABLE_TERMINAL_TOOL_TYPES = new Set([ + "claude", + "claude-orchestrated", + "codex", + "codex-orchestrated", +]); + +export async function listTerminalSessions( + connection: AdeCodeConnection, + laneId?: string | null, +): Promise { + const sessions = await connection.action("terminal", "list", { + ...(laneId ? { laneId } : {}), + limit: 200, + }); + return sessions.filter((session) => { + const toolType = session.toolType ?? ""; + if (CHAT_BACKED_TERMINAL_TOOL_TYPES.has(toolType)) return false; + return RESUMABLE_TERMINAL_TOOL_TYPES.has(toolType) + || session.resumeMetadata?.provider === "claude" + || session.resumeMetadata?.provider === "codex"; + }); +} + +export async function previewTerminal( + connection: AdeCodeConnection, + terminalId: string, +): Promise { + return await connection.action("terminal", "preview", { + terminalId, + }); +} + +export async function writeTerminal( + connection: AdeCodeConnection, + terminalId: string, + data: string, +): Promise { + await connection.action("terminal", "write", { terminalId, data }); +} + +export async function resizeTerminal( + connection: AdeCodeConnection, + terminalId: string, + cols: number, + rows: number, +): Promise { + await connection.action("terminal", "resize", { terminalId, cols, rows }); +} + +export async function signalTerminal( + connection: AdeCodeConnection, + terminalId: string, + signal: "SIGINT" | "SIGTERM" | "SIGKILL", +): Promise { + await connection.action("terminal", "signal", { terminalId, signal }); +} + +export type StartClaudeTerminalSessionResult = { + provider: "claude"; + laneId: string; + title: string; + permissionMode: AgentChatPermissionMode; + model: string | null; + ptyId: string; + sessionId: string; + startupCommand: string | null; + initialInputWritten: boolean; + session: ChatTerminalSession | null; +}; + +function terminalSummaryToChatSession(session: TerminalSessionSummary): ChatTerminalSession { + return { + terminalId: session.id, + ptyId: session.ptyId, + chatSessionId: session.chatSessionId ?? null, + laneId: session.laneId, + laneName: session.laneName, + title: session.title, + toolType: session.toolType, + goal: session.goal, + status: session.status, + runtimeState: session.runtimeState, + active: session.status === "running", + startedAt: session.startedAt, + endedAt: session.endedAt, + exitCode: session.exitCode, + pid: null, + resumeCommand: session.resumeCommand, + resumeMetadata: session.resumeMetadata, + lastOutputPreview: session.lastOutputPreview, + summary: session.summary, + }; +} + +export function normalizeChatTerminalSession( + session: ChatTerminalSession | TerminalSessionSummary | null, +): ChatTerminalSession | null { + if (!session) return null; + if ("terminalId" in session) return session; + return terminalSummaryToChatSession(session); +} + +export async function startClaudeTerminalSession(args: { + connection: AdeCodeConnection; + laneId: string; + title?: string | null; + model?: string | null; + permissionMode?: AgentChatPermissionMode | null; + initialInput?: string | null; + cols: number; + rows: number; +}): Promise { + const result = await args.connection.tool & { + session: ChatTerminalSession | TerminalSessionSummary | null; + }>("start_cli_session", { + laneId: args.laneId, + provider: "claude", + title: args.title ?? undefined, + model: args.model ?? undefined, + permissionMode: args.permissionMode ?? "default", + initialInput: args.initialInput ?? undefined, + cols: args.cols, + rows: args.rows, + tracked: true, + }); + return { + ...result, + session: normalizeChatTerminalSession(result.session), + }; +} + +export async function sendToTerminalSession(args: { + connection: AdeCodeConnection; + sessionId: string; + text: string; + cols: number; + rows: number; +}): Promise { + return await args.connection.action("pty", "sendToSession", { + sessionId: args.sessionId, + text: args.text, + cols: args.cols, + rows: args.rows, + }); +} + export async function listPrsByLane(connection: AdeCodeConnection): Promise { return await connection.action("pr", "listPrsByLane", {}); } @@ -74,7 +234,7 @@ export async function listPrsByLane(connection: AdeCodeConnection): Promise { return await connection.actionList("chat", "getChatEventHistory", [sessionId, { maxEvents }]); } @@ -96,13 +256,6 @@ export async function getContextUsage( return await connection.action("chat", "getContextUsage", { sessionId }); } -export async function getClaudeMcpStatus( - connection: AdeCodeConnection, - sessionId: string, -): Promise { - return await connection.action("chat", "getClaudeMcpStatus", { sessionId }); -} - export async function listClaudePlugins( connection: AdeCodeConnection, sessionId: string, diff --git a/apps/ade-cli/src/tuiClient/aggregate.ts b/apps/ade-cli/src/tuiClient/aggregate.ts index 8b3a33f96..f3dd352cd 100644 --- a/apps/ade-cli/src/tuiClient/aggregate.ts +++ b/apps/ade-cli/src/tuiClient/aggregate.ts @@ -1,4 +1,3 @@ -import path from "node:path"; import type { AgentChatEvent, AgentChatEventEnvelope, @@ -14,7 +13,7 @@ import { workEventItemId, workEventParentItemId } from "./workEventIds"; export type WorkToolStatus = "running" | "ok" | "failed"; -export type WorkTool = { +export type ToolCallEntry = { itemId: string; tool: string; arg: string; @@ -22,6 +21,16 @@ export type WorkTool = { durationMs?: number; }; +export type FileChangeEntry = { + itemId: string; + path: string; + kind: string; + status: WorkToolStatus; + additions: number; + deletions: number; + deleted?: boolean; +}; + export type PlanStep = { text: string; status: "pending" | "in_progress" | "completed" | "failed"; @@ -35,7 +44,8 @@ export type PendingSteer = { export type AggregatedBlock = | { kind: "user-bubble"; id: string; line: RenderedChatLine } | { kind: "assistant-text"; id: string; line: RenderedChatLine; precededByHeavy?: boolean } - | { kind: "work-block"; id: string; turnId: string | null; tools: WorkTool[]; live: boolean; durationMs?: number } + | { kind: "tool-calls-group"; id: string; turnId: string | null; entries: ToolCallEntry[]; live: boolean; durationMs?: number } + | { kind: "files-changed-group"; id: string; turnId: string | null; entries: FileChangeEntry[]; live: boolean; durationMs?: number } | { kind: "memory"; id: string; turnId: string | null; live: boolean; hitCount?: number; text?: string } | { kind: "compaction"; id: string; turnId: string | null; trigger: "manual" | "auto"; live: boolean; preTokens?: number } | { kind: "queued-steer"; id: string; turnId: string | null; steerId: string; text: string } @@ -53,6 +63,14 @@ function safeMs(value: string): number { return Number.isNaN(parsed) ? 0 : parsed; } +function validDurationMs(value: unknown): number | undefined { + return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : undefined; +} + +function workItemKey(turnId: string | null, itemId: string): string { + return `${turnId ?? ""}:${itemId}`; +} + function singleArg(value: unknown, max = 60): string { const text = (() => { if (typeof value === "string") return value; @@ -65,20 +83,24 @@ function singleArg(value: unknown, max = 60): string { return (text ?? "").replace(/\s+/g, " ").trim().slice(0, max); } -function diffStats(diff: string): string { +function diffStats(diff: string): { additions: number; deletions: number } { const lines = diff.split(/\r?\n/); - let adds = 0; - let dels = 0; + let additions = 0; + let deletions = 0; for (const line of lines) { - if (/^\+[^+]/.test(line)) adds += 1; - else if (/^-[^-]/.test(line)) dels += 1; + if (/^\+[^+]/.test(line)) additions += 1; + else if (/^-[^-]/.test(line)) deletions += 1; } - return `+${adds} −${dels}`; + return { additions, deletions }; } -function appendTool(block: Extract, event: AgentChatEvent, envelope: AgentChatEventEnvelope): void { +function appendToolCallEvent( + block: Extract, + event: Extract, + durationMs?: number, +): void { if (event.type === "tool_call") { - block.tools.push({ + block.entries.push({ itemId: event.itemId, tool: event.tool, arg: singleArg(event.args), @@ -86,55 +108,83 @@ function appendTool(block: Extract, eve }); return; } - if (event.type === "tool_result") { - const existing = block.tools.find((tool) => tool.itemId === event.itemId); - const status: WorkToolStatus = event.status === "failed" ? "failed" : event.status === "running" ? "running" : "ok"; - if (existing) { - existing.status = status; - return; - } - block.tools.push({ - itemId: event.itemId, - tool: event.tool, - arg: singleArg(event.result), - status, - }); + const existing = block.entries.find((entry) => entry.itemId === event.itemId); + const status: WorkToolStatus = event.status === "failed" ? "failed" : event.status === "running" ? "running" : "ok"; + if (existing) { + existing.status = status; + if (durationMs !== undefined) existing.durationMs = durationMs; return; } - if (event.type === "command") { - const failed = event.status === "failed" || (event.exitCode ?? 0) !== 0; - const status: WorkToolStatus = event.status === "running" ? "running" : failed ? "failed" : "ok"; - const existing = block.tools.find((tool) => tool.itemId === event.itemId); - if (existing) { - existing.status = status; - if (typeof event.durationMs === "number") existing.durationMs = event.durationMs; - existing.arg = event.command; - return; - } - block.tools.push({ - itemId: event.itemId, - tool: "bash", - arg: event.command, - status, - durationMs: event.durationMs ?? undefined, - }); + block.entries.push({ + itemId: event.itemId, + tool: event.tool, + arg: singleArg(event.result), + status, + ...(durationMs !== undefined ? { durationMs } : {}), + }); +} + +function appendFileChangeEvent( + block: Extract, + event: Extract, +): void { + const status: WorkToolStatus = event.status === "failed" ? "failed" : event.status === "running" ? "running" : "ok"; + const { additions, deletions } = diffStats(event.diff); + const deleted = event.kind === "delete" || (additions === 0 && deletions === 0 && event.diff.length === 0); + const existing = block.entries.find((entry) => entry.itemId === event.itemId); + if (existing) { + existing.status = status; + existing.path = event.path; + existing.kind = event.kind; + existing.additions = additions; + existing.deletions = deletions; + if (deleted) existing.deleted = true; return; } - if (event.type === "file_change") { - const status: WorkToolStatus = event.status === "failed" ? "failed" : event.status === "running" ? "running" : "ok"; - const stats = diffStats(event.diff); - const arg = `${path.basename(event.path)} ${stats}`; - const existing = block.tools.find((tool) => tool.itemId === event.itemId); - if (existing) { - existing.status = status; - existing.arg = arg; - return; - } - block.tools.push({ itemId: event.itemId, tool: "edit", arg, status }); + const entry: FileChangeEntry = { + itemId: event.itemId, + path: event.path, + kind: event.kind, + status, + additions, + deletions, + }; + if (deleted) entry.deleted = true; + block.entries.push(entry); +} + +// Most shell commands arrive wrapped in a launcher (`/bin/zsh -lc "actual"`, +// `bash -c 'actual'`, `sh -c "actual"`). Strip it so the user sees the actual +// command in the tool list — the launcher prefix is pure noise in every entry. +function stripShellLauncher(command: string): string { + const match = /^(?:\/[\w./-]+\/)?(?:zsh|bash|sh)\s+-[\w]*c\s+(['"])([\s\S]*)\1\s*$/.exec(command); + if (match && typeof match[2] === "string") return match[2]; + return command; +} + +function appendCommandAsTool( + block: Extract, + event: Extract, + derivedDurationMs?: number, +): void { + const failed = event.status === "failed" || (event.exitCode ?? 0) !== 0; + const status: WorkToolStatus = event.status === "running" ? "running" : failed ? "failed" : "ok"; + const durationMs = validDurationMs(event.durationMs) ?? validDurationMs(derivedDurationMs); + const cleanCommand = stripShellLauncher(event.command); + const existing = block.entries.find((entry) => entry.itemId === event.itemId); + if (existing) { + existing.status = status; + existing.arg = cleanCommand; + if (durationMs !== undefined) existing.durationMs = durationMs; return; } - // Unknown — ignore. - void envelope; + block.entries.push({ + itemId: event.itemId, + tool: "shell", + arg: cleanCommand, + status, + ...(durationMs !== undefined ? { durationMs } : {}), + }); } function isExpandedFailureEvent(event: AgentChatEvent): boolean { @@ -167,6 +217,24 @@ function findLastBlock( return null; } +function isLiveTurnBlock( + block: AggregatedBlock, +): block is Extract { + return block.kind === "tool-calls-group" + || block.kind === "files-changed-group" + || block.kind === "plan" + || block.kind === "compaction"; +} + +function finishTurnBlocks(blocks: AggregatedBlock[], turnId: string | null): void { + for (const block of blocks) { + const blockTurn = (block as { turnId?: string | null }).turnId ?? null; + if (blockTurn === turnId && isLiveTurnBlock(block)) { + block.live = false; + } + } +} + // Event types that have already contributed to aggregate-level blocks or are // intentionally hidden. Keep unmatched system_notice events visible. const SILENCED_EVENT_TYPES = new Set([ @@ -278,7 +346,6 @@ export function aggregateChatBlocks(args: { for (const line of lines) linesById.set(line.id, line); const blocks: AggregatedBlock[] = []; - const turnStart = new Map(); const pendingSteerIds = new Set(derivePendingSteers(args.events).map((steer) => steer.steerId)); const subagentParentItemIds = new Set(); for (const envelope of args.events) { @@ -324,6 +391,7 @@ export function aggregateChatBlocks(args: { if (!line) return; blocks.push({ kind, id, line } as AggregatedBlock); }; + const workItemStartedAt = new Map(); for (const entry of timeline) { if (entry.kind === "notice") { @@ -340,7 +408,6 @@ export function aggregateChatBlocks(args: { const event = envelope.event; const id = chatEventLineId(envelope, index); const turnId = turnIdOf(event); - if (turnId && !turnStart.has(turnId)) turnStart.set(turnId, entry.timestamp); if (isSubagentTimelineEvent(event)) { continue; @@ -373,15 +440,45 @@ export function aggregateChatBlocks(args: { if (isSubagentChildWorkEvent(event, subagentParentItemIds, subagentChildItemIds)) { continue; } - const last = blocks[blocks.length - 1]; - let workBlock: Extract; - if (last && last.kind === "work-block" && last.turnId === turnId) { - workBlock = last; - } else { - workBlock = { kind: "work-block", id, turnId, tools: [], live: true }; - blocks.push(workBlock); + if (event.type === "tool_call" || event.type === "tool_result" || event.type === "command") { + const last = blocks[blocks.length - 1]; + let group: Extract; + if (last && last.kind === "tool-calls-group" && last.turnId === turnId) { + group = last; + } else { + group = { kind: "tool-calls-group", id, turnId, entries: [], live: true }; + blocks.push(group); + } + if (event.type === "command") { + const key = workItemKey(turnId, event.itemId); + const startedAt = workItemStartedAt.get(key); + const derivedDurationMs = event.status === "running" || startedAt === undefined + ? undefined + : entry.timestamp - startedAt; + if (!workItemStartedAt.has(key)) workItemStartedAt.set(key, entry.timestamp); + appendCommandAsTool(group, event, derivedDurationMs); + } else { + const key = workItemKey(turnId, event.itemId); + if (event.type === "tool_call" && !workItemStartedAt.has(key)) { + workItemStartedAt.set(key, entry.timestamp); + } + const startedAt = workItemStartedAt.get(key); + const derivedDurationMs = event.type === "tool_result" && event.status !== "running" && startedAt !== undefined + ? entry.timestamp - startedAt + : undefined; + appendToolCallEvent(group, event, validDurationMs(derivedDurationMs)); + } + } else if (event.type === "file_change") { + const last = blocks[blocks.length - 1]; + let group: Extract; + if (last && last.kind === "files-changed-group" && last.turnId === turnId) { + group = last; + } else { + group = { kind: "files-changed-group", id, turnId, entries: [], live: true }; + blocks.push(group); + } + appendFileChangeEvent(group, event); } - appendTool(workBlock, event, envelope); if ((args.expandedLineIds?.has(id) ?? false) && isExpandedFailureEvent(event)) { passthrough(id, "error"); } @@ -475,19 +572,8 @@ export function aggregateChatBlocks(args: { continue; } if (event.type === "status") { - const startMs = turnId ? turnStart.get(turnId) : undefined; - const durationMs = startMs !== undefined ? entry.timestamp - startMs : undefined; if (event.turnStatus !== "started") { - for (const block of blocks) { - const blockTurn = (block as { turnId?: string | null }).turnId ?? null; - if (blockTurn !== turnId) continue; - if (block.kind === "work-block" || block.kind === "plan" || block.kind === "compaction") { - block.live = false; - if (durationMs !== undefined && block.kind === "work-block") { - block.durationMs = block.durationMs ?? durationMs; - } - } - } + finishTurnBlocks(blocks, turnId); } if (event.turnStatus === "failed" || event.turnStatus === "interrupted") { passthrough(id, "error"); @@ -495,18 +581,7 @@ export function aggregateChatBlocks(args: { continue; } if (event.type === "done") { - const startMs = turnId ? turnStart.get(turnId) : undefined; - const durationMs = startMs !== undefined ? entry.timestamp - startMs : undefined; - for (const block of blocks) { - const blockTurn = (block as { turnId?: string | null }).turnId ?? null; - if (blockTurn !== turnId) continue; - if (block.kind === "work-block" || block.kind === "plan" || block.kind === "compaction") { - block.live = false; - if (durationMs !== undefined && block.kind === "work-block") { - block.durationMs = block.durationMs ?? durationMs; - } - } - } + finishTurnBlocks(blocks, turnId); continue; } if (SILENCED_EVENT_TYPES.has(event.type)) { @@ -523,12 +598,12 @@ export function aggregateChatBlocks(args: { }); } - // Mark assistant-text blocks that follow a heavy work block for top spacing. + // Mark assistant-text blocks that follow a heavy group block for top spacing. for (let index = 1; index < blocks.length; index += 1) { const current = blocks[index]!; if (current.kind !== "assistant-text") continue; const prev = blocks[index - 1]!; - if (prev.kind === "work-block") { + if (prev.kind === "tool-calls-group" || prev.kind === "files-changed-group") { current.precededByHeavy = true; } } diff --git a/apps/ade-cli/src/tuiClient/app.tsx b/apps/ade-cli/src/tuiClient/app.tsx index 55e74ea5a..b58a0170e 100644 --- a/apps/ade-cli/src/tuiClient/app.tsx +++ b/apps/ade-cli/src/tuiClient/app.tsx @@ -9,14 +9,15 @@ import { getModelById, listModelDescriptorsForProvider, modelSupportsFastMode, + resolveModelDescriptor, resolveProviderGroupForModel, } 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 type { AgentChatCodexApprovalPolicy, AgentChatCodexConfigSource, AgentChatCodexSandbox, - AgentChatClaudeMcpServerStatus, AgentChatClaudePlugin, AgentChatReloadClaudePluginsResult, AgentChatContextUsage, @@ -31,6 +32,8 @@ import type { import type { AiSettingsStatus, OpenCodeRuntimeSnapshot } from "../../../desktop/src/shared/types/config"; import type { DiffLineStats } from "../../../desktop/src/shared/types/git"; import type { LaneSummary } from "../../../desktop/src/shared/types/lanes"; +import type { FeedbackPreparedDraft, FeedbackSubmission } from "../../../desktop/src/shared/types/feedback"; +import type { ChatTerminalPreviewResult, ChatTerminalSession } from "../../../desktop/src/shared/types"; import { DEFAULT_CODEX_REASONING_EFFORT, approveToolUse, @@ -42,7 +45,6 @@ import { getAvailableModels, getAiSettingsStatus, getChatHistory, - getClaudeMcpStatus, getContextUsage, getOpenCodeRuntimeDiagnostics, getSlashCommands, @@ -54,25 +56,42 @@ import { listClaudePlugins, listClaudeOutputStyles, listChatSessions, + listTerminalSessions, listLanes, listPrsByLane, navigateDesktop, newestSession, + normalizeChatTerminalSession, + previewTerminal, renameChat, + resizeTerminal, reloadClaudePlugins, respondToInput, sendChatMessage, + sendToTerminalSession, + signalTerminal, setClaudeOutputStyle, + startClaudeTerminalSession, steerChatMessage, tagChat, updateChatModel, + writeTerminal, type TokenStats, } from "./adeApi"; import { derivePendingSteers } from "./aggregate"; import { paletteCommands, parseCommand } from "./commands"; +import { hasFirstUserMessage, isPlanMode } from "./planMode"; import { connectToAde } from "./connection"; import { Drawer, visibleDrawerChatCount, visibleDrawerLaneCount, type DrawerPrSummary } from "./components/Drawer"; -import { ChatView, computeChatScrollMaxOffset, renderChatTranscriptPlainText } from "./components/ChatView"; +import { + ChatView, + computeChatScrollMaxOffset, + renderChatTranscriptPlainText, + renderChatVisibleRowTexts, + selectedTextFromVisibleChatRows, + type ChatTextSelection, +} from "./components/ChatView"; +import { TerminalPane, clampTerminalPaneCols } from "./components/TerminalPane"; import { Header } from "./components/Header"; import { LANE_DETAIL_ACTIONS, RightPane } from "./components/RightPane"; import { SlashPalette, SLASH_PALETTE_ROWS } from "./components/SlashPalette"; @@ -91,6 +110,13 @@ import { appendReservedTuiEvent, reserveTuiEventDedupKey, syncTuiEventDedupKeys import { loadAdeCodeState, saveAdeCodeState } from "./state"; import { SpinTickProvider } from "./spinTick"; import { buildLinearToolRequest } from "./linearCommands"; +import { + buildFeedbackDraftInput, + buildFeedbackEnvironment, + feedbackFormFields, + feedbackSubmissionNotice, + type FeedbackFormValues, +} from "./feedback"; import { buildPendingInputAnswers, latestPendingApproval } from "./pendingInput"; import { claudeHomePath, defaultKeybindingsPath, dispatchKeybinding, openKeybindingsFile, readClaudeKeybindingsFile, type KeybindingDispatchState, type TuiKeybindingAction } from "./keybindings"; import { @@ -140,6 +166,28 @@ export function footerControlsForAvailability(agentsAvailable: boolean): FooterC return agentsAvailable ? ["agents", "drawer", "details"] : ["drawer", "details"]; } +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", + }; +} + const DESKTOP_COMMAND_ROUTES: Record = { "/app-control": "/app-control", "/browser": "/browser", @@ -322,6 +370,33 @@ function permissionSummary(modelState: AdeCodeModelState): string { 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"; @@ -428,7 +503,6 @@ function formatContextUsage(usage: AgentChatContextUsage | null): string { }); const extras: string[] = []; if (usage.memoryFiles?.length) extras.push(`${usage.memoryFiles.length} memory file${usage.memoryFiles.length === 1 ? "" : "s"}`); - if (usage.mcpTools?.length) extras.push(`${usage.mcpTools.length} MCP tool${usage.mcpTools.length === 1 ? "" : "s"}`); return [header, usage.model ? `Model: ${usage.model}` : null, "", ...rows, extras.length ? `\n${extras.join(" · ")}` : null] .filter((line): line is string => line != null) .join("\n"); @@ -662,24 +736,6 @@ function formatOutputStyles(styles: Awaited { - const scope = server.scope ? ` · ${server.scope}` : ""; - const toolCount = server.tools?.length ? ` · ${server.tools.length} tool${server.tools.length === 1 ? "" : "s"}` : ""; - const target = server.config?.url ?? server.config?.command ?? ""; - const detail = server.error ?? target; - return [ - `${server.name}: ${server.status}${scope}${toolCount}`, - detail ? ` ${detail}` : null, - ].filter((line): line is string => Boolean(line)).join("\n"); - }), - ].join("\n"); -} - function formatClaudePlugins(plugins: AgentChatClaudePlugin[]): string { if (!plugins.length) return "No local Claude plugins were discovered for this chat."; return [ @@ -697,7 +753,6 @@ function formatPluginReload(result: AgentChatReloadClaudePluginsResult): string `Reloaded ${result.plugins.length} plugin${result.plugins.length === 1 ? "" : "s"} with ${result.errorCount} error${result.errorCount === 1 ? "" : "s"}.`, result.commands.length ? `${result.commands.length} command${result.commands.length === 1 ? "" : "s"}` : null, result.agents.length ? `${result.agents.length} agent${result.agents.length === 1 ? "" : "s"}` : null, - result.mcpServers.length ? `${result.mcpServers.length} MCP server${result.mcpServers.length === 1 ? "" : "s"}` : null, "", ...result.plugins.map((plugin) => `- ${plugin.name}\n ${plugin.path}`), ].filter((line): line is string => line != null).join("\n"); @@ -863,7 +918,6 @@ function formatDoctorReport(args: { workspaceRoot: string; activeProvider?: string | null; pluginCount: number | null; - mcpCount: number | null; }): string { const keybindings = readClaudeKeybindingsFile({ create: false }); const statusLine = formatClaudeStatusLineConfig(args.workspaceRoot); @@ -873,7 +927,6 @@ function formatDoctorReport(args: { `provider: ${args.activeProvider ?? "none"}`, `keybindings: ${keybindings.warnings.length ? `${keybindings.warnings.length} warning${keybindings.warnings.length === 1 ? "" : "s"}` : "ok"}`, args.pluginCount == null ? "plugins: not checked" : `plugins: ${args.pluginCount}`, - args.mcpCount == null ? "MCP servers: not checked" : `MCP servers: ${args.mcpCount}`, "", statusLine, ].join("\n"); @@ -1114,6 +1167,13 @@ function printableInput(input: string): string { return input.replace(/[\u0000-\u001f\u007f]/g, ""); } +function printablePromptInput(input: string): string { + return input + .replace(/\r\n/g, "\n") + .replace(/\r/g, "\n") + .replace(/[\u0000-\u0009\u000b-\u001f\u007f]/g, ""); +} + export function deletePreviousPromptWord(value: string): string { let index = value.length; while (index > 0 && /\s/.test(value[index - 1] ?? "")) index -= 1; @@ -1121,19 +1181,52 @@ export function deletePreviousPromptWord(value: string): string { return value.slice(0, index); } +export function deletePreviousPromptLine(value: string): string { + if (!value) return ""; + if (/\r?\n$/.test(value)) return value.replace(/\r?\n$/, ""); + if (/\r$/.test(value)) return value.slice(0, -1); + const index = value.lastIndexOf("\n"); + return index === -1 ? "" : value.slice(0, index + 1); +} + export function isPromptWordBackspace(input: string, key: { ctrl?: boolean; meta?: boolean; backspace?: boolean; delete?: boolean }): boolean { if (key.ctrl && input === "w") return true; - if ((key.ctrl || key.meta) && (key.backspace || key.delete)) return true; + if (key.ctrl && (key.backspace || key.delete)) return true; + if (key.meta && (key.backspace || key.delete)) return true; if (key.meta && (input === "\u007f" || input === "\b" || input === "\x1b\u007f" || input === "\x1b\b")) return true; if (key.ctrl && (input === "\u007f" || input === "\b" || input === "h")) return true; return false; } +export function isPromptLineBackspace(input: string, key: { ctrl?: boolean; meta?: boolean; backspace?: boolean; delete?: boolean }): boolean { + if (key.ctrl && !key.meta && input === "u") return true; + return false; +} + function inputBeforeLineBreak(input: string): string | null { const index = input.search(/[\r\n]/); return index === -1 ? null : input.slice(0, index); } +const PROMPT_MAX_ROWS = 5; + +export function promptDisplayRows(value: string, width: number, maxRows = PROMPT_MAX_ROWS): string[] { + const safeWidth = Math.max(1, Math.floor(width)); + const rows: string[] = []; + for (const rawLine of value.split(/\r?\n/)) { + const chars = [...rawLine]; + if (!chars.length) { + rows.push(""); + continue; + } + for (let index = 0; index < chars.length; index += safeWidth) { + rows.push(chars.slice(index, index + safeWidth).join("")); + } + } + const nonEmptyRows = rows.length ? rows : [""]; + return nonEmptyRows.slice(-Math.max(1, maxRows)); +} + function runInteractiveTerminalCommand(command: string, args: string[], cwd: string): Promise { return new Promise((resolve, reject) => { const stdin = process.stdin as NodeJS.ReadStream & { isRaw?: boolean; setRawMode?: (mode: boolean) => void }; @@ -1219,7 +1312,7 @@ function useTerminalAlternateScroll(): void { } type TerminalMouseInput = { - kind: "wheel" | "click" | "other"; + kind: "wheel" | "click" | "drag" | "release" | "other"; x: number | null; y: number | null; direction?: "up" | "down" | "left" | "right"; @@ -1227,15 +1320,18 @@ type TerminalMouseInput = { function decodeMouseButton(code: number, x: number | null, y: number | null, pressed: boolean): TerminalMouseInput { if (!pressed) { - return { kind: "other", x, y }; + return { kind: "release", x, y }; + } + if (code & 64) { + const wheelButton = code & 3; + if (wheelButton === 0) return { kind: "wheel", direction: "up", x, y }; + if (wheelButton === 1) return { kind: "wheel", direction: "down", x, y }; + if (wheelButton === 2) return { kind: "wheel", direction: "left", x, y }; + return { kind: "wheel", direction: "right", x, y }; } - if (!(code & 64) && (code & 3) === 0) return { kind: "click", x, y }; - if (!(code & 64)) return { kind: "other", x, y }; - const wheelButton = code & 3; - if (wheelButton === 0) return { kind: "wheel", direction: "up", x, y }; - if (wheelButton === 1) return { kind: "wheel", direction: "down", x, y }; - if (wheelButton === 2) return { kind: "wheel", direction: "left", x, y }; - return { kind: "wheel", direction: "right", x, y }; + if ((code & 32) && (code & 3) === 0) return { kind: "drag", x, y }; + if ((code & 3) === 0) return { kind: "click", x, y }; + return { kind: "other", x, y }; } export function parseTerminalMouseInput(input: string): TerminalMouseInput | null { @@ -1277,22 +1373,191 @@ export function clampChatScrollOffsetRows(value: number, maxOffset: number): num return Math.max(0, Math.min(Math.floor(value), safeMax)); } +export function isTerminalMouseTrackingEnabled(value?: string): boolean { + return !/^(0|false|no|off)$/i.test((value ?? "").trim()); +} + +function disableTerminalMouseTracking(): void { + process.stdout.write("\x1b[?1015l\x1b[?1006l\x1b[?1003l\x1b[?1002l\x1b[?1000l"); +} + function useTerminalMouseTracking(): void { useEffect(() => { if (!process.stdin.isTTY || !process.stdout.isTTY) return; - const disabled = /^(0|false|no|off)$/i.test(process.env.ADE_TUI_MOUSE ?? ""); - if (disabled) return; + disableTerminalMouseTracking(); + if (!isTerminalMouseTrackingEnabled(process.env.ADE_TUI_MOUSE)) return; process.stdout.write("\x1b[?1000h\x1b[?1002h\x1b[?1006h\x1b[?1015h"); return () => { - process.stdout.write("\x1b[?1015l\x1b[?1006l\x1b[?1002l\x1b[?1000l"); + disableTerminalMouseTracking(); }; }, []); } const DRAWER_PANE_WIDTH = 32; const MIN_CENTER_PANE_WIDTH = 24; +// When the chat fills most of the terminal (drawer and/or right pane closed), +// agent text can stretch into hard-to-read 200-char lines. Cap the wrap width +// here so the rhythm stays comfortable; the rendered Box still fills the full +// centerWidth, but the wrap budget passed to ChatView is bounded. +const READABLE_CHAT_MAX_WIDTH = 110; const MIN_RIGHT_PANE_WIDTH = 30; const RIGHT_PANE_MAX_WIDTH = 42; +const CLAUDE_TERMINAL_HIDDEN_INPUT_ROWS = 3; + +function finiteFloor(value: number, fallback: number): number { + return Number.isFinite(value) ? Math.floor(value) : fallback; +} + +function safeCenterWidth(centerWidth: number): number { + return Math.max(MIN_CENTER_PANE_WIDTH, finiteFloor(centerWidth, MIN_CENTER_PANE_WIDTH)); +} + +export function resolveChatWrapWidth(centerWidth: number, drawerOpen: boolean, rightPaneWidth: number): number { + const safeWidth = safeCenterWidth(centerWidth); + const bothSidePanesOpen = drawerOpen && rightPaneWidth > 0; + return bothSidePanesOpen + ? safeWidth + : Math.min(safeWidth, READABLE_CHAT_MAX_WIDTH); +} + +export function resolveTerminalPaneWidth(centerWidth: number): number { + return safeCenterWidth(centerWidth); +} + +export function encodeTerminalPromptSubmit(value: string): string { + const normalized = value.replace(/\r\n/g, "\n").replace(/\r/g, "\n"); + if (normalized.includes("\n")) return `\x1b[200~${normalized}\x1b[201~\r`; + return `${normalized}\r`; +} + +function claudeTerminalRowsForPane(rows: number): number { + const safeRows = finiteFloor(rows, 4); + return Math.max( + 4, + Math.min(120, safeRows + CLAUDE_TERMINAL_HIDDEN_INPUT_ROWS), + ); +} + +function terminalSessionProvider(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 session ? "claude" : null; +} + +function promptTextForTerminal(text: string, attachments: AgentChatFileRef[]): string { + const attachmentPaths = attachments.map((attachment) => attachment.path).filter(Boolean); + if (!attachmentPaths.length) return text; + const attachmentBlock = ["Attached files:", ...attachmentPaths.map((filePath) => `- ${filePath}`)].join("\n"); + return text ? `${text}\n\n${attachmentBlock}` : attachmentBlock; +} + +function terminalPromptNeedle(value: string): string | null { + const lines = value + .replace(/\r\n/g, "\n") + .replace(/\r/g, "\n") + .split("\n") + .map((line) => line.trim()) + .filter(Boolean); + const last = lines[lines.length - 1] ?? ""; + return last.length ? last.slice(0, 96) : null; +} + +function terminalHiddenRowsContainPrompt(preview: ChatTerminalPreviewResult | null, value: string): boolean { + const needle = terminalPromptNeedle(value); + const rows = preview?.snapshot?.visibleRows ?? []; + if (!needle || !rows.length) return false; + const hiddenText = rows + .slice(-CLAUDE_TERMINAL_HIDDEN_INPUT_ROWS) + .map((row) => row.text) + .join("\n") + .trim(); + return hiddenText.includes(needle); +} + +function signalTerminalWithCliSync(args: { + projectRoot: string; + socketPath?: string | null; + terminalId: string; + signal: "SIGTERM" | "SIGKILL"; +}): boolean { + const entrypoint = process.argv[1]; + if (!entrypoint || !fs.existsSync(entrypoint)) return false; + const env = { ...process.env }; + const socketPath = args.socketPath?.trim() || process.env.ADE_RUNTIME_SOCKET_PATH?.trim() || null; + if (socketPath) { + env.ADE_RUNTIME_SOCKET_PATH = socketPath; + env.ADE_RPC_SOCKET_PATH = socketPath; + } + const result = spawnSync(process.execPath, [ + entrypoint, + "--project-root", + args.projectRoot, + "--socket", + "terminal", + "signal", + "--terminal", + args.terminalId, + "--signal", + args.signal, + "--json", + ], { + env, + stdio: "ignore", + timeout: 1_000, + }); + return !result.error && result.status === 0; +} + +function modelInfoFromDescriptor(modelRef: string): AgentChatModelInfo | null { + const descriptor = resolveModelDescriptor(modelRef); + if (!descriptor) return null; + return { + id: descriptor.id, + modelId: descriptor.id, + displayName: descriptor.displayName, + isDefault: false, + reasoningEfforts: descriptor.reasoningTiers?.map((effort) => ({ effort, description: effort })), + }; +} + +function findModelForArg(provider: AdeCodeProvider, currentModels: AgentChatModelInfo[], value: string): AgentChatModelInfo | null { + const trimmed = value.trim(); + if (!trimmed) return null; + const normalized = trimmed.toLowerCase(); + const descriptorModel = modelInfoFromDescriptor(trimmed); + const pool = [ + ...currentModels, + ...registryModelsForProvider(provider), + ...(descriptorModel ? [descriptorModel] : []), + ]; + return pool.find((entry) => ( + entry.id.toLowerCase() === normalized + || (entry.modelId ?? "").toLowerCase() === normalized + || entry.displayName.toLowerCase() === normalized + )) ?? descriptorModel ?? null; +} + +function modelStatePatchForArg( + provider: AdeCodeProvider, + currentModels: AgentChatModelInfo[], + value: string, +): Pick { + const model = findModelForArg(provider, currentModels, value); + if (model) return modelStatePatchForModel(provider, model); + return { + provider, + model: value, + modelId: value, + displayName: value, + reasoningEffort: provider === "codex" ? DEFAULT_CODEX_REASONING_EFFORT : null, + }; +} function resolveRightPaneWidth(columns: number, rightOpen: boolean, drawerOpen: boolean, maxWidth = RIGHT_PANE_MAX_WIDTH): number { if (!rightOpen) return 0; @@ -1324,6 +1589,10 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } const [prByLaneId, setPrByLaneId] = useState>({}); const [diffByLaneId, setDiffByLaneId] = useState>({}); const [sessions, setSessions] = useState([]); + const [terminalSessions, setTerminalSessions] = useState([]); + const [terminalPreview, setTerminalPreview] = useState(null); + const [attachedTerminalId, setAttachedTerminalId] = useState(null); + const [terminalLiveChunks, setTerminalLiveChunks] = useState>({}); const [activeLaneId, setActiveLaneId] = useState(null); const [activeSessionId, setActiveSessionId] = useState(null); const [events, setEvents] = useState([]); @@ -1332,6 +1601,12 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } const [keybindings, setKeybindings] = useState(() => readClaudeKeybindingsFile({ create: false }).bindings); const [models, setModels] = useState([]); const [modelState, setModelState] = useState(initialModelState); + const [modeChangeNotice, setModeChangeNotice] = useState<{ summary: string; key: string } | null>(null); + const lastPermissionSummaryRef = useRef(null); + const modeNoticeTimerRef = useRef(null); + // Only user-initiated mode changes (Shift+Tab, /plan, inline-row edit, picker) + // fire the banner. Session-load background syncs do not. + const userInitiatedModeChangeRef = useRef(false); const [draftChatActive, setDraftChatActive] = useState(false); const [aiStatus, setAiStatus] = useState(null); const [aiStatusCheckedAt, setAiStatusCheckedAt] = useState(null); @@ -1355,6 +1630,8 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } const [vimMode, setVimMode] = useState<"insert" | "normal">("insert"); const [hideVimModeIndicator, setHideVimModeIndicator] = useState(false); const [streaming, setStreaming] = useState(false); + const [interrupted, setInterrupted] = useState(false); + const [chatMouseSelection, setChatMouseSelection] = useState<(ChatTextSelection & { active: boolean }) | null>(null); const [clearedAt, setClearedAt] = useState(null); const [expandedLineIds, setExpandedLineIds] = useState>(() => new Set()); const [chatScrollOffsetRows, setChatScrollOffsetRows] = useState(0); @@ -1370,6 +1647,8 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } const [selectedDrawerChatAction, setSelectedDrawerChatAction] = useState(null); const [formDiscardArmed, setFormDiscardArmed] = useState(false); const [footerControl, setFooterControl] = useState(null); + const [inlineRowFocus, setInlineRowFocus] = useState<{ cell: 'provider' | 'model' | 'reasoning' | 'permission' | 'subagents' | null }>({ cell: null }); + const inlineRowFocused = inlineRowFocus.cell !== null; const connectionRef = useRef(null); const activeLaneIdRef = useRef(null); @@ -1391,6 +1670,7 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } const eventDedupKeyOrderRef = useRef([]); const chatScrollOffsetRowsRef = useRef(0); const chatScrollMaxOffsetRef = useRef(0); + const lastSeenAtBottomEventCountRef = useRef(0); const newChatPreviewLaneIdRef = useRef(null); const heartbeatRef = useRef(null); const draftSeededFromHistoryRef = useRef(false); @@ -1403,7 +1683,14 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } const pendingNewChatTitleRef = useRef(null); const lastUserOpenedPaneRef = useRef(null); const activeSessionRef = useRef(null); + const activeTerminalSessionRef = useRef(null); + const terminalSessionsRef = useRef([]); + const attachedTerminalIdRef = useRef(null); + const claudeTerminalSubmitQueueRef = useRef>(Promise.resolve()); + const exitRequestedRef = useRef(false); const modelStateRef = useRef(initialModelState()); + const chatMouseSelectionRef = useRef<(ChatTextSelection & { active: boolean }) | null>(null); + const loadedSessionIdRef = useRef(null); const providerModelsCacheRef = useRef>(new Map()); const pendingModelCommitTimerRef = useRef(null); const pendingModelCommitStateRef = useRef(null); @@ -1442,6 +1729,7 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } setTokenSummary(null); setStatusLineStats(null); setStreaming(false); + setInterrupted(false); }, []); const selectActiveLaneId = useCallback((laneId: string | null) => { @@ -1461,7 +1749,9 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } lastUserOpenedPaneRef.current = null; } if (!sessionId) { + activeTerminalSessionRef.current = null; clearTranscriptPreview(); + setAttachedTerminalId(null); } if (sessionId) { newChatPreviewLaneIdRef.current = null; @@ -1498,6 +1788,31 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } promptRef.current = prompt; }, [prompt]); + useEffect(() => { + chatMouseSelectionRef.current = chatMouseSelection; + }, [chatMouseSelection]); + + useEffect(() => { + const summary = permissionSummary(modelState); + const previous = lastPermissionSummaryRef.current; + lastPermissionSummaryRef.current = summary; + if (previous == null || previous === summary) return; + if (!userInitiatedModeChangeRef.current) return; + userInitiatedModeChangeRef.current = false; + const key = `${Date.now()}:${summary}`; + setModeChangeNotice({ summary, key }); + if (modeNoticeTimerRef.current) clearTimeout(modeNoticeTimerRef.current); + modeNoticeTimerRef.current = setTimeout(() => { + setModeChangeNotice((prev) => (prev?.key === key ? null : prev)); + modeNoticeTimerRef.current = null; + }, 3000); + }, [modelState]); + + const updateChatMouseSelection = useCallback((selection: (ChatTextSelection & { active: boolean }) | null) => { + chatMouseSelectionRef.current = selection; + setChatMouseSelection(selection); + }, []); + const stashActiveInput = useCallback(() => { const pane = activePaneRef.current; if (pane === "chat") { @@ -1547,6 +1862,12 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } setPaneFocus("details"); }, [formFieldIndex, formValues, rightPane, selectFooterControl, setPaneFocus, stashActiveInput]); + const clearChatPromptDraft = useCallback(() => { + setPrompt(""); + promptRef.current = ""; + chatDraftRef.current = ""; + }, []); + const toggleDrawerPane = useCallback(() => { selectFooterControl(null); if (drawerOpen) { @@ -1612,14 +1933,43 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } () => sessions.find((session) => session.sessionId === activeSessionId) ?? null, [activeSessionId, sessions], ); - const activeCommandProvider = activeSession?.provider ?? modelState.provider; + const activeTerminalSession = useMemo( + () => terminalSessions.find((session) => session.terminalId === activeSessionId) ?? null, + [activeSessionId, terminalSessions], + ); + const displaySessions = useMemo( + () => [...sessions, ...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); + }), + [sessions, terminalSessions], + ); + const activeCommandProvider = terminalSessionProvider(activeTerminalSession) ?? activeSession?.provider ?? modelState.provider; + // Once a chat has any sent user message, the provider is locked — swapping + // mid-thread breaks runtime continuity. Derived from events; persists across reloads. + const providerLocked = useMemo(() => Boolean(activeSession) && hasFirstUserMessage(events), [activeSession, events]); + const providerLockedRef = useRef(false); + useEffect(() => { + providerLockedRef.current = providerLocked; + }, [providerLocked]); const latestFailedLineId = useMemo(() => latestExpandableFailureId(events), [events]); const subagentSnapshots = useMemo(() => subagentSnapshotsFromEvents(events), [events]); const liveAgentCount = useMemo( () => subagentSnapshots.filter((snap) => snap.status === "running").length, [subagentSnapshots], ); - const subagentPaneAvailable = Boolean(activeSession && !draftChatActive && subagentSnapshots.length > 0); + // Button visibility (shown in the inline model row) requires snapshots > 0. + // Command availability (typing /subagents) only requires a chat to attach to, + // so the slash command can open an empty-state pane. + const subagentsButtonVisible = Boolean(activeSession && !draftChatActive && subagentSnapshots.length > 0); + const subagentPaneCommandAvailable = Boolean(activeSession && !draftChatActive); + const subagentPaneAvailable = subagentsButtonVisible; + const subagentsButtonVisibleRef = useRef(false); + useEffect(() => { + subagentsButtonVisibleRef.current = subagentsButtonVisible; + }, [subagentsButtonVisible]); const footerControls = useMemo( () => footerControlsForAvailability(subagentPaneAvailable), [subagentPaneAvailable], @@ -1647,7 +1997,10 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } }, [rightPane.kind]); useEffect(() => { if (subagentPaneAvailable || rightPane.kind !== "subagents") return; - lastUserOpenedPaneRef.current = null; + // If the user explicitly opened via /subagents, keep the pane open with the + // empty state. Auto-close only fires for transient toggle-style openings + // when the snapshot list has drained. + if (lastUserOpenedPaneRef.current === "subagents") return; setRightPane({ kind: "empty" }); setRightOpen(false); if (activePaneRef.current === "details") { @@ -1659,7 +2012,7 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } setRightSelectionIndex((index) => clampSubagentSelection(rightPane, index)); }, [rightPane]); const openSubagentsPane = useCallback((): boolean => { - if (!subagentPaneAvailable) return false; + if (!subagentPaneCommandAvailable) return false; const previousPane = activePaneRef.current; stashActiveInput(); selectFooterControl(null); @@ -1668,6 +2021,7 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } } setFormDiscardArmed(false); setPrompt(""); + setInlineRowFocus({ cell: null }); setRightPane({ kind: "subagents", tab: "subagents", @@ -1677,6 +2031,7 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } setRightSelectionIndex(0); setRightOpen(true); setPaneFocus("details"); + lastUserOpenedPaneRef.current = "subagents"; return true; }, [ activeSession?.provider, @@ -1684,11 +2039,11 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } selectFooterControl, setPaneFocus, stashActiveInput, - subagentPaneAvailable, + subagentPaneCommandAvailable, subagentSnapshots, ]); const toggleSubagentsPane = useCallback((): boolean => { - if (!subagentPaneAvailable) return true; + if (!subagentPaneCommandAvailable) return true; selectFooterControl(null); if (rightOpen && rightPane.kind === "subagents") { setRightOpen(false); @@ -1696,7 +2051,6 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } focusChat(); return true; } - lastUserOpenedPaneRef.current = "subagents"; openSubagentsPane(); return true; }, [ @@ -1705,7 +2059,7 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } rightOpen, rightPane.kind, selectFooterControl, - subagentPaneAvailable, + subagentPaneCommandAvailable, ]); const promptHistory = useMemo(() => events .map((envelope) => envelope.event) @@ -1734,8 +2088,8 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } [lanes], ); const drawerLaneSessions = useMemo( - () => sessions.filter((session) => session.laneId === drawerLaneId), - [drawerLaneId, sessions], + () => displaySessions.filter((session) => session.laneId === drawerLaneId), + [displaySessions, drawerLaneId], ); const drawerVisibleLaneSessions = useMemo( () => drawerLaneSessions.slice(0, visibleDrawerChatCount(drawerLaneSessions.length)), @@ -1771,10 +2125,15 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } const statusLineRows = statusLineText ? Math.min(3, statusLineText.split(/\r?\n/).filter(Boolean).length || 1) : 0; const statusRows = statusLineRows; const goalBannerRows = goalBannerText ? 1 : 0; - const chatRowBudget = Math.max(4, rows - 8 - statusRows - goalBannerRows); - const rightPaneMaxWidth = rightPane.kind === "model-setup" ? 88 : RIGHT_PANE_MAX_WIDTH; + const rightPaneMaxWidth = RIGHT_PANE_MAX_WIDTH; const rightPaneWidth = resolveRightPaneWidth(columns, rightOpen, drawerOpen, rightPaneMaxWidth); const centerWidth = resolveCenterPaneWidth(columns, drawerOpen, rightPaneWidth); + const promptRows = promptDisplayRows(prompt, Math.max(1, centerWidth - 5), PROMPT_MAX_ROWS); + const chatRowBudget = Math.max(4, rows - 8 - (promptRows.length - 1) - statusRows - goalBannerRows); + // Only cap the prose chat wrap width. Embedded CLI terminals should track the + // center pane so PTYs resize when panes or the host window resize. + const chatWrapWidth = resolveChatWrapWidth(centerWidth, drawerOpen, rightPaneWidth); + const terminalPaneWidth = resolveTerminalPaneWidth(centerWidth); const selectedAgentSnapshot = useMemo(() => { if (!rightOpen || activePane !== "details" || rightPane.kind !== "subagents") return null; return selectedSubagentSnapshot(rightPane, rightSelectionIndex); @@ -1786,6 +2145,13 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } ), [activeSession, events, selectedAgentSnapshot]); const displayNotices = useMemo(() => (selectedAgentSnapshot ? [] : notices), [notices, selectedAgentSnapshot]); const displayStreaming = selectedAgentSnapshot ? selectedAgentSnapshot.status === "running" : streaming; + const displayInterrupted = selectedAgentSnapshot ? false : interrupted && !displayStreaming; + const spinTickActive = displayStreaming + || mode === "connecting" + || sessions.some((session) => session.status === "active") + || terminalSessions.some((session) => session.status === "running") + || liveAgentCount > 0 + || (rightPane.kind === "lane-details" && rightPane.run?.status === "running"); const chatScrollMaxOffset = useMemo(() => computeChatScrollMaxOffset({ events: displayEvents, notices: displayNotices, @@ -1793,11 +2159,44 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } expandedLineIds, maxRows: chatRowBudget, streaming: displayStreaming, - width: centerWidth, - }), [activeSession, centerWidth, chatRowBudget, displayEvents, displayNotices, displayStreaming, expandedLineIds]); + interrupted: displayInterrupted, + width: chatWrapWidth, + }), [activeSession, chatRowBudget, chatWrapWidth, displayEvents, displayInterrupted, displayNotices, displayStreaming, expandedLineIds]); chatScrollMaxOffsetRef.current = chatScrollMaxOffset; const effectiveChatScrollOffsetRows = clampChatScrollOffsetRows(chatScrollOffsetRows, chatScrollMaxOffset); chatScrollOffsetRowsRef.current = effectiveChatScrollOffsetRows; + // Track the event-count snapshot at the moment the user was last anchored to + // the bottom of the transcript. When they scroll up and new messages arrive, + // the delta becomes the "↓ N new messages" pill count. + if (effectiveChatScrollOffsetRows === 0) { + lastSeenAtBottomEventCountRef.current = displayEvents.length; + } + const unseenMessageCount = effectiveChatScrollOffsetRows > 0 + ? Math.max(0, displayEvents.length - lastSeenAtBottomEventCountRef.current) + : 0; + const visibleChatRowTexts = useMemo(() => renderChatVisibleRowTexts({ + events: displayEvents, + notices: displayNotices, + activeSession, + expandedLineIds, + maxRows: chatRowBudget, + scrollOffsetRows: effectiveChatScrollOffsetRows, + unseenMessageCount, + width: chatWrapWidth, + streaming: displayStreaming, + interrupted: displayInterrupted, + }), [ + activeSession, + chatRowBudget, + chatWrapWidth, + displayEvents, + displayInterrupted, + displayNotices, + displayStreaming, + effectiveChatScrollOffsetRows, + expandedLineIds, + unseenMessageCount, + ]); const providerReadinessRows = useMemo( () => buildProviderReadinessRows(aiStatus, storedApiKeyProviders, openCodeDiagnostics), [aiStatus, openCodeDiagnostics, storedApiKeyProviders], @@ -1818,7 +2217,7 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } modelState, models, includeRefresh: true, - includeApply: false, + includeApply: true, outputStyle: activeSession?.claudeOutputStyle ?? "default", outputStyleEditable: Boolean(activeSession?.sessionId && activeSession.provider === "claude"), }), mode), @@ -1837,6 +2236,53 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } activeSessionRef.current = activeSession; }, [activeSession]); + useEffect(() => { + activeTerminalSessionRef.current = activeTerminalSession; + }, [activeTerminalSession]); + + useEffect(() => { + terminalSessionsRef.current = terminalSessions; + }, [terminalSessions]); + + useEffect(() => { + if (!activeTerminalSession) setTerminalPreview(null); + }, [activeTerminalSession]); + + useEffect(() => { + attachedTerminalIdRef.current = attachedTerminalId; + }, [attachedTerminalId]); + + useEffect(() => { + if (!connection || !activeTerminalSession) return; + const cols = clampTerminalPaneCols(terminalPaneWidth); + const terminalRows = claudeTerminalRowsForPane(chatRowBudget); + void resizeTerminal(connection, activeTerminalSession.terminalId, cols, terminalRows).catch(() => {}); + }, [activeTerminalSession, chatRowBudget, connection, terminalPaneWidth]); + + useEffect(() => { + if (!connection || !activeTerminalSession) return; + let cancelled = false; + const refreshPreview = () => { + void previewTerminal(connection, activeTerminalSession.terminalId) + .then((preview) => { + if (!cancelled && activeSessionIdRef.current === activeTerminalSession.terminalId) { + setTerminalPreview(preview); + } + }) + .catch(() => { + if (!cancelled && activeSessionIdRef.current === activeTerminalSession.terminalId) { + setTerminalPreview(null); + } + }); + }; + refreshPreview(); + const timer = setInterval(refreshPreview, 500); + return () => { + cancelled = true; + clearInterval(timer); + }; + }, [activeTerminalSession, connection]); + useEffect(() => { modelStateRef.current = modelState; }, [modelState]); @@ -1929,17 +2375,6 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } rows: newChatSetupRows, } : prev); - } else if (rightPane.kind === "model-setup") { - setRightPane((prev) => prev.kind === "model-setup" - ? { - ...prev, - rows: modelSetupRows, - providerRows: providerReadinessRows, - activeProvider: modelState.provider, - checkedAt: aiStatusCheckedAt, - desktopAttached: mode === "attached", - } - : prev); } else if (rightPane.kind === "subagents") { const provider = activeSession?.provider ?? modelState.provider; setRightPane((prev) => prev.kind === "subagents" @@ -1953,7 +2388,7 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } } : prev); } - }, [activeLane?.name, activeLaneId, activeSession, aiStatusCheckedAt, events, mode, modelSetupRows, modelState.provider, newChatSetupRows, providerReadinessRows, rightPane.kind, subagentSnapshots, tokenSummary]); + }, [activeLane?.name, activeLaneId, activeSession, events, mode, modelState.provider, newChatSetupRows, rightPane.kind, subagentSnapshots, tokenSummary]); useEffect(() => { const { config } = readClaudeStatusLineConfig(project.workspaceRoot); @@ -2242,6 +2677,31 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } ]); }, []); + useEffect(() => { + if (!connection || !attachedTerminalId) return; + const handleRawInput = (chunk: Buffer | string) => { + const raw = Buffer.isBuffer(chunk) ? chunk.toString("utf8") : chunk; + if (!raw) return; + if (raw.includes("\x1d")) { + const forwarded = raw.replace(/\x1d/g, ""); + setAttachedTerminalId(null); + if (forwarded) { + void writeTerminal(connection, attachedTerminalId, forwarded).catch((err) => { + addNotice(err instanceof Error ? err.message : String(err), "error"); + }); + } + return; + } + void writeTerminal(connection, attachedTerminalId, raw).catch((err) => { + addNotice(err instanceof Error ? err.message : String(err), "error"); + }); + }; + process.stdin.on("data", handleRawInput); + return () => { + process.stdin.off("data", handleRawInput); + }; + }, [addNotice, attachedTerminalId, connection]); + const reloadKeybindings = useCallback((announce = false) => { const diagnostics = readClaudeKeybindingsFile({ create: false }); setKeybindings(diagnostics.bindings); @@ -2350,6 +2810,15 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } }); }, [openForm]); + const openFeedbackForm = useCallback(() => { + openForm({ + kind: "form", + title: "Feedback", + command: "feedback", + fields: feedbackFormFields(buildFeedbackEnvironment(project, activeLane ?? null)), + }); + }, [activeLane, openForm, project]); + const openNewChatSetup = useCallback((title?: string | null) => { const laneId = activeLaneIdRef.current; const lane = lanes.find((entry) => entry.id === laneId) ?? activeLane; @@ -2380,6 +2849,7 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } } setDraftChatMode(true); selectActiveSessionId(null); + setAttachedTerminalId(null); // New-chat-setup is part of the context default; let the resolver drive it. lastUserOpenedPaneRef.current = null; eventDedupKeysRef.current.clear(); @@ -2402,31 +2872,18 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } void loadProviderModels(modelState.provider, { applyDefault: false }).catch(() => undefined); }, [activeLane, addNotice, focusDetails, lanes, loadProviderModels, modelState.provider, newChatSetupRows, refreshAiSetupStatus, selectActiveSessionId, setDraftChatMode, setPaneFocus, stashActiveInput]); - const openModelSetup = useCallback((options: { forceRefresh?: boolean } = {}) => { - const previousPane = activePaneRef.current; - stashActiveInput(); - if (previousPane !== "details") { - paneBeforeDetailsRef.current = previousPane; - } - setRightSelectionIndex(0); - setRightPane({ - kind: "model-setup", - rows: modelSetupRows, - providerRows: providerReadinessRows, - activeProvider: modelState.provider, - checkedAt: aiStatusCheckedAt, - desktopAttached: mode === "attached", - }); - setRightOpen(true); - // /model is sticky until explicitly closed. - lastUserOpenedPaneRef.current = "model-setup"; - setPrompt(""); - setPaneFocus("details"); + // /model now focuses the always-visible inline row instead of opening a + // separate right pane. When the chat is fresh the cursor lands on the + // provider cell; once a chat is underway the provider is locked, so we land + // on the model cell directly. Refreshes provider status + model list in the + // background so cycling has up-to-date values. + const openModelRow = useCallback((options: { forceRefresh?: boolean } = {}) => { + setInlineRowFocus({ cell: providerLockedRef.current ? "model" : "provider" }); void refreshAiSetupStatus({ force: options.forceRefresh === true }).catch((err) => { addNotice(err instanceof Error ? err.message : String(err), "error"); }); void loadProviderModels(modelState.provider, { applyDefault: false }).catch(() => undefined); - }, [addNotice, aiStatusCheckedAt, loadProviderModels, mode, modelSetupRows, modelState.provider, providerReadinessRows, refreshAiSetupStatus, setPaneFocus, stashActiveInput]); + }, [addNotice, loadProviderModels, modelState.provider, refreshAiSetupStatus]); useEffect(() => { const range = activeMentionRange; @@ -2446,7 +2903,7 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } insertText: `@lane:${lane.id}`, detail: lane.branchRef ?? lane.id, })), - ...sessions.slice(0, 30).map((session) => ({ + ...displaySessions.slice(0, 30).map((session) => ({ kind: "chat" as const, label: session.title ?? session.sessionId, insertText: `@chat:${session.sessionId}`, @@ -2533,17 +2990,19 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } return () => { cancelled = true; }; - }, [activeMentionRange, lanes, selectedMentions, sessions]); + }, [activeMentionRange, displaySessions, lanes, selectedMentions]); const refreshState = useCallback(async () => { const conn = connectionRef.current; if (!conn) return; const nextLanes = await listLanes(conn); const nextSessions = await listChatSessions(conn); + const nextTerminalSessions = await listTerminalSessions(conn).catch(() => []); + const nextDisplaySessions = [...nextSessions, ...nextTerminalSessions.map(terminalSessionToChatSummary)]; const draftMode = draftChatActiveRef.current; const target = resolveTuiChatRefreshTarget({ lanes: nextLanes, - sessions: nextSessions, + sessions: nextDisplaySessions, context: project, lastLaneId: lastLaneIdRef.current, activeLaneId: activeLaneIdRef.current, @@ -2558,6 +3017,9 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } const nextLaneId = target.laneId; const nextSession = target.session; const nextSessionId = nextSession?.sessionId ?? null; + const nextTerminalSession = nextSessionId + ? nextTerminalSessions.find((session) => session.terminalId === nextSessionId) ?? null + : null; const seedSession = target.seedSession; const launchToNewChatPreview = target.launchToNewChatPreview; const previewMode = target.previewMode; @@ -2565,7 +3027,7 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } newChatPreviewLaneIdRef.current = nextLaneId; } let nextEvents: AgentChatEventEnvelope[] = []; - if (nextSessionId) { + if (nextSessionId && !nextTerminalSession) { const history = await getChatHistory(conn, nextSessionId); setCurrentGoal(latestGoal(history.events)); nextEvents = clearedAt @@ -2578,6 +3040,7 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } setTokenSummary(formatTokenSummary(stats)); setStatusLineStats(stats); setStreaming(nextSession?.status === "active"); + if (nextSession?.status === "active") setInterrupted(false); eventCountRef.current = history.events.length; } else { setContextPercent(null); @@ -2585,11 +3048,12 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } setStatusLineStats(null); setCurrentGoal(null); setStreaming(false); + setInterrupted(false); eventCountRef.current = 0; } - const configSession = nextSession ?? (!draftSeededFromHistoryRef.current ? seedSession : null); - const nextProvider = configSession?.provider ?? modelState.provider ?? "codex"; - const commandSessionId = nextSessionId ?? configSession?.sessionId ?? null; + const configSession = nextTerminalSession ? null : nextSession ?? (!draftSeededFromHistoryRef.current ? seedSession : null); + const nextProvider = terminalSessionProvider(nextTerminalSession) ?? configSession?.provider ?? modelState.provider ?? "codex"; + const commandSessionId = nextTerminalSession ? null : nextSessionId ?? configSession?.sessionId ?? null; const commandArgs = commandSessionId ? { sessionId: commandSessionId } : nextLaneId @@ -2609,6 +3073,12 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } ?? null; setLanes(nextLanes); setSessions(nextSessions); + terminalSessionsRef.current = nextTerminalSessions; + setTerminalSessions(nextTerminalSessions); + const attachedId = attachedTerminalIdRef.current; + if (attachedId && !nextTerminalSessions.some((session) => session.terminalId === attachedId)) { + setAttachedTerminalId(null); + } selectActiveLaneId(nextLaneId); selectActiveSessionId(nextSessionId); eventDedupKeyOrderRef.current = syncTuiEventDedupKeys(eventDedupKeysRef.current, nextEvents); @@ -2627,7 +3097,32 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } setSelectedDrawerChatAction(nextLaneId ? "new-chat" : null); setRightOpen(true); } - if (configSession && (!draftMode || !draftSeededFromHistoryRef.current)) { + if (nextTerminalSession && nextSessionId) { + void previewTerminal(conn, nextSessionId) + .then((preview) => { + if (activeSessionIdRef.current === nextSessionId) setTerminalPreview(preview); + }) + .catch(() => { + if (activeSessionIdRef.current === nextSessionId) setTerminalPreview(null); + }); + } else { + setTerminalPreview(null); + } + if (nextTerminalSession) { + const current = modelStateRef.current; + if (current.provider !== "claude") { + setModelState((prev) => { + const next = { + ...prev, + ...fallbackModelStatePatch("claude"), + permissionMode: nextTerminalSession.resumeMetadata?.launch?.permissionMode ?? prev.permissionMode, + claudePermissionMode: nextTerminalSession.resumeMetadata?.launch?.claudePermissionMode ?? prev.claudePermissionMode, + }; + modelStateRef.current = next; + return next; + }); + } + } else if (configSession && (!draftMode || !draftSeededFromHistoryRef.current)) { setModelState((prev) => ({ ...prev, provider, @@ -2655,6 +3150,7 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } const conn = connectionRef.current; const sessionId = activeSessionIdRef.current; if (!conn || !sessionId || draftChatActiveRef.current) return; + if (activeTerminalSessionRef.current) return; const normalized = { ...nextState, ...applyProviderPermissionMode(nextState) }; await updateChatModel({ connection: conn, @@ -2692,6 +3188,54 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } }, 200); }, [addNotice, commitModelStateToSession]); + const resolveActiveTerminalForExit = useCallback((): ChatTerminalSession | null => { + const activeId = activeSessionIdRef.current; + const current = activeTerminalSessionRef.current; + if (current && (current.status === "running" || current.terminalId === activeId)) { + return current; + } + const fromList = activeId + ? terminalSessionsRef.current.find((session) => session.terminalId === activeId) ?? null + : null; + if (fromList && (fromList.status === "running" || fromList.terminalId === activeId)) { + return fromList; + } + const activeLane = activeLaneIdRef.current; + return terminalSessionsRef.current.find((session) => ( + session.status === "running" + && terminalSessionProvider(session) === "claude" + && (!activeLane || session.laneId === activeLane) + )) ?? null; + }, []); + + const signalActiveTerminalForExitSync = useCallback(() => { + const terminal = resolveActiveTerminalForExit(); + if (!terminal) return; + const conn = connectionRef.current; + const socket = conn?.socketPath ?? socketPath ?? null; + signalTerminalWithCliSync({ + projectRoot: project.projectRoot, + socketPath: socket, + terminalId: terminal.terminalId, + signal: "SIGTERM", + }); + signalTerminalWithCliSync({ + projectRoot: project.projectRoot, + socketPath: socket, + terminalId: terminal.terminalId, + signal: "SIGKILL", + }); + }, [project.projectRoot, resolveActiveTerminalForExit, socketPath]); + + const signalActiveTerminalForExit = useCallback(async () => { + const conn = connectionRef.current; + const terminal = resolveActiveTerminalForExit(); + if (!conn || !terminal) return; + await signalTerminal(conn, terminal.terminalId, "SIGTERM").catch(() => undefined); + await delay(350); + await signalTerminal(conn, terminal.terminalId, "SIGKILL").catch(() => undefined); + }, [resolveActiveTerminalForExit]); + useEffect(() => { let cancelled = false; void (async () => { @@ -2701,7 +3245,12 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } await conn.close(); return; } - heartbeatRef.current = startTuiHeartbeat(project.projectRoot); + heartbeatRef.current = startTuiHeartbeat(project.projectRoot, { + beforeSignalExit: () => { + signalActiveTerminalForExitSync(); + return signalActiveTerminalForExit(); + }, + }); connectionRef.current = conn; setConnection(conn); setMode(conn.mode); @@ -2741,7 +3290,7 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } connectionRef.current = null; void conn?.close().catch(() => {}); }; - }, [forceEmbedded, project, requireSocket, socketPath]); + }, [forceEmbedded, project, requireSocket, signalActiveTerminalForExit, signalActiveTerminalForExitSync, socketPath]); useEffect(() => { if (!connection) return; @@ -2771,8 +3320,22 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } return next.events; }); } - if (event.type === "status" && event.turnStatus === "started") setStreaming(true); - if (event.type === "done" || (event.type === "status" && event.turnStatus === "completed")) setStreaming(false); + if (event.type === "status" && event.turnStatus === "started") { + setStreaming(true); + setInterrupted(false); + } + if (event.type === "status" && event.turnStatus === "interrupted") { + setStreaming(false); + setInterrupted(true); + } + if (event.type === "done") { + setStreaming(false); + setInterrupted(event.status === "interrupted"); + } + if (event.type === "status" && (event.turnStatus === "completed" || event.turnStatus === "failed")) { + setStreaming(false); + setInterrupted(false); + } if (event.type === "subagent_started" || event.type === "subagent.started") { // Auto-open only when nothing important is showing. Otherwise the // footer chip surfaces the live agent count without disrupting the user. @@ -2795,13 +3358,62 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } useEffect(() => { if (!connection) return; + let disposed = false; + let unsubscribe: (() => void) | null = null; + void connection.subscribeRuntimeEvents({ category: "runtime", cursor: 0, limit: 50 }, (event) => { + const payload = event.payload as { type?: unknown; event?: unknown }; + const terminalEvent = payload.event as { sessionId?: unknown; data?: unknown } | undefined; + const sessionId = typeof terminalEvent?.sessionId === "string" ? terminalEvent.sessionId : null; + if (!sessionId) return; + if (payload.type === "pty_data" && typeof terminalEvent?.data === "string") { + setTerminalLiveChunks((prev) => { + const nextChunks = [...(prev[sessionId] ?? []), terminalEvent.data as string].slice(-500); + return { ...prev, [sessionId]: nextChunks }; + }); + return; + } + if (payload.type === "pty_exit") { + void refreshState().catch(() => undefined); + } + }).then((stop) => { + if (disposed) { + stop(); + return; + } + unsubscribe = stop; + }).catch(() => {}); + return () => { + disposed = true; + unsubscribe?.(); + }; + }, [connection, refreshState]); + + useEffect(() => { + if (!connection || !activeSessionId) { + loadedSessionIdRef.current = activeSessionId; + return; + } + if (loadedSessionIdRef.current === activeSessionId) return; + loadedSessionIdRef.current = activeSessionId; + void refreshState().catch((err) => { + setError(err instanceof Error ? err.message : String(err)); + }); + }, [activeSessionId, connection, refreshState]); + + const chatRefreshPollActive = streaming + || sessions.some((session) => session.status === "active") + || terminalSessions.some((session) => session.status === "running"); + + useEffect(() => { + if (!connection) return; + const intervalMs = chatRefreshPollActive ? 1_000 : 15_000; const timer = setInterval(() => { void refreshState().catch((err) => { setError(err instanceof Error ? err.message : String(err)); }); - }, 1_000); + }, intervalMs); return () => clearInterval(timer); - }, [connection, refreshState]); + }, [chatRefreshPollActive, connection, refreshState]); useEffect(() => { if (!connection) { @@ -2906,7 +3518,12 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } const conn = connectionRef.current; const laneId = activeLaneIdRef.current; if (!conn || !laneId) return null; - if (activeSessionIdRef.current) return activeSessionIdRef.current; + const currentSessionId = activeSessionIdRef.current; + if (currentSessionId) { + if (sessions.some((entry) => entry.sessionId === currentSessionId)) return currentSessionId; + selectActiveSessionId(null); + setAttachedTerminalId(null); + } const lane = lanes.find((entry) => entry.id === laneId) ?? null; const unavailableMessage = laneWorktreeUnavailableMessage(lane); if (unavailableMessage) { @@ -2943,7 +3560,7 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } selectActiveSessionId(created.id); await refreshState(); return created.id; - }, [addNotice, lanes, modelState, refreshState, selectActiveSessionId, setDraftChatMode]); + }, [addNotice, lanes, modelState, refreshState, selectActiveSessionId, sessions, setDraftChatMode]); const resolvePendingApproval = useCallback(async ( approval: PendingApproval, @@ -2993,8 +3610,132 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } await refreshState(); }, [addNotice, refreshState]); + const refreshTerminalPreview = useCallback(async ( + conn: AdeCodeConnection, + terminalId: string, + ): Promise => { + try { + const preview = await previewTerminal(conn, terminalId); + if (activeSessionIdRef.current === terminalId) setTerminalPreview(preview); + return preview; + } catch { + if (activeSessionIdRef.current === terminalId) setTerminalPreview(null); + return null; + } + }, []); + + const applyLocalModelArg = useCallback((value: string, providerOverride?: AdeCodeProvider) => { + const provider = providerOverride ?? modelStateRef.current.provider; + const availableModels = providerModelsCacheRef.current.get(provider) ?? models; + const patch = modelStatePatchForArg(provider, availableModels, value); + const next = { ...modelStateRef.current, ...patch }; + modelStateRef.current = next; + setModelState(next); + return next; + }, [models]); + + const submitClaudePromptToTerminal = useCallback(async (terminal: ChatTerminalSession, text: string) => { + const conn = connectionRef.current; + const trimmed = text.trim(); + if (!conn || !trimmed) return false; + const run = async (): Promise => { + lastLocalSendAtRef.current = Date.now(); + const cols = clampTerminalPaneCols(terminalPaneWidth); + const terminalRows = claudeTerminalRowsForPane(chatRowBudget); + if (terminal.status === "running") { + await writeTerminal(conn, terminal.terminalId, encodeTerminalPromptSubmit(text)); + await delay(250); + let preview = await refreshTerminalPreview(conn, terminal.terminalId); + if (terminalHiddenRowsContainPrompt(preview, text)) { + await writeTerminal(conn, terminal.terminalId, "\r"); + await delay(150); + preview = await refreshTerminalPreview(conn, terminal.terminalId); + } + return true; + } + const created = await sendToTerminalSession({ + connection: conn, + sessionId: terminal.terminalId, + text, + cols, + rows: terminalRows, + }); + pendingNewChatTitleRef.current = null; + setDraftChatMode(false); + activeTerminalSessionRef.current = normalizeChatTerminalSession(created.session); + selectActiveSessionId(created.sessionId); + await refreshState(); + return true; + }; + const queued = claudeTerminalSubmitQueueRef.current + .catch(() => undefined) + .then(run); + claudeTerminalSubmitQueueRef.current = queued; + return await queued; + }, [addNotice, chatRowBudget, refreshState, refreshTerminalPreview, selectActiveSessionId, setDraftChatMode, terminalPaneWidth]); + + const startClaudeTerminalForPrompt = useCallback(async (text: string): Promise => { + const conn = connectionRef.current; + const laneId = activeLaneIdRef.current; + if (!conn || !laneId) return null; + const lane = lanes.find((entry) => entry.id === laneId) ?? null; + const unavailableMessage = laneWorktreeUnavailableMessage(lane); + if (unavailableMessage) { + if (lane) { + setRightPane(seedLaneDetails(lane, false)); + setRightOpen(true); + } + setDraftChatMode(false); + addNotice(unavailableMessage, "error"); + return null; + } + const normalized = { ...modelStateRef.current, ...applyProviderPermissionMode(modelStateRef.current) }; + const cols = clampTerminalPaneCols(terminalPaneWidth); + const terminalRows = claudeTerminalRowsForPane(chatRowBudget); + const activeClaudeChat = activeSessionRef.current?.provider === "claude" ? activeSessionRef.current : null; + const title = pendingNewChatTitleRef.current + ?? activeClaudeChat?.title + ?? "Claude Code"; + const created = await startClaudeTerminalSession({ + connection: conn, + laneId, + title, + model: normalized.modelId ?? normalized.model, + permissionMode: normalized.permissionMode, + initialInput: text.trim() ? text : null, + cols, + rows: terminalRows, + }); + pendingNewChatTitleRef.current = null; + setDraftChatMode(false); + if (created.session) activeTerminalSessionRef.current = created.session; + selectActiveSessionId(created.sessionId); + await refreshState(); + return created.sessionId; + }, [addNotice, chatRowBudget, lanes, refreshState, selectActiveSessionId, setDraftChatMode, terminalPaneWidth]); + + const sendClaudeModelCommandToTerminal = useCallback(async (modelRef?: string | null): Promise => { + const terminal = activeTerminalSessionRef.current; + if (!terminal || modelStateRef.current.provider !== "claude") return false; + const resolved = resolveClaudeCliModelForLaunch(modelRef ?? modelStateRef.current.modelId ?? modelStateRef.current.model); + if (!resolved) { + addNotice("No Claude model is selected.", "error"); + return false; + } + const sent = await submitClaudePromptToTerminal(terminal, `/model ${resolved}`); + if (sent) addNotice(`Claude Code model change sent: ${resolved}.`, "success"); + return sent; + }, [addNotice, submitClaudePromptToTerminal]); + + const requestAppExit = useCallback(() => { + if (exitRequestedRef.current) return; + exitRequestedRef.current = true; + signalActiveTerminalForExitSync(); + void signalActiveTerminalForExit() + .finally(() => exit()); + }, [exit, signalActiveTerminalForExit, signalActiveTerminalForExitSync]); + const copyVisibleTranscript = useCallback((): boolean => { - const rightPaneWidth = resolveRightPaneWidth(columns, rightOpen, drawerOpen); const transcript = renderChatTranscriptPlainText({ events: displayEvents, notices: displayNotices, @@ -3002,7 +3743,7 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } expandedLineIds, maxRows: chatRowBudget, scrollOffsetRows: effectiveChatScrollOffsetRows, - width: resolveCenterPaneWidth(columns, drawerOpen, rightPaneWidth), + width: chatWrapWidth, }).trim(); if (!transcript) { addNotice("No visible chat text to copy.", "info"); @@ -3019,12 +3760,10 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } addNotice, chatRowBudget, effectiveChatScrollOffsetRows, - columns, + chatWrapWidth, displayEvents, displayNotices, - drawerOpen, expandedLineIds, - rightOpen, rightPane.kind, ]); @@ -3080,7 +3819,6 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } const runRightCommand = useCallback(async (name: string, args: string) => { const conn = connectionRef.current; - if (!conn) return; const laneId = activeLaneIdRef.current; const sessionId = activeSessionIdRef.current; focusDetails(); @@ -3090,6 +3828,56 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } // /new lane re-enter their own flows below which clear this marker. lastUserOpenedPaneRef.current = "details"; + if (!conn) { + if (name === "/help") { + setRightPane({ kind: "help", title: "Help" }); + return; + } + if (name === "/status") { + setRightPane({ + kind: "status", + rows: [ + ["project", project.projectRoot], + ["workspace", project.workspaceRoot], + ["lane", activeLane?.name ?? laneId ?? "none"], + ["chat", activeSession?.title ?? activeSession?.sessionId ?? "none"], + ["ADE", mode], + ], + }); + return; + } + if (name === "/feedback") { + openFeedbackForm(); + return; + } + if (name === "/model") { + openModelRow(); + return; + } + if (name === "/subagents") { + if (!subagentPaneCommandAvailable) { + addNotice("Open a chat first to inspect subagents.", "info"); + return; + } + openSubagentsPane(); + return; + } + if (name === "/system") { + setRightPane({ + kind: "details", + title: "System", + body: renderObject({ project, pid: process.pid, mode }, 24), + }); + return; + } + setRightPane({ + kind: "details", + title: name.replace(/^\//, "") || "ADE", + body: "ADE runtime is still connecting. Try again when the connection is ready.", + }); + return; + } + if (name === "/help") { setRightPane({ kind: "help", title: "Help" }); return; @@ -3112,18 +3900,12 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } } if (name === "/doctor") { let pluginCount: number | null = null; - let mcpCount: number | null = null; if (sessionId && activeSession?.provider === "claude") { try { pluginCount = (await listClaudePlugins(conn, sessionId)).length; } catch { pluginCount = null; } - try { - mcpCount = (await getClaudeMcpStatus(conn, sessionId)).length; - } catch { - mcpCount = null; - } } setRightPane({ kind: "details", @@ -3132,7 +3914,6 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } workspaceRoot: project.workspaceRoot, activeProvider: activeSession?.provider ?? modelState.provider, pluginCount, - mcpCount, }), }); return; @@ -3191,19 +3972,6 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } } return; } - if (name === "/mcp") { - if (!sessionId) { - setRightPane({ kind: "details", title: "MCP", body: "No active chat is selected." }); - return; - } - if (activeSession?.provider !== "claude") { - setRightPane({ kind: "details", title: "MCP", body: "/mcp is only available for Claude chats." }); - return; - } - const statuses = await getClaudeMcpStatus(conn, sessionId); - setRightPane({ kind: "details", title: "MCP", body: formatMcpStatus(statuses) }); - return; - } if (name === "/output-style") { if (!sessionId) { setRightPane({ kind: "details", title: "Output style", body: "No active chat is selected." }); @@ -3482,6 +4250,10 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } setRightPane({ kind: "details", title: request.title, body: renderObject(result, 24) }); return; } + if (name === "/feedback") { + openFeedbackForm(); + return; + } if (name === "/memory") { const query = args || "project"; const result = await conn.tool("memory_search", { query, scope: "project", limit: 10 }); @@ -3493,7 +4265,7 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } return; } if (name === "/chats") { - const laneSessions = sessions.filter((session) => session.laneId === laneId); + const laneSessions = displaySessions.filter((session) => session.laneId === laneId); const selectedIndex = Math.max(0, laneSessions.findIndex((session) => session.sessionId === sessionId)); setRightSelectionIndex(selectedIndex); setRightPane({ @@ -3524,7 +4296,7 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } selectActiveLaneId(lane.id); setDrawerLaneId(lane.id); setSelectedDrawerLaneId(lane.id); - const session = newestSession(sessions.filter((entry) => entry.laneId === lane.id)); + const session = newestSession(displaySessions.filter((entry) => entry.laneId === lane.id)); selectActiveSessionId(session?.sessionId ?? null); setSelectedDrawerChatId(session?.sessionId ?? null); addNotice(`Switched to lane ${lane.name}.`, "success"); @@ -3534,44 +4306,15 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } return; } if (name === "/model") { - if (args) { - if (!sessionId) { - const model = models.find((entry) => entry.id === args || entry.modelId === args); - setModelState((prev) => ({ - ...prev, - model: model?.id ?? args, - modelId: model?.modelId ?? model?.id ?? args, - displayName: model?.displayName ?? args, - })); - addNotice(`Default model set to ${model?.displayName ?? args}.`, "success"); - return; - } - await updateChatModel({ connection: conn, sessionId, modelId: args }); - addNotice(`Model set to ${args}.`, "success"); - await refreshState(); - return; - } - openModelSetup(); + openModelRow(); return; } - if (name === "/effort") { - if (args) { - if (!EFFORTS.includes(args)) { - setRightPane({ kind: "details", title: "Effort", body: `Usage: /effort <${EFFORTS.join("|")}>` }); - return; - } - if (!sessionId) { - setModelState((prev) => ({ ...prev, reasoningEffort: args })); - addNotice(`Default effort set to ${args}.`, "success"); - return; - } - await updateChatModel({ connection: conn, sessionId, reasoningEffort: args }); - addNotice(`Effort set to ${args}.`, "success"); - await refreshState(); + if (name === "/subagents") { + if (!subagentPaneCommandAvailable) { + addNotice("Open a chat first to inspect subagents.", "info"); return; } - setRightSelectionIndex(Math.max(0, EFFORTS.findIndex((effort) => effort === modelState.reasoningEffort))); - setRightPane({ kind: "effort", efforts: EFFORTS, activeEffort: modelState.reasoningEffort }); + openSubagentsPane(); return; } if (name === "/system") { @@ -3619,15 +4362,11 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } : result; setRightPane({ kind: "details", title: `ADE ${domain}.${action}`, body: renderObject(body, 24) }); } - }, [activeLane?.name, activeSession?.provider, activeSession?.sessionId, activeSession?.title, addNotice, ensureActiveSession, focusDetails, lanes, mode, modelState.modelId, models, openForm, openModelSetup, openNewChatSetup, openNewLaneForm, pendingSteers, project, refreshState, selectActiveLaneId, selectActiveSessionId, sendOrSteerChatMessage, sessions, setChatScrollOffset]); + }, [activeLane?.name, activeSession?.provider, activeSession?.sessionId, activeSession?.title, addNotice, ensureActiveSession, focusDetails, lanes, mode, modelState.modelId, modelState.reasoningEffort, models, openFeedbackForm, openForm, openModelRow, openNewChatSetup, openNewLaneForm, openSubagentsPane, pendingSteers, project, refreshState, selectActiveLaneId, selectActiveSessionId, sendOrSteerChatMessage, sessions, setChatScrollOffset, subagentPaneCommandAvailable]); const runInlineCommand = useCallback(async (name: string, args: string) => { - const conn = connectionRef.current; - if (!conn) return; - const laneId = activeLaneIdRef.current; - const sessionId = activeSessionIdRef.current; if (name === "/quit") { - exit(); + requestAppExit(); return; } if (name === "/clear") { @@ -3643,16 +4382,10 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } copyVisibleTranscript(); return; } - if (name === "/end") { - if (!sessionId) { - addNotice("No active chat is selected.", "error"); - return; - } - await conn.action("chat", "dispose", { sessionId }); - addNotice("Ended active chat runtime.", "success"); - await refreshState(); - return; - } + const conn = connectionRef.current; + if (!conn) return; + const laneId = activeLaneIdRef.current; + const sessionId = activeSessionIdRef.current; if (name === "/login") { const provider = normalizeProvider(activeSession?.provider ?? modelState.provider); const loginCommands = loginCommandsForProvider(provider); @@ -3826,7 +4559,7 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } addNotice(result.message ?? "Desktop route unavailable from this runtime.", "error"); } } - }, [activeSession?.provider, addNotice, copyVisibleTranscript, exit, loadProviderModels, modelState.provider, pendingSteers, project, refreshAiSetupStatus, refreshState, setChatScrollOffset, socketPath]); + }, [activeSession?.provider, addNotice, applyLocalModelArg, copyVisibleTranscript, displaySessions, loadProviderModels, modelState.provider, pendingSteers, project, refreshAiSetupStatus, refreshState, requestAppExit, scheduleModelStateCommit, sendClaudeModelCommandToTerminal, setChatScrollOffset, socketPath]); const submitRightForm = useCallback(async ( form: Extract, @@ -3901,6 +4634,47 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } addNotice("Created draft PR.", "success"); await refreshState(); } + + if (form.command === "feedback") { + const summary = requireField("summary", "Summary"); + if (!summary) return; + const draftInput = buildFeedbackDraftInput({ ...values, summary } as FeedbackFormValues); + setRightPane({ kind: "details", title: "Feedback", body: "Posting feedback to GitHub..." }); + try { + const draft = await conn.action("feedback", "prepareDraft", { + draftInput, + modelId: null, + reasoningEffort: null, + }); + const submission = await conn.action("feedback", "submitPreparedDraft", { + draft, + title: draft.title, + body: draft.body, + labels: draft.labels, + }); + setFormDiscardArmed(false); + setFormValues({}); + setFormFieldIndex(0); + setPrompt(""); + setRightOpen(false); + setRightPane({ kind: "empty" }); + lastUserOpenedPaneRef.current = null; + focusAfterDetails(); + const notice = feedbackSubmissionNotice(submission); + addNotice(notice.text, notice.tone); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + setFormDiscardArmed(false); + setFormValues({}); + setFormFieldIndex(0); + setPrompt(""); + setRightOpen(false); + setRightPane({ kind: "empty" }); + lastUserOpenedPaneRef.current = null; + focusAfterDetails(); + addNotice(`Feedback failed: ${message}`, "error"); + } + } }, [addNotice, focusAfterDetails, refreshState, selectActiveLaneId, selectActiveSessionId]); const openLatestImage = useCallback(() => { @@ -3928,8 +4702,34 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } } }, [addNotice, events]); + // Slash commands typed while a turn is streaming or rate-limited used to slip + // past the dispatcher and get queued/sent as plain text. interceptLocalSlashCommand + // routes any recognized inline/right slash before any send path runs. + const interceptLocalSlashCommand = useCallback(async (text: string): Promise => { + if (!text.startsWith("/")) return false; + const parsed = parseCommand(text, slashCommands); + if (!parsed?.spec) return false; + if (parsed.spec.providers?.length && !parsed.spec.providers.includes(activeCommandProvider)) { + clearChatPromptDraft(); + addNotice(`${parsed.name} is only available for ${parsed.spec.providers.join(", ")} chats.`, "error"); + return true; + } + if (parsed.spec.placement === "inline") { + clearChatPromptDraft(); + await runInlineCommand(parsed.name, parsed.args); + return true; + } + if (parsed.spec.placement === "right") { + clearChatPromptDraft(); + await runRightCommand(parsed.name, parsed.args); + return true; + } + return false; + }, [activeCommandProvider, addNotice, clearChatPromptDraft, runInlineCommand, runRightCommand, slashCommands]); + const submitPrompt = useCallback(async (value: string) => { const text = value.trim(); + const submittedValue = value; const promptAttachments: AgentChatFileRef[] = selectedMentions .filter((mention) => ( mention.kind === "file" @@ -3938,9 +4738,24 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } )) .map((mention) => ({ type: isImageFilePath(mention.filePath!) ? "image" : "file", path: mention.filePath! })); if (!text && rightPane.kind !== "form" && !promptAttachments.length) return; + // Intercept ADE-owned slash commands before the connection gate so /model and + // /plan work pre-chat (splash screen) where connectionRef.current is null. + try { + if (text.startsWith("/") && rightPane.kind !== "form" && !pendingApproval) { + if (await interceptLocalSlashCommand(text)) { + clearChatPromptDraft(); + return; + } + } + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + addNotice(message, "error"); + return; + } const conn = connectionRef.current; if (!conn) return; try { + setInterrupted(false); setPrompt(""); promptRef.current = ""; setChatScrollOffset(0); @@ -3993,6 +4808,11 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } await runRightCommand(selectedCommand.name, selectedCommand.args); return; } + const activeTerminal = activeTerminalSessionRef.current; + if (activeTerminal) { + await submitClaudePromptToTerminal(activeTerminal, selected.name); + return; + } const sessionId = await ensureActiveSession(); if (sessionId) { await sendOrSteerChatMessage(sessionId, selected.name); @@ -4022,6 +4842,21 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } addNotice(`${parsed?.name} is a desktop-only surface; opened ADE desktop.`, "info"); return; } + const terminalPrompt = promptTextForTerminal(text, promptAttachments); + const activeTerminal = activeTerminalSessionRef.current; + if (activeTerminal) { + if (await submitClaudePromptToTerminal(activeTerminal, terminalPrompt)) { + setSelectedMentions((prev) => prev.filter((mention) => !mention.attachment)); + } + return; + } + if (modelStateRef.current.provider === "claude") { + const terminalId = await startClaudeTerminalForPrompt(terminalPrompt || " "); + if (terminalId) { + setSelectedMentions((prev) => prev.filter((mention) => !mention.attachment)); + } + return; + } const sessionId = await ensureActiveSession(); if (!sessionId) { addNotice("No active lane is available for chat.", "error"); @@ -4034,9 +4869,114 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } const message = err instanceof Error ? err.message : String(err); setStreaming(false); setError(message); + if (submittedValue.trim() || promptAttachments.length) { + setPrompt(submittedValue); + promptRef.current = submittedValue; + if (activePaneRef.current === "chat") chatDraftRef.current = submittedValue; + } addNotice(message, "error"); } - }, [activeCommandProvider, activeFormField, addNotice, answerPendingInput, ensureActiveSession, formValues, pendingApproval, resolvePendingApproval, rightPane, runInlineCommand, runRightCommand, selectedMentions, sendOrSteerChatMessage, setChatScrollOffset, slashCommands, slashIndex, slashRows, submitRightForm]); + }, [activeCommandProvider, activeFormField, addNotice, answerPendingInput, clearChatPromptDraft, ensureActiveSession, formValues, interceptLocalSlashCommand, pendingApproval, resolvePendingApproval, rightPane, runInlineCommand, runRightCommand, selectedMentions, sendOrSteerChatMessage, setChatScrollOffset, slashCommands, slashIndex, slashRows, startClaudeTerminalForPrompt, submitClaudePromptToTerminal, submitRightForm]); + + const launchPromptInBackground = useCallback(async (value: string) => { + const text = value.trim(); + const submittedValue = value; + const promptAttachments: AgentChatFileRef[] = selectedMentions + .filter((mention) => ( + mention.kind === "file" + && mention.filePath + && (mention.attachment || (mention.insertText.length > 0 && text.includes(mention.insertText))) + )) + .map((mention) => ({ type: isImageFilePath(mention.filePath!) ? "image" : "file", path: mention.filePath! })); + if (!text && !promptAttachments.length) return; + const conn = connectionRef.current; + const laneId = activeLaneIdRef.current; + if (!conn || !laneId) { + addNotice("No active lane is available for background chat.", "error"); + return; + } + if (!draftChatActiveRef.current || activeSessionIdRef.current) { + addNotice("Background launch is only available from New Chat.", "info"); + return; + } + const lane = lanes.find((entry) => entry.id === laneId) ?? null; + const unavailableMessage = laneWorktreeUnavailableMessage(lane); + if (unavailableMessage) { + if (lane) { + setRightPane(seedLaneDetails(lane, false)); + setRightOpen(true); + } + addNotice(unavailableMessage, "error"); + return; + } + let launched = false; + try { + setInterrupted(false); + setPrompt(""); + promptRef.current = ""; + chatDraftRef.current = ""; + setChatScrollOffset(0); + setError(null); + const normalized = { ...modelStateRef.current, ...applyProviderPermissionMode(modelStateRef.current) }; + if (normalized.provider === "claude") { + const cols = clampTerminalPaneCols(terminalPaneWidth); + const terminalRows = claudeTerminalRowsForPane(chatRowBudget); + const terminalPrompt = promptTextForTerminal(text, promptAttachments); + await startClaudeTerminalSession({ + connection: conn, + laneId, + title: pendingNewChatTitleRef.current ?? "Claude Code", + model: normalized.modelId ?? normalized.model, + permissionMode: normalized.permissionMode, + initialInput: terminalPrompt.trim() ? terminalPrompt : null, + cols, + rows: terminalRows, + }); + launched = true; + } else { + const created = await createChatSession({ + connection: conn, + laneId, + title: pendingNewChatTitleRef.current, + provider: normalized.provider, + modelId: normalized.modelId, + reasoningEffort: normalized.reasoningEffort, + codexFastMode: normalized.codexFastMode, + permissionMode: normalized.permissionMode, + interactionMode: normalized.interactionMode, + claudePermissionMode: normalized.claudePermissionMode, + codexApprovalPolicy: normalized.codexApprovalPolicy, + codexSandbox: normalized.codexSandbox, + codexConfigSource: normalized.codexConfigSource, + opencodePermissionMode: normalized.opencodePermissionMode, + droidPermissionMode: normalized.droidPermissionMode, + cursorModeId: normalized.cursorModeId, + cursorConfigValues: normalized.cursorConfigValues, + }); + lastLocalSendAtRef.current = Date.now(); + await sendChatMessage(conn, created.id, text || "Use the attached image.", promptAttachments); + launched = true; + } + pendingNewChatTitleRef.current = null; + setSelectedMentions((prev) => prev.filter((mention) => !mention.attachment)); + await refreshState().catch((refreshError) => { + addNotice(`Launched chat, but refresh failed: ${refreshError instanceof Error ? refreshError.message : String(refreshError)}`, "error"); + }); + setDraftChatMode(true); + addNotice(`Launched chat in ${lane?.name ?? "lane"}.`, "success"); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + if (!launched) { + setPrompt(submittedValue); + promptRef.current = submittedValue; + chatDraftRef.current = submittedValue; + } else { + setDraftChatMode(true); + } + setError(message); + addNotice(launched ? `Launched chat, but follow-up failed: ${message}` : message, "error"); + } + }, [addNotice, chatRowBudget, lanes, refreshState, selectedMentions, setChatScrollOffset, setDraftChatMode, terminalPaneWidth]); const insertMention = useCallback((suggestion: MentionSuggestion) => { const range = activeMention(prompt); @@ -4065,6 +5005,10 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } }, [scheduleModelStateCommit]); const selectProvider = useCallback((provider: AdeCodeProvider) => { + if (providerLockedRef.current) { + addNotice("Provider is locked for this chat. /new chat to switch.", "info"); + return; + } const immediateModels = providerModelsCacheRef.current.get(provider) ?? registryModelsForProvider(provider); setModels(immediateModels); const model = immediateModels.find((entry) => entry.isDefault) ?? immediateModels[0] ?? null; @@ -4073,13 +5017,17 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } ...(model ? modelStatePatchForModel(provider, model) : fallbackModelStatePatch(provider)), })); void loadProviderModels(provider, { applyDefault: false }).catch(() => undefined); - }, [applyModelState, loadProviderModels]); + }, [addNotice, applyModelState, loadProviderModels]); const cycleProvider = useCallback((delta: number) => { + if (providerLockedRef.current) { + addNotice("Provider is locked for this chat. /new chat to switch.", "info"); + return; + } const index = Math.max(0, PROVIDER_OPTIONS.findIndex((entry) => entry.value === modelState.provider)); const next = PROVIDER_OPTIONS[(index + delta + PROVIDER_OPTIONS.length) % PROVIDER_OPTIONS.length]?.value ?? "codex"; selectProvider(next); - }, [modelState.provider, selectProvider]); + }, [addNotice, modelState.provider, selectProvider]); const cycleModel = useCallback((delta: number) => { const candidates = models.length @@ -4104,6 +5052,7 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } }, [applyModelState, modelState, models]); const cyclePermission = useCallback((delta: number) => { + userInitiatedModeChangeRef.current = true; if (modelState.provider === "codex") { const current = resolveCodexPreset(modelState); const index = Math.max(0, CODEX_PRESETS.findIndex((entry) => entry === current)); @@ -4216,12 +5165,16 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } return; } if (row.kind === "apply") { + if (activeTerminalSessionRef.current && modelStateRef.current.provider === "claude") { + void sendClaudeModelCommandToTerminal() + .catch((err) => addNotice(err instanceof Error ? err.message : String(err), "error")); + } setRightOpen(false); setRightPane({ kind: "empty" }); lastUserOpenedPaneRef.current = null; focusChat(); } - }, [addNotice, applyModelState, cycleModel, cyclePermission, cycleProvider, cycleReasoning, focusChat, refreshAiSetupStatus, refreshState]); + }, [addNotice, applyModelState, cycleModel, cyclePermission, cycleProvider, cycleReasoning, focusChat, refreshAiSetupStatus, refreshState, sendClaudeModelCommandToTerminal]); const recallPromptHistory = useCallback((direction: "previous" | "next"): boolean => { const history = promptHistoryRef.current; @@ -4302,7 +5255,10 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } if (action === "app:interrupt") { const conn = connectionRef.current; const sessionId = activeSessionIdRef.current; - if (streaming && conn && sessionId) { + const activeTurnVisible = streaming || activeSessionRef.current?.status === "active"; + if (activeTurnVisible && conn && sessionId) { + setStreaming(false); + setInterrupted(true); void interruptChat(conn, sessionId) .then(() => addNotice("Interrupted chat.", "info")) .catch((err) => addNotice(err instanceof Error ? err.message : String(err), "error")); @@ -4334,13 +5290,17 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } return true; } if (action === "app:quit" || action === "app:exit") { - exit(); + requestAppExit(); return true; } if (action === "chat:submit") { void submitPrompt(prompt); return true; } + if (action === "chat:launchBackground") { + void launchPromptInBackground(prompt); + return true; + } if (action === "chat:cancel" || action === "chat:clearInput") { handlePromptChange(""); return true; @@ -4353,7 +5313,7 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } return true; } if (action === "chat:modelPicker") { - openModelSetup(); + openModelRow(); return true; } if (action === "chat:fastMode") { @@ -4483,8 +5443,12 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } return true; } if (action === "footer:up" || action === "footer:down") { - if (action === "footer:up") selectFooterControl(null); - else selectFooterControl(footerControls[0] ?? "drawer"); + if (action === "footer:up") { + selectFooterControl(null); + setInlineRowFocus({ cell: providerLockedRef.current ? "model" : "provider" }); + } else { + selectFooterControl(footerControls[0] ?? "drawer"); + } return true; } if ( @@ -4575,11 +5539,86 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } return reportUnavailable(); } return reportUnavailable(); - }, [addNotice, applyModelState, attachClipboardImage, chatRowBudget, copyVisibleTranscript, cycleFooterControl, cyclePaneFocus, cyclePermission, cycleReasoning, drawerOpen, exit, focusAfterDetails, focusChat, focusDetails, footerControls, modelState.provider, openHistorySearch, openModelSetup, prompt, recallPromptHistory, refreshState, rightOpen, selectFooterControl, setChatScrollOffset, submitPrompt, toggleDetailsPane, toggleSubagentsPane]); + }, [addNotice, applyModelState, attachClipboardImage, chatRowBudget, copyVisibleTranscript, cycleFooterControl, cyclePaneFocus, cyclePermission, cycleReasoning, drawerOpen, focusAfterDetails, focusChat, focusDetails, footerControls, launchPromptInBackground, modelState.provider, openHistorySearch, openModelRow, prompt, recallPromptHistory, refreshState, requestAppExit, rightOpen, selectFooterControl, setChatScrollOffset, submitPrompt, toggleDetailsPane, toggleSubagentsPane]); + + const chatPointFromMouse = useCallback(( + x: number | null, + y: number | null, + clampToChat: boolean, + ): { row: number; column: number } | null => { + if (x == null || y == null) return null; + const drawerWidth = drawerOpen ? DRAWER_PANE_WIDTH : 0; + const textStartColumn = drawerWidth + 2; + const textEndColumn = textStartColumn + Math.max(1, chatWrapWidth) - 1; + const topRow = 2 + goalBannerRows; + const bottomRow = topRow + Math.max(1, chatRowBudget) - 1; + if (!clampToChat && (x < textStartColumn || x > textEndColumn || y < topRow || y > bottomRow)) { + return null; + } + const row = Math.max(0, Math.min(y - topRow, Math.max(0, chatRowBudget - 1))); + const column = Math.max(0, Math.min(x - textStartColumn, Math.max(0, chatWrapWidth - 1))); + return { row, column }; + }, [chatRowBudget, chatWrapWidth, drawerOpen, goalBannerRows]); + + const copyChatMouseSelection = useCallback((selection: ChatTextSelection): void => { + const text = selectedTextFromVisibleChatRows(visibleChatRowTexts, selection); + if (!text) return; + if (!writeClipboardText(text)) { + addNotice("Could not find a clipboard command for this terminal.", "error"); + } + }, [addNotice, visibleChatRowTexts]); useInput((input, key) => { + if (attachedTerminalIdRef.current) { + if (input === "\x1d") setAttachedTerminalId(null); + return; + } const mouse = parseTerminalMouseInput(input); if (mouse) { + const activeSelection = chatMouseSelectionRef.current; + if (mouse.kind === "click") { + const point = chatPointFromMouse(mouse.x, mouse.y, false); + if (point) { + focusChat(); + updateChatMouseSelection({ + startRow: point.row, + startColumn: point.column, + endRow: point.row, + endColumn: point.column, + active: true, + }); + return; + } + if (activeSelection) updateChatMouseSelection(null); + } + if (mouse.kind === "drag" && activeSelection?.active) { + const point = chatPointFromMouse(mouse.x, mouse.y, true); + if (point) { + updateChatMouseSelection({ + ...activeSelection, + endRow: point.row, + endColumn: point.column, + active: true, + }); + } + return; + } + if (mouse.kind === "release" && activeSelection?.active) { + const point = chatPointFromMouse(mouse.x, mouse.y, true); + const next = point + ? { + ...activeSelection, + endRow: point.row, + endColumn: point.column, + active: false, + } + : { ...activeSelection, active: false }; + const collapsed = next.startRow === next.endRow && next.startColumn === next.endColumn; + updateChatMouseSelection(collapsed ? null : next); + if (!collapsed) copyChatMouseSelection(next); + return; + } + const rightWidth = resolveRightPaneWidth(columns, rightOpen, drawerOpen); const drawerWidth = drawerOpen ? DRAWER_PANE_WIDTH : 0; const centerStart = drawerWidth + 1; @@ -4587,6 +5626,7 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } const inCenterPane = mouse.x == null || (mouse.x >= centerStart && mouse.x <= centerEnd); const inTranscriptRows = mouse.y == null || mouse.y > 2; if (mouse.kind === "wheel" && inCenterPane && inTranscriptRows) { + updateChatMouseSelection(null); if (mouse.direction === "up") { setChatScrollOffset((offset) => offset + 3); } else if (mouse.direction === "down") { @@ -4608,7 +5648,85 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } return; } + if (chatMouseSelectionRef.current) updateChatMouseSelection(null); + const pane = activePaneRef.current; + const detailsFormActive = pane === "details" && rightOpen && rightPane.kind === "form"; + const footerActive = footerControlRef.current != null; + const textInputActive = (pane === "chat" && !footerActive) || detailsFormActive; + + // Inline model row state machine. Lives above the chat-pane arrow handlers + // so navigation events route here first when the row is focused. + // Down arrow cycles values in the current cell; up arrow exits to prompt; + // left/right moves between cells. Provider cell is skipped when chat is + // already underway (providerLocked). + if (inlineRowFocused) { + const cell = inlineRowFocus.cell; + if (key.upArrow) { + setInlineRowFocus({ cell: null }); + return; + } + if (key.downArrow) { + if (cell === "provider") cycleProvider(1); + else if (cell === "model") cycleModel(1); + else if (cell === "reasoning") cycleReasoning(1); + else if (cell === "permission") cyclePermission(1); + else if (cell === "subagents") openSubagentsPane(); + return; + } + if (key.leftArrow || key.rightArrow) { + const fullOrder: Array<'provider' | 'model' | 'reasoning' | 'permission' | 'subagents'> = [ + "provider", + "model", + "reasoning", + "permission", + "subagents", + ]; + const order = fullOrder.filter((entry) => { + if (entry === "provider" && providerLockedRef.current) return false; + if (entry === "subagents" && !subagentsButtonVisibleRef.current) return false; + return true; + }); + const idx = cell ? order.indexOf(cell) : 0; + const delta = key.rightArrow ? 1 : -1; + const safeIdx = idx < 0 ? 0 : idx; + const nextCell = order[(safeIdx + delta + order.length) % order.length] ?? order[0] ?? "model"; + setInlineRowFocus({ cell: nextCell }); + return; + } + if (key.return) { + if (cell === "subagents") { + openSubagentsPane(); + return; + } + if (cell === "model" && activeTerminalSessionRef.current && modelStateRef.current.provider === "claude") { + void sendClaudeModelCommandToTerminal() + .catch((err) => addNotice(err instanceof Error ? err.message : String(err), "error")); + } + return; + } + if (key.escape) { + setInlineRowFocus({ cell: null }); + return; + } + return; + } + + if ((textInputActive || footerActive) && prompt.length > 0 && isPromptLineBackspace(input, key)) { + if (footerActive) selectFooterControl(null); + handlePromptChange(deletePreviousPromptLine(prompt)); + return; + } + if ((textInputActive || footerActive) && prompt.length > 0 && isPromptWordBackspace(input, key)) { + if (footerActive) selectFooterControl(null); + handlePromptChange(deletePreviousPromptWord(prompt)); + return; + } + if (textInputActive && key.return && key.shift) { + handlePromptChange(`${prompt}\n`); + return; + } + if (pane === "chat") { const pageUp = Boolean((key as { pageUp?: boolean }).pageUp); const pageDown = Boolean((key as { pageDown?: boolean }).pageDown); @@ -4617,7 +5735,7 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } const paletteOpen = (activeMentionRange != null && mentionSuggestions.length > 0) || slashRows.length > 0; const pageRows = Math.max(1, chatRowBudget - 2); if (!paletteOpen && key.downArrow && effectiveChatScrollOffsetRows <= 0) { - selectFooterControl(footerControls[0] ?? "drawer"); + setInlineRowFocus({ cell: providerLockedRef.current ? "model" : "provider" }); return; } if (pageUp || (key.ctrl && input === "u")) { @@ -4679,9 +5797,10 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } if (keybindingAction !== undefined && runKeybindingAction(keybindingAction)) { return; } - const detailsFormActive = pane === "details" && rightOpen && rightPane.kind === "form"; - const footerActive = footerControlRef.current != null; - const textInputActive = (pane === "chat" && !footerActive) || detailsFormActive; + if (pane === "chat" && textInputActive && key.return && key.meta && !key.ctrl && !key.shift) { + void launchPromptInBackground(prompt); + return; + } const currentFormValues = (): Record => { if (rightPane.kind !== "form") return formValues; const currentField = rightPane.fields[formFieldIndex] ?? rightPane.fields[0]; @@ -4693,12 +5812,12 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } }; if (key.tab && key.shift) { - cyclePaneFocus(); + cyclePermission(1); return; } - if (key.tab && !key.shift && pane !== "drawer") { - focusDrawer(); + if (key.tab && !key.shift) { + cyclePaneFocus(); return; } @@ -4733,16 +5852,16 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } } if (footerActive) { - if (prompt.length > 0 && isPromptWordBackspace(input, key)) { - selectFooterControl(null); - handlePromptChange(deletePreviousPromptWord(prompt)); - return; - } if (key.leftArrow || key.rightArrow) { cycleFooterControl(key.rightArrow ? 1 : -1); return; } - if (key.upArrow || key.escape) { + if (key.upArrow) { + selectFooterControl(null); + setInlineRowFocus({ cell: providerLockedRef.current ? "model" : "provider" }); + return; + } + if (key.escape) { selectFooterControl(null); return; } @@ -4771,11 +5890,6 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } } } - if (textInputActive && prompt.length > 0 && isPromptWordBackspace(input, key)) { - handlePromptChange(deletePreviousPromptWord(prompt)); - return; - } - if (pane === "chat" && textInputActive && key.ctrl && input === "r") { openHistorySearch(); return; @@ -4875,8 +5989,15 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } focusChat(); return; } - if (pane === "chat" && streaming && prompt.trim() && activeSession?.provider === "codex") { - void submitPrompt(prompt); + const conn = connectionRef.current; + const sessionId = activeSessionIdRef.current; + const activeTurnVisible = streaming || activeSession?.status === "active"; + if (pane === "chat" && activeTurnVisible && conn && sessionId) { + setStreaming(false); + setInterrupted(true); + void interruptChat(conn, sessionId) + .then(() => addNotice("Interrupted chat.", "info")) + .catch((err) => addNotice(err instanceof Error ? err.message : String(err), "error")); return; } setPrompt(""); @@ -4886,13 +6007,16 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } if (key.ctrl && input === "c") { const conn = connectionRef.current; const sessionId = activeSessionIdRef.current; - if (streaming && conn && sessionId) { + const activeTurnVisible = streaming || activeSession?.status === "active"; + if (activeTurnVisible && conn && sessionId) { + setStreaming(false); + setInterrupted(true); void interruptChat(conn, sessionId) .then(() => addNotice("Interrupted chat.", "info")) .catch((err) => addNotice(err instanceof Error ? err.message : String(err), "error")); return; } - exit(); + requestAppExit(); return; } @@ -4949,7 +6073,7 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } if ( pane === "details" && rightOpen - && (rightPane.kind === "new-chat-setup" || rightPane.kind === "model-setup") + && rightPane.kind === "new-chat-setup" && (key.upArrow || key.downArrow || key.leftArrow || key.rightArrow || key.return) ) { const rows = rightPane.rows; @@ -4959,7 +6083,7 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } setRightSelectionIndex((index) => totalRows ? (index + delta + totalRows) % totalRows : 0); return; } - if (rightPane.kind === "new-chat-setup" && key.return) { + if (key.return) { const applyRow = rows.find((entry) => entry.kind === "apply"); if (applyRow) handleSetupRow(applyRow, 1); return; @@ -5042,21 +6166,13 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } } } - if (pane === "details" && rightOpen && (rightPane.kind === "models" || rightPane.kind === "effort" || (rightPane.kind === "list" && rightPane.action)) && key.upArrow) { - const max = rightPane.kind === "models" - ? rightPane.models.length - : rightPane.kind === "effort" - ? rightPane.efforts.length - : rightPane.rows.length; + if (pane === "details" && rightOpen && rightPane.kind === "list" && rightPane.action && key.upArrow) { + const max = rightPane.rows.length; setRightSelectionIndex((index) => (index <= 0 ? Math.max(0, max - 1) : index - 1)); return; } - if (pane === "details" && rightOpen && (rightPane.kind === "models" || rightPane.kind === "effort" || (rightPane.kind === "list" && rightPane.action)) && key.downArrow) { - const max = rightPane.kind === "models" - ? rightPane.models.length - : rightPane.kind === "effort" - ? rightPane.efforts.length - : rightPane.rows.length; + if (pane === "details" && rightOpen && rightPane.kind === "list" && rightPane.action && key.downArrow) { + const max = rightPane.rows.length; setRightSelectionIndex((index) => (max > 0 ? (index + 1) % max : 0)); return; } @@ -5069,13 +6185,13 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } selectActiveLaneId(lane.id); setDrawerLaneId(lane.id); setSelectedDrawerLaneId(lane.id); - const session = newestSession(sessions.filter((entry) => entry.laneId === lane.id)); + const session = newestSession(displaySessions.filter((entry) => entry.laneId === lane.id)); selectActiveSessionId(session?.sessionId ?? null); setSelectedDrawerChatId(session?.sessionId ?? null); addNotice(`Switched to lane ${lane.name}.`, "success"); return; } - const session = sessions.find((entry) => entry.sessionId === selectedId); + const session = displaySessions.find((entry) => entry.sessionId === selectedId); if (!session) return; selectActiveLaneId(session.laneId); setDrawerLaneId(session.laneId); @@ -5085,49 +6201,6 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } addNotice(`Switched to chat ${session.title ?? session.sessionId}.`, "success"); return; } - if (pane === "details" && rightOpen && (rightPane.kind === "models" || rightPane.kind === "effort") && key.return) { - const conn = connectionRef.current; - const sessionId = activeSessionIdRef.current; - if (!conn) { - return; - } - if (rightPane.kind === "models") { - const model = rightPane.models[rightSelectionIndex] ?? rightPane.models[0]; - if (!model) return; - const modelId = model.modelId ?? model.id; - if (!sessionId) { - setModelState((prev) => ({ - ...prev, - model: model.id, - modelId, - displayName: model.displayName, - })); - addNotice(`Default model set to ${model.displayName}.`, "success"); - return; - } - void updateChatModel({ connection: conn, sessionId, modelId }) - .then(() => { - addNotice(`Model set to ${model.displayName}.`, "success"); - return refreshState(); - }) - .catch((err) => addNotice(err instanceof Error ? err.message : String(err), "error")); - return; - } - const effort = rightPane.efforts[rightSelectionIndex] ?? rightPane.efforts[0]; - if (!effort) return; - if (!sessionId) { - setModelState((prev) => ({ ...prev, reasoningEffort: effort })); - addNotice(`Default effort set to ${effort}.`, "success"); - return; - } - void updateChatModel({ connection: conn, sessionId, reasoningEffort: effort }) - .then(() => { - addNotice(`Effort set to ${effort}.`, "success"); - return refreshState(); - }) - .catch((err) => addNotice(err instanceof Error ? err.message : String(err), "error")); - return; - } const pageUp = Boolean((key as { pageUp?: boolean }).pageUp); const pageDown = Boolean((key as { pageDown?: boolean }).pageDown); @@ -5186,7 +6259,7 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } return; } if (pane === "chat" && key.downArrow && !activeMentionRange && !slashRows.length) { - selectFooterControl(footerControlRef.current ?? footerControls[0] ?? "drawer"); + setInlineRowFocus({ cell: providerLockedRef.current ? "model" : "provider" }); setPaneFocus("chat"); return; } @@ -5275,7 +6348,7 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } addNotice(unavailableMessage, "error"); return; } - const laneSessions = sessions.filter((entry) => entry.laneId === lane.id); + const laneSessions = displaySessions.filter((entry) => entry.laneId === lane.id); const lastSessionId = lastChatByLaneRef.current.get(lane.id); const session = laneSessions.find((s) => s.sessionId === lastSessionId) @@ -5345,9 +6418,13 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } return; } const linePrefix = inputBeforeLineBreak(input); - if (textInputActive && (key.return || linePrefix != null)) { - const suffix = linePrefix == null ? "" : printableInput(linePrefix); - void submitPrompt(`${prompt}${suffix}`); + if (textInputActive && linePrefix != null && !key.return) { + const suffix = printablePromptInput(input); + if (suffix) handlePromptChange(`${prompt}${suffix}`); + return; + } + if (textInputActive && key.return) { + void submitPrompt(prompt); return; } if (textInputActive && (key.backspace || key.delete)) { @@ -5397,7 +6474,10 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } const rightPaneVisible = rightPaneWidth > 0; const laneName = activeLane?.name ?? "main"; - const promptFocused = (activePane === "chat" && footerControl == null) || (activePane === "details" && rightPane.kind === "form"); + // When the cursor is in the inline model row, the prompt box loses its + // focused outline so the user can see the row took over. + const promptFocused = !inlineRowFocused + && ((activePane === "chat" && footerControl == null) || (activePane === "details" && rightPane.kind === "form")); const drawerFooterSelected = footerControl === "drawer"; const detailsFooterSelected = footerControl === "details"; const agentsFooterSelected = footerControl === "agents"; @@ -5407,6 +6487,7 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } const modelStatusOverlayRows = statusRows + (draftChatActive || (vimModeEnabled && !hideVimModeIndicator) || (modelState.provider === "codex" && modelState.codexFastMode) ? 1 : 0); const paletteBottomRows = 5 + + (promptRows.length - 1) + modelStatusOverlayRows + (attachedImageChips.length ? 1 : 0) + (error ? 1 : 0); @@ -5425,12 +6506,12 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } } return ( - +
{goalBannerText ? ( @@ -5442,7 +6523,7 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } {drawerOpen ? ( {pendingApproval?.highStakes ? ( + ) : activeTerminalSession ? ( + ) : ( <> @@ -5489,7 +6583,7 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } activeFormField={formFieldIndex} selectedIndex={rightSelectionIndex} focused={activePane === "details"} - activeProvider={(activeSession?.provider ?? modelState.provider) as AdeCodeProvider} + activeProvider={activeCommandProvider as AdeCodeProvider} width={rightPaneWidth} /> ) : null} @@ -5527,12 +6621,25 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } ))} ) : null} - - - {prompt} - - {!prompt ? {" ^V paste image"} : null} - {streaming && !goalBannerText ? {" · streaming"} : null} + {modeChangeNotice ? ( + + {modeChangeNotice.summary} mode + {` · ${modeDescription(modeChangeNotice.summary)}`} + + ) : null} + + {promptRows.map((line, index) => { + const last = index === promptRows.length - 1; + return ( + + {index === 0 ? "› " : " "} + {line} + {last ? : null} + {index === 0 && !prompt ? {" ^V paste image"} : null} + {last && streaming && !goalBannerText ? {" · streaming"} : null} + + ); + })} diff --git a/apps/ade-cli/src/tuiClient/commands.ts b/apps/ade-cli/src/tuiClient/commands.ts index 967e30182..5144d4310 100644 --- a/apps/ade-cli/src/tuiClient/commands.ts +++ b/apps/ade-cli/src/tuiClient/commands.ts @@ -17,7 +17,6 @@ export const BUILTIN_COMMANDS: BuiltinCommand[] = [ { name: "/stage all", description: "Stage all changes in the active lane", placement: "inline" }, { name: "/clear", description: "Clear the local terminal transcript view", placement: "inline" }, { name: "/copy", description: "Copy the visible chat transcript", placement: "inline" }, - { name: "/end", description: "End the active chat runtime", placement: "inline" }, { name: "/login", description: "Sign in to the active CLI-backed provider from this terminal", placement: "inline" }, { name: "/open", description: "Open this ADE context in desktop", placement: "inline" }, { name: "/quit", description: "Exit ade code", placement: "inline" }, @@ -36,8 +35,8 @@ export const BUILTIN_COMMANDS: BuiltinCommand[] = [ { name: "/status", description: "Show project, lane, and runtime state", placement: "right" }, { name: "/context", description: "Show Claude context usage", placement: "right", providers: ["claude"] }, { name: "/agents", description: "List Claude agents from user and project config", placement: "right", providers: ["claude"] }, + { name: "/subagents", description: "Show live subagents and teammates for the active chat", placement: "right" }, { name: "/skills", description: "List Claude skills from user and project config", placement: "right", providers: ["claude"] }, - { name: "/mcp", description: "Show Claude MCP server status", placement: "right", providers: ["claude"] }, { name: "/compact", description: "Compact the active chat context", placement: "chat", argumentHint: "[instructions]", providers: ["claude", "codex"] }, { name: "/init", description: "Generate AGENTS.md and Claude pointer files", placement: "right", providers: ["claude"] }, { name: "/usage", description: "Show Claude usage through the active SDK session", placement: "chat", providers: ["claude"] }, @@ -61,6 +60,7 @@ export const BUILTIN_COMMANDS: BuiltinCommand[] = [ { name: "/linear comment", description: "Comment on a Linear ticket", placement: "right", argumentHint: " " }, { name: "/linear status", description: "Show Linear sync status", placement: "right" }, { name: "/linear assign", description: "Assign a Linear ticket", placement: "right", argumentHint: " " }, + { name: "/feedback", description: "Submit ADE feedback to GitHub issues", placement: "right" }, { name: "/memory", description: "Search ADE memory", placement: "right", argumentHint: "[query]" }, { name: "/forget", description: "Open memory management", placement: "right" }, { name: "/chats", description: "List chats in the active lane", placement: "right" }, @@ -69,15 +69,16 @@ export const BUILTIN_COMMANDS: BuiltinCommand[] = [ { name: "/keybindings", description: "Show Claude-compatible keybinding config diagnostics", placement: "right" }, { name: "/statusline", description: "Show Claude-compatible status line config", placement: "right" }, { name: "/doctor", description: "Show ADE Code and Claude-compat diagnostics", placement: "right" }, - { name: "/model", description: "Pick the active chat model", placement: "right" }, - { name: "/effort", description: "Pick reasoning effort", placement: "right" }, + { name: "/model", description: "Open the model, reasoning, and permission picker", placement: "right" }, { name: "/system", description: "Show system and runtime details", placement: "right" }, { name: "/ade", description: "Run an ADE action or force a TUI command", placement: "right", argumentHint: " [json]" }, ]; -const ADE_OWNED_SINGLE_WORD_COMMANDS = new Set( +// ADE-owned local controls must dispatch in the TUI even if the active runtime +// exposes a user slash command with the same single-token name. +const ADE_LOCAL_SINGLE_WORD_COMMANDS = new Set( BUILTIN_COMMANDS - .filter((command) => command.placement === "inline" && !command.name.includes(" ")) + .filter((command) => command.placement !== "chat" && !command.name.includes(" ")) .map((command) => command.name.toLowerCase()), ); @@ -130,14 +131,15 @@ export function parseCommand(input: string, userCommands: AgentChatSlashCommand[ } const exactUserCommand = userCommands.find((command) => slashCommandKey(command.name) === firstKey) ?? null; - const adeOwnedSingleWordCommand = candidates.find((command) => - slashCommandKey(command.name) === firstKey && ADE_OWNED_SINGLE_WORD_COMMANDS.has(slashCommandKey(command.name)) - ); - if (adeOwnedSingleWordCommand) { + const adeOwnedOverride = candidates.find((command) => { + const key = slashCommandKey(command.name); + return key === firstKey && ADE_LOCAL_SINGLE_WORD_COMMANDS.has(key); + }); + if (adeOwnedOverride) { return { - name: adeOwnedSingleWordCommand.name, + name: adeOwnedOverride.name, args: trimmed.slice(first.length).trim(), - spec: adeOwnedSingleWordCommand, + spec: adeOwnedOverride, userCommand: null, }; } @@ -181,11 +183,19 @@ export function parseCommand(input: string, userCommands: AgentChatSlashCommand[ }; } +export type PaletteCommand = { + name: string; + description: string; + source: "ade" | "user"; + argumentHint?: string; + placement?: CommandPlacement; +}; + export function paletteCommands( query: string, userCommands: AgentChatSlashCommand[] = [], options: { provider?: AgentChatProvider | null } = {}, -): Array<{ name: string; description: string; source: "ade" | "user"; argumentHint?: string }> { +): PaletteCommand[] { const normalizedQuery = query.trim().toLowerCase(); const queryToken = normalizedQuery.replace(/^\//, ""); const builtins = BUILTIN_COMMANDS @@ -195,23 +205,26 @@ export function paletteCommands( description: command.description, source: "ade" as const, argumentHint: command.argumentHint, + placement: command.placement, })); const users = userCommands.map((command) => ({ name: command.name, description: command.description, source: "user" as const, argumentHint: command.argumentHint, + placement: "chat" as CommandPlacement, })); - // Dedupe by name. Most runtime/user commands win over ADE built-ins, but - // ADE-owned inline terminal controls must match parseCommand dispatch. - const byName = new Map(); + // Dedupe by name. Runtime/user chat commands can still replace chat-placement + // built-ins, but ADE local controls must match parseCommand dispatch so a row + // marked as opening the right pane never falls through as a chat message. + const byName = new Map(); for (const command of builtins) { const key = slashCommandKey(command.name); if (!byName.has(key)) byName.set(key, command); } for (const command of users) { const key = slashCommandKey(command.name); - if (ADE_OWNED_SINGLE_WORD_COMMANDS.has(key)) continue; + if (ADE_LOCAL_SINGLE_WORD_COMMANDS.has(key)) continue; if (ADE_OWNED_CLAUDE_PARITY_COMMANDS.has(key)) continue; byName.set(key, command); } diff --git a/apps/ade-cli/src/tuiClient/components/ChatView.tsx b/apps/ade-cli/src/tuiClient/components/ChatView.tsx index 0b531f39f..0aba5dc32 100644 --- a/apps/ade-cli/src/tuiClient/components/ChatView.tsx +++ b/apps/ade-cli/src/tuiClient/components/ChatView.tsx @@ -1,15 +1,24 @@ -import React from "react"; +import React, { useMemo } from "react"; import { Box, Text } from "ink"; import type { AgentChatEventEnvelope, AgentChatSessionSummary } from "../../../../desktop/src/shared/types/chat"; import type { LaneSummary } from "../../../../desktop/src/shared/types/lanes"; import type { LocalNotice } from "../types"; import { parseInlineRuns, + compactPath, type AssistantMarkdownBlock, + type HighlightedToken, type InlineRun, type RenderedChatLine, } from "../format"; -import { aggregateChatBlocks, type AggregatedBlock, type PlanStep, type WorkTool } from "../aggregate"; +import { + aggregateChatBlocks, + type AggregatedBlock, + type FileChangeEntry, + type PlanStep, + type ToolCallEntry, + type WorkToolStatus, +} from "../aggregate"; import { theme } from "../theme"; import { useBrailleSpin, useDotPulse, useSpinFrame } from "../spinTick"; import { AdeWordmark } from "./AdeWordmark"; @@ -34,6 +43,13 @@ type RenderedChatRow = { runs?: InlineRun[]; }; +export type ChatTextSelection = { + startRow: number; + startColumn: number; + endRow: number; + endColumn: number; +}; + function textWidth(value: string): number { return [...value].length; } @@ -442,50 +458,103 @@ function padCells(cells: string[], width: number): string[] { return out; } +const HIGHLIGHT_COLOR: Record, string> = { + keyword: theme.color.codeKeyword, + string: theme.color.codeString, + number: theme.color.codeNumber, + comment: theme.color.codeComment, + function: theme.color.codeFunction, + type: theme.color.codeType, + variable: theme.color.codeVariable, + tag: theme.color.codeTag, + meta: theme.color.codeMeta, +}; + +function tokensToRuns(tokens: HighlightedToken[]): InlineRun[] { + return tokens.map((token) => { + const color = token.category ? HIGHLIGHT_COLOR[token.category] : theme.color.t1; + const run: InlineRun = { text: token.text, color }; + if (token.bold) run.bold = true; + if (token.italic) run.italic = true; + return run; + }); +} + function markdownRows(blocks: AssistantMarkdownBlock[], width: number, id: string): RenderedChatRow[] { const rows: RenderedChatRow[] = []; + let prevKind: AssistantMarkdownBlock["kind"] | null = null; for (const block of blocks) { - if (rows.length) rows.push(spacerRow(`${id}:markdown-spacer:${rows.length}`, "assistant")); + if (prevKind) { + rows.push(spacerRow(`${id}:md-spacer:${rows.length}`, "assistant")); + } if (block.kind === "heading") { rows.push(...inlineRowsFromText(block.text, width, id, "", "", { color: theme.color.accent, bold: true })); - continue; - } - if (block.kind === "bullet") { + } else if (block.kind === "bullet") { rows.push(...inlineRowsFromText(block.text, width, id, "• ", " ")); - continue; - } - if (block.kind === "numbered") { + } else if (block.kind === "numbered") { const prefix = `${block.number}. `; rows.push(...inlineRowsFromText(block.text, width, id, prefix, repeat(" ", textWidth(prefix)))); - continue; - } - if (block.kind === "quote") { + } else if (block.kind === "quote") { rows.push(...inlineRowsFromText(block.text, width, id, "> ", "> ", { dim: true })); - continue; - } - if (block.kind === "code") { - const label = block.language ? ` ${block.language}` : ""; - rows.push({ id, tone: "assistant", text: ` ┌${repeat("─", Math.max(1, Math.min(width - 5, 24)))}${label}`, color: theme.color.border, dim: true }); - for (const codeLine of block.lines.length ? block.lines : [""]) { - const available = Math.max(1, width - 4); - const chunks = hardWrapWord(codeLine || " ", available); + } else if (block.kind === "code") { + const label = block.language ? ` ${block.language} ` : ""; + const ruleWidth = Math.max(1, Math.min(width - 5, 24)); + rows.push({ + id, + tone: "assistant", + text: ` ┌─${label}${repeat("─", Math.max(0, ruleWidth - label.length))}`, + color: theme.color.border, + dim: true, + }); + const codeLines = block.lines.length ? block.lines : [""]; + const tokensPerLine = block.tokens && block.tokens.length === codeLines.length ? block.tokens : null; + const available = Math.max(1, width - 4); + for (let lineIndex = 0; lineIndex < codeLines.length; lineIndex += 1) { + const codeLine = codeLines[lineIndex] ?? ""; + if (!codeLine) { + rows.push({ id, tone: "assistant", text: " │ ", color: theme.color.border, dim: true }); + continue; + } + if (tokensPerLine) { + const lineTokens = tokensPerLine[lineIndex] ?? []; + if (!lineTokens.length) { + rows.push({ id, tone: "assistant", text: " │ ", color: theme.color.border, dim: true }); + continue; + } + // Hard-wrap by width across the token sequence so we keep colored runs intact. + const wrapped = wrapInlineRuns(tokensToRuns(lineTokens), available, " │ ", " │ "); + for (const lineSegments of wrapped) { + rows.push({ + id: `${id}:code:${lineIndex}`, + tone: "assistant", + text: runsPlainText(lineSegments), + runs: lineSegments, + rail: null, + }); + } + continue; + } + // No tokens (unknown language): render plain code text in t1 with wrap. + const chunks = hardWrapWord(codeLine, available); for (const chunk of chunks) { - rows.push({ id, tone: "assistant", text: ` │ ${chunk}`, color: theme.color.tool, dim: true }); + rows.push({ + id, + tone: "assistant", + text: ` │ ${chunk}`, + color: theme.color.t1, + }); } } rows.push({ id, tone: "assistant", text: " └", color: theme.color.border, dim: true }); - continue; - } - if (block.kind === "table") { + } else if (block.kind === "table") { rows.push(...tableRows(block, width, id)); - continue; - } - if (block.kind === "hr") { + } else if (block.kind === "hr") { rows.push({ id, tone: "assistant", text: repeat("─", Math.min(width, 72)), color: theme.color.border, dim: true }); - continue; + } else { + rows.push(...inlineRowsFromText(block.text, width, id, "", "")); } - rows.push(...inlineRowsFromText(block.text, width, id, "", "")); + prevKind = block.kind; } return rows; } @@ -496,15 +565,24 @@ function markdownRows(blocks: AssistantMarkdownBlock[], width: number, id: strin const LINK_COLOR = theme.color.info; const MEMORY_COLOR = theme.color.tool; -const WORK_STATUS_COLOR: Record = { +const WORK_STATUS_COLOR: Record = { running: theme.color.violet, ok: theme.color.running, failed: theme.color.error, }; +// How many entries to render inline per work-group before collapsing into "+N more". +// Tight by design: the TUI can't expand groups, so flooding with all tools is just noise. +const GROUP_VISIBLE_CAP = 3; +// Per-row arg/command truncation. Chosen so each tool entry stays on a single +// terminal line at typical widths (~120 cols) — the noise from line-wrapped +// shell args was the user's main complaint. +const LONG_LINE_TRUNCATE_AT = 80; + function formatDurationMs(ms: number | undefined): string | null { - if (typeof ms !== "number" || !Number.isFinite(ms) || ms < 0) return null; - if (ms < 1000) return `${Math.round(ms)}ms`; + if (typeof ms !== "number" || !Number.isFinite(ms) || ms <= 0) return null; + if (ms < 1) return "<1ms"; + if (ms < 1000) return `${Math.max(1, Math.round(ms))}ms`; const seconds = ms / 1000; if (seconds < 60) return `${seconds.toFixed(1)}s`; const minutes = Math.floor(seconds / 60); @@ -512,81 +590,175 @@ function formatDurationMs(ms: number | undefined): string | null { return `${minutes}m ${remSeconds}s`; } -function workBlockRows( - block: Extract, - width: number, - brailleFrame: string, +function statusGlyph(status: WorkToolStatus, spinFrame: string): string { + if (status === "failed") return "✗"; + if (status === "running") return spinFrame; + return "✓"; +} + +function truncateLongLine(value: string): string { + if (textWidth(value) <= LONG_LINE_TRUNCATE_AT) return value; + return `${[...value].slice(0, LONG_LINE_TRUNCATE_AT - 1).join("")}…`; +} + +function groupHeaderRuns( + label: string, + total: number, + liveSummary: { ok: number; failed: number } | null, + spinFrame: string, +): InlineRun[] { + const runs: InlineRun[] = [ + { text: "▸ ", color: theme.color.t3 }, + { text: `${label} (${total})`, color: theme.color.t2, bold: true }, + ]; + if (liveSummary) { + const tail: string[] = []; + if (liveSummary.ok > 0) tail.push(`${liveSummary.ok} ok`); + if (liveSummary.failed > 0) tail.push(`${liveSummary.failed} failed`); + if (tail.length) { + runs.push({ text: " · ", color: theme.color.t4 }); + runs.push({ text: tail.join(" · "), color: theme.color.t4 }); + } + } else { + runs.push({ text: " ", color: theme.color.t4 }); + runs.push({ text: spinFrame, color: theme.color.violet }); + runs.push({ text: " working…", color: theme.color.violet, italic: true }); + } + return runs; +} + +function moreLineRow(blockId: string, remaining: number): RenderedChatRow { + return { + id: `${blockId}:more`, + tone: "work", + text: ` + ${remaining} more`, + color: theme.color.t4, + italic: true, + rail: null, + }; +} + +function visibleEntries(entries: T[]): { shown: T[]; remaining: number } { + if (entries.length <= GROUP_VISIBLE_CAP) return { shown: entries, remaining: 0 }; + // Prefer the most recent entries; older ones collapse into the "+N more" tail. + const shown = entries.slice(-GROUP_VISIBLE_CAP); + return { shown, remaining: entries.length - shown.length }; +} + +function toolCallsGroupRows( + block: Extract, spinFrame: string, ): RenderedChatRow[] { - if (!block.tools.length) return []; + if (!block.entries.length) return []; const out: RenderedChatRow[] = []; - const total = block.tools.length; - const ok = block.tools.filter((tool) => tool.status === "ok").length; - const failed = block.tools.filter((tool) => tool.status === "failed").length; - - const headerText = block.live - ? `▸ working · ${total} tool${total === 1 ? "" : "s"} so far ${brailleFrame}` - : (() => { - const parts: string[] = [`▸ ${total} tool${total === 1 ? "" : "s"}`]; - if (ok > 0) parts.push(`${ok} ok`); - if (failed > 0) parts.push(`${failed} failed`); - const dur = formatDurationMs(block.durationMs); - if (dur) parts.push(dur); - return parts.join(" · "); - })(); + const total = block.entries.length; + const ok = block.entries.filter((entry) => entry.status === "ok").length; + const failed = block.entries.filter((entry) => entry.status === "failed").length; out.push({ id: block.id, tone: "work", - text: headerText, - color: theme.color.t3, + text: `▸ Tool calls (${total})`, + runs: groupHeaderRuns("Tool calls", total, block.live ? null : { ok, failed }, spinFrame), rail: null, }); - type Display = { tool: WorkTool; faded: boolean }; - let displays: Display[]; - if (block.live) { - const recent = block.tools.slice(-3).reverse(); - displays = recent.map((tool, index) => ({ tool, faded: index === recent.length - 1 && total > 3 })); - } else { - const failedTools = block.tools.filter((tool) => tool.status === "failed"); - if (failedTools.length === 0) { - const lastOk = [...block.tools].reverse().find((tool) => tool.status === "ok"); - displays = lastOk ? [{ tool: lastOk, faded: false }] : []; - } else { - displays = failedTools.map((tool) => ({ tool, faded: false })); - if (failed < total) { - const lastOk = [...block.tools].reverse().find((tool) => tool.status === "ok"); - if (lastOk) displays.push({ tool: lastOk, faded: false }); - } - } + const { shown, remaining } = visibleEntries(block.entries); + for (const entry of shown) { + const glyph = statusGlyph(entry.status, spinFrame); + const dur = formatDurationMs(entry.durationMs); + const arg = entry.arg ? truncateLongLine(entry.arg) : ""; + const runs: InlineRun[] = [ + { text: " " }, + { text: glyph, color: WORK_STATUS_COLOR[entry.status] }, + { text: ` ${entry.tool}`, color: theme.color.t1 }, + ]; + if (arg) runs.push({ text: ` ${arg}`, color: theme.color.t3 }); + if (dur) runs.push({ text: ` ${dur}`, color: theme.color.t4 }); + out.push({ + id: `${block.id}:${entry.itemId}`, + tone: "work", + text: ` ${glyph} ${entry.tool}${arg ? ` ${arg}` : ""}${dur ? ` ${dur}` : ""}`, + runs, + rail: null, + }); } + if (remaining > 0) out.push(moreLineRow(block.id, remaining)); + return out; +} + +function fileBadgeFor(entry: FileChangeEntry): { label: string; color: string } { + const badges = theme.color.fileBadge as Record; + const lower = entry.path.toLowerCase(); + const lastDot = lower.lastIndexOf("."); + const ext = lastDot >= 0 ? lower.slice(lastDot + 1) : ""; + const color = badges[ext] ?? badges.default ?? theme.color.t4; + const label = (ext || "•").slice(0, 3).toUpperCase(); + return { label, color }; +} + +function filesChangedGroupRows( + block: Extract, + width: number, + spinFrame: string, +): RenderedChatRow[] { + if (!block.entries.length) return []; + const out: RenderedChatRow[] = []; + const total = block.entries.length; + const failed = block.entries.filter((entry) => entry.status === "failed").length; + const label = total === 1 ? "file changed" : "files changed"; - for (const { tool, faded } of displays) { - const glyph = tool.status === "failed" ? "✗" : tool.status === "running" ? spinFrame : "✓"; - const statusColor = faded ? theme.color.t5 : WORK_STATUS_COLOR[tool.status]; - const dur = formatDurationMs(tool.durationMs); - const nameColor = faded ? theme.color.t5 : theme.color.t1; - const argColor = faded ? theme.color.t5 : theme.color.t3; - const tailColor = faded ? theme.color.t5 : theme.color.t4; - const argText = tool.arg ? ` · ${tool.arg}` : ""; - const tailText = dur ? ` ${dur}` : ""; + out.push({ + id: block.id, + tone: "work", + text: `▸ ${total} ${label}`, + runs: [ + { text: "▸ ", color: theme.color.t3 }, + { text: `${total} ${label}`, color: theme.color.t2, bold: true }, + ...(block.live + ? [ + { text: " ", color: theme.color.t4 } as InlineRun, + { text: spinFrame, color: theme.color.violet } as InlineRun, + { text: " working…", color: theme.color.violet, italic: true } as InlineRun, + ] + : [ + ...(failed > 0 + ? [{ text: ` · ${failed} failed`, color: theme.color.t4 } as InlineRun] + : []), + ]), + ], + rail: null, + }); + + const pathWidth = Math.max(20, width - 18); + const { shown, remaining } = visibleEntries(block.entries); + for (const entry of shown) { + const badge = fileBadgeFor(entry); + const glyph = statusGlyph(entry.status, spinFrame); + const trimmedPath = compactPath(entry.path, pathWidth); + const stats = entry.deleted + ? "Deleted" + : `+${entry.additions} −${entry.deletions}`; + const statsColor = entry.deleted ? theme.color.error : theme.color.t4; const runs: InlineRun[] = [ + { text: " " }, + { text: glyph, color: WORK_STATUS_COLOR[entry.status] }, + { text: " " }, + { text: badge.label.padEnd(3, " "), color: badge.color, bold: true }, { text: " " }, - { text: glyph, color: statusColor }, - { text: ` ${tool.tool}`, color: nameColor }, + { text: trimmedPath, color: theme.color.t1 }, + { text: " " }, + { text: stats, color: statsColor }, ]; - if (argText) runs.push({ text: argText, color: argColor }); - if (tailText) runs.push({ text: tailText, color: tailColor }); - const lineText = ` ${glyph} ${tool.tool}${argText}${tailText}`; out.push({ - id: `${block.id}:${tool.itemId}`, + id: `${block.id}:${entry.itemId}`, tone: "work", - text: lineText, + text: ` ${glyph} ${badge.label} ${trimmedPath} ${stats}`, runs, rail: null, }); } + if (remaining > 0) out.push(moreLineRow(block.id, remaining)); return out; } @@ -684,6 +856,17 @@ function modelWorkingRows(dots: string): RenderedChatRow[] { }]; } +function modelInterruptedRows(): RenderedChatRow[] { + return [{ + id: "model-interrupted", + tone: "work", + text: "Interrupted · chat to continue", + color: theme.color.mutedFg, + bold: true, + rail: null, + }]; +} + // ───────────────────────────────────────────────────────────────────────────── // Passthrough renderers (user-bubble, assistant-text, approval, error, notice) // ───────────────────────────────────────────────────────────────────────────── @@ -748,15 +931,12 @@ function rowsForBlock( case "error": case "notice": return passthroughRows(block.line, width); - case "assistant-text": { - const rows = passthroughRows(block.line, width); - if (block.precededByHeavy) { - rows.unshift(spacerRow(`${block.id}:leading-spacer`, "assistant")); - } - return rows; - } - case "work-block": - return workBlockRows(block, width, brailleFrame, spinFrame); + case "assistant-text": + return passthroughRows(block.line, width); + case "tool-calls-group": + return toolCallsGroupRows(block, spinFrame); + case "files-changed-group": + return filesChangedGroupRows(block, width, spinFrame); case "memory": return memoryRows(block, brailleFrame); case "compaction": @@ -805,7 +985,12 @@ function spacerRow( return { id, tone, text: BLANK_ROW_TEXT, dim: true, rail: null }; } -function sliceRows(rows: RenderedChatRow[], maxRows?: number, scrollOffsetRows = 0): RenderedChatRow[] { +function sliceRows( + rows: RenderedChatRow[], + maxRows?: number, + scrollOffsetRows = 0, + unseenMessageCount = 0, +): RenderedChatRow[] { if (!maxRows || maxRows <= 0) return rows; const viewportRows = Math.max(1, maxRows); if (rows.length <= viewportRows) { @@ -836,28 +1021,27 @@ function sliceRows(rows: RenderedChatRow[], maxRows?: number, scrollOffsetRows = result.push(spacerRow(`scroll-filler:${result.length}`)); } if (hasNewer) { - result.push({ id: "newer-indicator", tone: "indicator", text: "↓ newer messages", dim: true, rail: null }); + // If we know how many unseen messages arrived since the user scrolled away, + // surface that count as the "↓ N new" pill. Falls back to the plain indicator. + const pillText = unseenMessageCount > 0 + ? `↓ ${unseenMessageCount} new message${unseenMessageCount === 1 ? "" : "s"} · press End` + : "↓ newer messages"; + result.push({ + id: "newer-indicator", + tone: "indicator", + text: pillText, + color: unseenMessageCount > 0 ? theme.color.violet : undefined, + bold: unseenMessageCount > 0, + rail: null, + }); } return result; } -function railColorForTone(tone: RenderedChatRow["tone"]): string | null { - switch (tone) { - case "assistant": - return theme.color.t1; - case "tool": - return theme.color.tool; - case "reasoning": - return theme.color.violet; - case "notice": - return theme.color.t4; - case "error": - return theme.color.danger; - case "approval": - return theme.color.attention; - default: - return null; - } +function railColorForTone(_tone: RenderedChatRow["tone"]): string | null { + // User-facing decision: drop the left vertical bar entirely. Subagent rows + // get their own colored side rail via the row's explicit `rail` field. + return null; } function InlineSpans({ runs }: { runs: InlineRun[] }) { @@ -866,8 +1050,9 @@ function InlineSpans({ runs }: { runs: InlineRun[] }) { {runs.map((run, index) => { const key = `${index}:${run.text}`; if (run.code) { + // Codex-style: green foreground, no background chip. return ( - + {run.text} ); @@ -889,9 +1074,70 @@ function InlineSpans({ runs }: { runs: InlineRun[] }) { ); } -function ChatRow({ row }: { row: RenderedChatRow }) { +function normalizeSelection(selection: ChatTextSelection): ChatTextSelection { + if ( + selection.startRow < selection.endRow + || (selection.startRow === selection.endRow && selection.startColumn <= selection.endColumn) + ) { + return selection; + } + return { + startRow: selection.endRow, + startColumn: selection.endColumn, + endRow: selection.startRow, + endColumn: selection.startColumn, + }; +} + +function selectedRangeForRow(rowIndex: number, selection: ChatTextSelection, lineLength: number): [number, number] | null { + const normalized = normalizeSelection(selection); + if (rowIndex < normalized.startRow || rowIndex > normalized.endRow) return null; + const rawStart = rowIndex === normalized.startRow ? normalized.startColumn : 0; + const rawEnd = rowIndex === normalized.endRow ? normalized.endColumn + 1 : Math.max(lineLength, 1); + const start = Math.max(0, Math.min(rawStart, Math.max(lineLength, 1))); + const end = Math.max(start, Math.min(rawEnd, Math.max(lineLength, 1))); + return end > start ? [start, end] : null; +} + +function splitTextByColumns(value: string, start: number, end: number): [string, string, string] { + const chars = [...(value || BLANK_ROW_TEXT)]; + const safeStart = Math.max(0, Math.min(start, chars.length)); + const safeEnd = Math.max(safeStart, Math.min(end, chars.length)); + return [ + chars.slice(0, safeStart).join(""), + chars.slice(safeStart, safeEnd).join(""), + chars.slice(safeEnd).join(""), + ]; +} + +function ChatRow({ + row, + rowIndex, + selection, +}: { + row: RenderedChatRow; + rowIndex: number; + selection?: ChatTextSelection | null; +}) { const railColor = row.rail === undefined ? railColorForTone(row.tone) : row.rail; const plainText = row.text || BLANK_ROW_TEXT; + const selectedRange = selection ? selectedRangeForRow(rowIndex, selection, textWidth(renderedRowText(row))) : null; + if (selectedRange) { + const [before, selected, after] = splitTextByColumns(renderedRowText(row), selectedRange[0], selectedRange[1]); + return ( + + {railColor ? {"▎ "} : null} + {before} + {selected || BLANK_ROW_TEXT} + {after} + + ); + } return ( ; + maxRows?: number; + scrollOffsetRows?: number; + unseenMessageCount?: number; + width?: number; + streaming?: boolean; + interrupted?: boolean; +}): string[] { + const blocks = aggregateChatBlocks({ + events, + notices, + activeSession, + expandedLineIds, + }); + return visibleRowsForBlocks({ + blocks, + maxRows, + scrollOffsetRows, + unseenMessageCount, + width, + streaming, + interrupted, + }).map(renderedRowText); +} + +export function selectedTextFromVisibleChatRows(rows: string[], selection: ChatTextSelection): string { + const normalized = normalizeSelection(selection); + const selected: string[] = []; + for (let rowIndex = normalized.startRow; rowIndex <= normalized.endRow; rowIndex += 1) { + const row = rows[rowIndex] ?? ""; + const chars = [...row]; + const range = selectedRangeForRow(rowIndex, normalized, chars.length); + if (!range) { + selected.push(""); + continue; + } + selected.push(chars.slice(range[0], range[1]).join("")); + } + return selected.join("\n").trimEnd(); +} + export function renderChatTranscriptPlainText({ events, notices, @@ -952,6 +1291,7 @@ export function computeChatScrollMaxOffset({ expandedLineIds, maxRows, streaming = false, + interrupted = false, width = DEFAULT_VIEW_WIDTH, }: { events: AgentChatEventEnvelope[]; @@ -960,6 +1300,7 @@ export function computeChatScrollMaxOffset({ expandedLineIds?: Set; maxRows?: number; streaming?: boolean; + interrupted?: boolean; width?: number; }): number { const blocks = aggregateChatBlocks({ @@ -968,9 +1309,10 @@ export function computeChatScrollMaxOffset({ activeSession, expandedLineIds, }); - if (!blocks.length && !streaming) return 0; + if (!blocks.length && !streaming && !interrupted) return 0; const innerWidth = Math.max(24, width - 4); - const rowCount = rowsForBlocks(blocks, innerWidth, "·", "◐").length + (streaming ? modelWorkingRows("").length : 0); + const statusRows = streaming ? modelWorkingRows("").length : interrupted ? modelInterruptedRows().length : 0; + const rowCount = rowsForBlocks(blocks, innerWidth, "·", "◐").length + statusRows; return maxScrollOffsetForRows(rowCount, maxRows); } @@ -984,10 +1326,13 @@ export function ChatView({ provider, modelDisplay, streaming = false, + interrupted = false, worktreeAvailable = true, expandedLineIds, maxRows, scrollOffsetRows = 0, + unseenMessageCount = 0, + selection = null, width = DEFAULT_VIEW_WIDTH, }: { events: AgentChatEventEnvelope[]; @@ -999,22 +1344,45 @@ export function ChatView({ provider?: AdeCodeProvider | null; modelDisplay?: string | null; streaming?: boolean; + interrupted?: boolean; worktreeAvailable?: boolean; expandedLineIds?: Set; maxRows?: number; scrollOffsetRows?: number; + unseenMessageCount?: number; + selection?: ChatTextSelection | null; width?: number; }) { - const blocks = aggregateChatBlocks({ - events, - notices, - activeSession, - expandedLineIds, - }); + // Memoize the heavy aggregation pass — it walks the entire transcript and + // shouldn't re-run on every spinner tick. Events identity changes only when + // the underlying chat history advances, which is the right invalidation key. + const blocks = useMemo( + () => aggregateChatBlocks({ events, notices, activeSession, expandedLineIds }), + [events, notices, activeSession, expandedLineIds], + ); const brailleFrame = useBrailleSpin(); const spinFrame = useSpinFrame(); const dotPulse = useDotPulse(); - if (!blocks.length && !streaming) { + // Memoize the rendered row list. Spinner ticks change brailleFrame/spinFrame + // every animation frame; without memoization we'd rebuild every row tree per + // tick. Most non-live rows don't depend on the frames, so this is a big win + // for large transcripts. + const rows = useMemo( + () => visibleRowsForBlocks({ + blocks, + maxRows, + scrollOffsetRows, + unseenMessageCount, + width, + streaming, + interrupted, + brailleFrame, + spinFrame, + dotPulse, + }), + [blocks, brailleFrame, dotPulse, interrupted, maxRows, scrollOffsetRows, spinFrame, streaming, unseenMessageCount, width], + ); + if (!blocks.length && !streaming && !interrupted) { return ( ); } - const innerWidth = Math.max(24, width - 4); - const baseRows = rowsForBlocks(blocks, innerWidth, brailleFrame, spinFrame); - const rows = sliceRows( - streaming ? [...baseRows, ...modelWorkingRows(dotPulse)] : baseRows, - maxRows, - scrollOffsetRows, - ); return ( {rows.map((row, index) => ( - + ))} ); diff --git a/apps/ade-cli/src/tuiClient/components/FooterControls.tsx b/apps/ade-cli/src/tuiClient/components/FooterControls.tsx index 92239c7f9..e6f3c6c6a 100644 --- a/apps/ade-cli/src/tuiClient/components/FooterControls.tsx +++ b/apps/ade-cli/src/tuiClient/components/FooterControls.tsx @@ -4,34 +4,8 @@ import { theme } from "../theme"; import type { AdeCodeProvider } from "../types"; const TOKEN_BAR_CELLS = 10; -type FooterPane = "drawer" | "chat" | "details"; -type FooterDrawerMode = "lanes" | "chats"; -function Toggle({ - label, - hint, - open, - focused, -}: { - label: string; - hint: string; - open: boolean; - focused: boolean; -}) { - const arrow = open ? "▾" : "▸"; - if (focused) { - return ( - - {` ${arrow} ${label} ${hint} `} - - ); - } - return ( - - {`[${arrow} ${label} ${hint}]`} - - ); -} +type InlineRowCell = 'provider' | 'model' | 'reasoning' | 'permission' | 'subagents' | null; function Hint({ keyLabel, action }: { keyLabel: string; action: string }) { return ( @@ -62,15 +36,50 @@ function TokenBar({ percent }: { percent: number }) { ); } +/** + * A single picker cell. When `focused` (and the parent row is focused), the + * value is wrapped in `[brackets]` and tinted with the violet accent (or the + * plan-mode accent when `planMode` is true). When `locked`, the cell renders + * dim regardless of focus. + */ +function Cell({ + value, + focused, + rowFocused, + baseColor, + accentColor, + locked, +}: { + value: string; + focused: boolean; + rowFocused: boolean; + baseColor: string; + accentColor: string; + locked?: boolean; +}) { + if (locked) { + return ( + + {value} + + ); + } + if (rowFocused && focused) { + return ( + + {`[${value}]`} + + ); + } + if (rowFocused) { + return {value}; + } + return {value}; +} + export function FooterControls({ - drawerOpen, - rightOpen, - activePane = "chat", - drawerMode = "lanes", - drawerFocused, - detailsFocused, - footerControlActive, provider, + providerLocked, modelDisplay, reasoningEffort, permissionLabel, @@ -78,23 +87,14 @@ export function FooterControls({ tokenSummary, approvalActive, liveAgentCount, - rightPaneShowsAgents, - agentsAvailable, - agentsOpen, - agentsFocused, + subagentsButtonVisible, fastMode, - pendingSteerCount = 0, - chatScrollOffset = 0, - chatScrollMaxOffset = 0, + inlineRowFocused, + inlineRowCell, + planMode, }: { - drawerOpen: boolean; - rightOpen: boolean; - activePane?: FooterPane; - drawerMode?: FooterDrawerMode; - drawerFocused: boolean; - detailsFocused: boolean; - footerControlActive: boolean; provider?: AdeCodeProvider | null; + providerLocked?: boolean; modelDisplay?: string | null; reasoningEffort?: string | null; permissionLabel?: string | null; @@ -102,174 +102,146 @@ export function FooterControls({ tokenSummary?: string | null; approvalActive?: boolean; liveAgentCount?: number; - rightPaneShowsAgents?: boolean; - agentsAvailable?: boolean; - agentsOpen?: boolean; - agentsFocused?: boolean; + subagentsButtonVisible?: boolean; fastMode?: boolean; - pendingSteerCount?: number; - chatScrollOffset?: number; - chatScrollMaxOffset?: number; + inlineRowFocused?: boolean; + inlineRowCell?: InlineRowCell; + planMode?: boolean; }) { const brand = provider ? theme.provider(provider) : null; - const hasModeBar = Boolean(brand || modelDisplay || fastMode || reasoningEffort || permissionLabel || contextPercent != null || tokenSummary || pendingSteerCount > 0); - const showAgentsToggle = agentsAvailable === true; - const showAgentsChip = (liveAgentCount ?? 0) > 0 && !rightPaneShowsAgents && !showAgentsToggle; - const actionHint = (() => { - if (activePane === "drawer" && drawerMode === "chats") { - return ( - <> - - - - - ); - } - if (activePane === "drawer") { - return ( - <> - - - - - ); - } - if (activePane === "details") { - return ( - <> - - - - - ); - } - const safeScrollMax = Math.max(0, chatScrollMaxOffset); - const safeScrollOffset = Math.max(0, Math.min(chatScrollOffset, safeScrollMax)); - return ( - <> - - {safeScrollMax > 0 ? {` ${safeScrollOffset}/${safeScrollMax}`} : null} - - - {pendingSteerCount > 0 ? ( + const rowFocused = inlineRowFocused === true; + const accentColor = planMode ? theme.color.planMode : theme.color.violet; + const agents = liveAgentCount ?? 0; + const showSubagents = subagentsButtonVisible === true; + const providerIsLocked = providerLocked === true; + + return ( + + + {rowFocused ? ( + {"▸ "} + ) : null} + {brand ? ( + + ) : null} + {modelDisplay ? ( <> - - + {" "} + ) : null} - - ); - })(); - return ( - - {hasModeBar ? ( - - - {brand ? ( - {brand.glyph} {brand.label} - ) : null} - {modelDisplay ? ( - <> - {" · "} - {modelDisplay} - - ) : null} - {fastMode ? ( - <> - {" · "} - fast - - ) : null} - {reasoningEffort ? ( - <> - {" · "} - {reasoningEffort} - - ) : null} - {permissionLabel ? ( + {fastMode ? ( + <> + {" "} + fast + + ) : null} + {reasoningEffort ? ( + <> + {" "} + + + ) : null} + {permissionLabel ? ( + <> + {" "} + + + ) : null} + {showSubagents ? ( + <> + {" "} + {(() => { + const subagentValue = `⊚ ${agents} subagent${agents === 1 ? '' : 's'}`; + const isFocused = inlineRowCell === 'subagents'; + if (rowFocused && isFocused) { + return ( + {`[${subagentValue}]`} + ); + } + return ( + + {subagentValue} + + ); + })()} + + ) : null} + {contextPercent != null || tokenSummary ? ( + <> + {" "} + {contextPercent != null ? ( <> - {" · "} - {permissionLabel} + + {` ${contextPercent}%`} ) : null} - {pendingSteerCount > 0 ? ( + {tokenSummary ? ( <> - {" · "} - staged {pendingSteerCount} + {contextPercent != null ? " · " : ""} + {tokenSummary} ) : null} - - {contextPercent != null || tokenSummary ? ( - - {contextPercent != null ? ( - <> - - {` ${contextPercent}%`} - - ) : null} - {tokenSummary ? ( - <> - {" "} - {tokenSummary} - - ) : null} - - ) : null} - - ) : null} - - - {showAgentsToggle ? ( - <> - - {" "} - - ) : null} - - {" "} - - {showAgentsChip ? ( - <> - {" "} - - {`[● ${liveAgentCount} agent${liveAgentCount === 1 ? "" : "s"} · ^a]`} - - - ) : null} - - - {approvalActive ? ( - <> - a - {" approve "} - d - {" deny · "} - ← → - {" choose"} - - ) : footerControlActive ? ( - ↵ toggle ← → choose ↑ exit - ) : ( - <> - {actionHint} - - - - - - )} - - + + ) : null} + + + {approvalActive ? ( + <> + a + {" approve "} + d + {" deny · "} + ← → + {" choose"} + + ) : rowFocused ? ( + <> + + {" "} + + {" "} + + + ) : ( + <> + + {" "} + + {" "} + + {" "} + + {" "} + + + )} + ); } diff --git a/apps/ade-cli/src/tuiClient/components/RightPane.tsx b/apps/ade-cli/src/tuiClient/components/RightPane.tsx index 2ea1176f0..35abefb27 100644 --- a/apps/ade-cli/src/tuiClient/components/RightPane.tsx +++ b/apps/ade-cli/src/tuiClient/components/RightPane.tsx @@ -2,9 +2,7 @@ import React from "react"; import { Box, Text } from "ink"; import type { AdeCodeProvider, - ProviderReadinessRow, RightPaneContent, - SetupPaneRow, SubagentSnapshot, } from "../types"; import { theme } from "../theme"; @@ -18,8 +16,6 @@ import { buildSubagentPaneRows, type SubagentPaneRow, type SubagentPaneSection } const DEFAULT_PANE_WIDTH = 38; const LANE_LABEL_WIDTH = 10; const LANE_FILE_PREVIEW_ROWS = 5; -const MODEL_LABEL_WIDTH = 14; -const PROVIDER_ORDER: AdeCodeProvider[] = ["claude", "codex", "cursor", "droid", "opencode"]; // --------------------------------------------------------------------------- // Actions for the lane-details pane (5 rows · wireframe) @@ -40,7 +36,7 @@ export const LANE_DETAIL_ACTIONS: ReadonlyArray<{ // --------------------------------------------------------------------------- // Tiny atom helpers (inline replacements for Chip / Rail / SectionLG / Kbd / -// ActionRow / Exec / ModelRow / ProvTab from the wireframe primitives) +// ActionRow / Exec from the wireframe primitives) // --------------------------------------------------------------------------- function Chip({ @@ -75,28 +71,6 @@ function SectionHead({ title, hint }: { title: string; hint?: string }) { ); } -function ModelSectionHead({ - title, - hint, - width, - marginTop = 1, -}: { - title: string; - hint?: string; - width: number; - marginTop?: number; -}) { - const hintWidth = hint ? hint.length + 1 : 0; - const lineWidth = Math.max(2, width - title.length - hintWidth - 3); - return ( - - {title} - {"─".repeat(lineWidth)} - {hint ? {hint} : null} - - ); -} - function ActionRow({ k, label, @@ -392,212 +366,6 @@ function LaneDetailsPane({ ); } -// --------------------------------------------------------------------------- -// Model-setup renderer (wireframe ModelFinal) -// --------------------------------------------------------------------------- - -function ProviderTabStrip({ - providerRows, - activeProvider, - width, -}: { - providerRows: ProviderReadinessRow[]; - activeProvider: AdeCodeProvider; - width: number; -}) { - const rowsByProvider = new Map(providerRows.map((row) => [row.provider, row])); - const orderedRows = [ - rowsByProvider.get(activeProvider), - ...PROVIDER_ORDER.filter((provider) => provider !== activeProvider).map((provider) => rowsByProvider.get(provider)), - ...providerRows.filter((row) => !PROVIDER_ORDER.includes(row.provider) && row.provider !== activeProvider), - ].filter((row): row is ProviderReadinessRow => Boolean(row)); - - return ( - - - - {orderedRows.map((row) => { - const brand = theme.provider(row.provider); - const isActive = row.provider === activeProvider; - const statusColor = row.status === "ready" - ? theme.color.running - : row.status === "unknown" - ? theme.color.attention - : theme.color.t4; - return ( - - [ - - - {" "}{brand.wordmark} - - ] - - ); - })} - - - - ready · - - active · - - needs login - - - ); -} - -function ModelRow({ - row, - selected, - width, -}: { - row: SetupPaneRow; - selected: boolean; - width: number; -}) { - const rail = selected ? {theme.rail} : ; - const detail = row.detail?.replace(/,\s*/g, " · ") ?? ""; - const valueWidth = Math.max(8, width - MODEL_LABEL_WIDTH - detail.length - 6); - return ( - - - {rail} - - {" "}{row.label.padEnd(MODEL_LABEL_WIDTH)} - - - {endTruncate(row.value, valueWidth)} - - - {detail ? ( - - {endTruncate(detail, Math.max(6, width - MODEL_LABEL_WIDTH - valueWidth - 4))} - - ) : null} - - ); -} - -function ActiveProviderStatus({ row }: { row: ProviderReadinessRow }) { - const modelCount = row.modelCount > 0 ? `${row.modelCount} available` : "none discovered"; - const statusLabel = ((): string => { - if (row.status === "ready") return "ready"; - if (row.status === "unknown") return "needs attention"; - return "needs login"; - })(); - const statusColor = ((): string => { - if (row.status === "ready") return theme.color.running; - if (row.status === "unknown") return theme.color.attention; - return theme.color.error; - })(); - return ( - - - - auth - · - {statusLabel} - - - - models - · - {modelCount} - - - - runtime - · - {tailTruncate(row.detail, 48)} - - - ); -} - -function ModelSetupPane({ - content, - selectedIndex, - width, -}: { - content: Extract; - selectedIndex: number; - width: number; -}) { - const contentWidth = Math.max(30, width - 2); - const cyclableRows = content.rows.filter((row) => row.cyclable === true); - const actionRows = content.rows.filter((row) => row.cyclable !== true); - const activeProviderRow = - content.providerRows.find((row) => row.provider === content.activeProvider) ?? null; - - return ( - - {/* Provider tab strip */} - - - {/* MODEL section */} - - {cyclableRows.map((row) => { - const absoluteIndex = content.rows.indexOf(row); - return ( - - ); - })} - - {/* RUN section (actions like Refresh / Open settings / Login) */} - {actionRows.length ? ( - <> - - {actionRows.map((row) => { - const absoluteIndex = content.rows.indexOf(row); - const selected = absoluteIndex === selectedIndex; - const glyph = ((): string => { - if (row.kind === "refresh-status") return "↻"; - if (row.kind === "open-settings") return "↗"; - return "→"; - })(); - return ( - - ); - })} - - ) : null} - - {/* STATUS · */} - - {activeProviderRow ? ( - - ) : null} - - - - ← → provider · ↑↓ field · ↵ apply · l login - {content.checkedAt ? ` · ${content.checkedAt.slice(11, 19)}` : ""} - - - - ); -} - // --------------------------------------------------------------------------- // Agents pane (Subagents · Teammates) — htop process table, runtime-adaptive // --------------------------------------------------------------------------- @@ -749,6 +517,17 @@ function SubagentsPane({ ); } + if (content.snapshots.length === 0) { + return ( + + No subagents yet. + + Subagents and teammates spawned by the active chat appear here. + + + ); + } + const rows = buildSubagentPaneRows(content); const snapshots = rows.filter((row): row is Extract => row.kind === "snapshot"); const subagentsCount = subagentSectionCount(rows, "subagents") + subagentSectionCount(rows, "recent"); @@ -846,20 +625,21 @@ function SubagentsPane({ } // --------------------------------------------------------------------------- -// Other content modes (status, list, details, diff, models, effort, form, -// new-chat-setup, help, empty) — kept compact, refreshed to use theme tokens. +// Other content modes (status, list, details, diff, form, new-chat-setup, +// help, empty) — kept compact, refreshed to use theme tokens. // --------------------------------------------------------------------------- function HelpPane() { return ( + ↓ from prompt enters the model row; ↑ returns + in the row: ← → moves between cells, ↓ cycles values + /model focuses the model row · /subagents opens the agents pane ctrl-o opens or focuses lanes and chats - ctrl-p opens or focuses info - shift-tab cycles pane focus - esc closes the active side pane + ctrl-p opens or focuses info · ctrl-a toggles subagents + shift-tab cycles pane focus · esc closes the active side pane ctrl-c interrupts a running chat; press again to quit / opens commands, @ opens references, tab inserts selected - /ade status forces ADE's TUI command when a runtime owns /status ); } @@ -874,8 +654,6 @@ function paneTitle(content: RightPaneContent): { title: string; hint?: string } return { title: `${endTruncate(content.lane.name.toUpperCase(), 22)} · ${content.worktreeAvailable === false ? "MISSING" : "FOCUSED"}`, }; - case "model-setup": - return { title: "SETUP · MODEL", hint: new Date().toISOString().slice(11, 19) }; case "new-chat-setup": return { title: "NEW CHAT" }; case "subagents": @@ -890,10 +668,6 @@ function paneTitle(content: RightPaneContent): { title: string; hint?: string } return { title: content.title.toUpperCase() }; case "details": return { title: content.title.toUpperCase() }; - case "models": - return { title: "MODEL" }; - case "effort": - return { title: "EFFORT" }; case "form": return { title: content.title.toUpperCase() }; default: @@ -930,7 +704,7 @@ export function RightPane({ flexDirection="column" borderStyle="single" borderColor={focused ? theme.color.borderFocused : theme.color.border} - paddingX={content.kind === "model-setup" ? 0 : 1} + paddingX={1} > {/* Pane header */} @@ -996,35 +770,6 @@ export function RightPane({ ) : null} - {content.kind === "models" ? ( - - {content.models.map((model, index) => ( - - {index === selectedIndex ? theme.rail : " "}{" "} - {(model.modelId ?? model.id) === content.activeModelId ? "●" : "○"} {model.displayName} - - ))} - arrows move · enter applies - - ) : null} - - {content.kind === "effort" ? ( - - {content.efforts.map((effort, index) => ( - - {index === selectedIndex ? theme.rail : " "} {effort === content.activeEffort ? "●" : "○"} {effort} - - ))} - arrows move · enter applies - - ) : null} - {content.kind === "lane-details" ? ( ) : null} @@ -1033,10 +778,6 @@ export function RightPane({ ) : null} - {content.kind === "model-setup" ? ( - - ) : null} - {content.kind === "new-chat-setup" ? ( Lane: {content.laneLabel} @@ -1058,7 +799,7 @@ export function RightPane({ ); })} - ↑↓ rows · ←→ change · ↵ prompt + ↑↓ rows · ←→ change · ↵ prompt · cmd+↵ background ) : null} @@ -1066,13 +807,17 @@ export function RightPane({ {content.fields.map((field, index) => { const value = formValues[field.name]?.trim(); + const displayValue = endTruncate( + (value || field.placeholder || "").replace(/\s+/g, " "), + Math.max(8, paneWidth - field.label.length - 8), + ); return ( {index === activeFormField ? theme.rail : " "} {field.label} - {field.required ? " *" : ""}: {value || field.placeholder || ""} + {field.required ? " *" : ""}: {displayValue} ); })} diff --git a/apps/ade-cli/src/tuiClient/components/SlashPalette.tsx b/apps/ade-cli/src/tuiClient/components/SlashPalette.tsx index 8052d6236..b54cc7137 100644 --- a/apps/ade-cli/src/tuiClient/components/SlashPalette.tsx +++ b/apps/ade-cli/src/tuiClient/components/SlashPalette.tsx @@ -1,18 +1,30 @@ import React from "react"; import { Box, Text } from "ink"; import type { AgentChatProvider, AgentChatSlashCommand } from "../../../../desktop/src/shared/types/chat"; -import { paletteCommands } from "../commands"; +import { paletteCommands, type CommandPlacement } from "../commands"; import { theme } from "../theme"; +const PLACEMENT_GLYPHS: Record = { + right: "↗", + chat: "✉", + inline: "·", + overlay: "◇", +}; + +function placementGlyph(placement?: CommandPlacement): string { + if (!placement) return " "; + return PLACEMENT_GLYPHS[placement] ?? " "; +} + const VISIBLE_ROWS = 5; export const SLASH_PALETTE_ROWS = VISIBLE_ROWS + 3; const DEFAULT_PALETTE_WIDTH = 88; const MAX_PALETTE_WIDTH = 104; -const MIN_PALETTE_WIDTH = 56; function clampPaletteWidth(width?: number): number { const available = Number.isFinite(width) ? Math.floor(width ?? DEFAULT_PALETTE_WIDTH) : DEFAULT_PALETTE_WIDTH; - return Math.max(MIN_PALETTE_WIDTH, Math.min(MAX_PALETTE_WIDTH, available)); + const safeAvailable = Math.max(1, available - 2); + return Math.min(MAX_PALETTE_WIDTH, safeAvailable); } const PROVIDER_LABELS: Record = { @@ -37,12 +49,27 @@ function selectedExample(row: PaletteRow): string { function endTruncate(value: string, max: number): string { if (max <= 1) return value.length ? "…" : ""; - if (value.length <= max) return value; - return `${value.slice(0, Math.max(0, max - 1))}…`; + if (textWidth(value) <= max) return value; + const ellipsis = "…"; + const ellipsisWidth = textWidth(ellipsis); + let width = 0; + let result = ""; + for (const char of value) { + const nextWidth = textWidth(char); + if (width + nextWidth + ellipsisWidth > max) break; + result += char; + width += nextWidth; + } + return `${result}${ellipsis}`; +} + +function terminalCellWidth(char: string): number { + const codePoint = char.codePointAt(0) ?? 0; + return codePoint > 0x7f ? 2 : 1; } function textWidth(value: string): number { - return [...value].length; + return [...value].reduce((width, char) => width + terminalCellWidth(char), 0); } function padEnd(value: string, width: number): string { @@ -113,20 +140,28 @@ export function SlashPalette({ belowCount ? `${belowCount} below` : null, ].filter(Boolean).join(" · "); const header = topLine(`Commands · ${providerLabel(provider)} · ${queryLabel} · ${total} match${total === 1 ? "" : "es"}`, paletteWidth); - const rowLines = window.map((row, index) => { + const rowLines: Array< + | { kind: "blank"; value: string } + | { + kind: "row"; + selected: boolean; + value: string; + } + > = window.map((row, index) => { const absoluteIndex = start + index; const isSelected = absoluteIndex === safeIndex; const command = selectedExample(row); + const rail = isSelected ? theme.rail : " "; + const glyph = placementGlyph(row.placement); + const rowText = `${rail} ${glyph} ${fillLine(command, nameWidth)} ${endTruncate(row.description, descriptionWidth)}`; return { + kind: "row" as const, selected: isSelected, - value: bodyLine( - `${isSelected ? theme.rail : " "} ${fillLine(command, nameWidth)} ${endTruncate(row.description, descriptionWidth)}`, - paletteWidth, - ), + value: bodyLine(rowText, paletteWidth), }; }); while (rowLines.length < VISIBLE_ROWS) { - rowLines.push({ selected: false, value: bodyLine("", paletteWidth) }); + rowLines.push({ kind: "blank" as const, value: bodyLine("", paletteWidth) }); } const footer = bottomLine( `${moreSummary ? `${moreSummary} · ` : ""}↑↓ move · Tab insert · Enter run · Esc close`, @@ -136,11 +171,21 @@ export function SlashPalette({ return ( {paletteLine(header, theme.color.violet)} - {rowLines.map((line, index) => ( - - {paletteLine(line.value, line.selected ? theme.color.t1 : theme.color.t2)} - - ))} + {rowLines.map((line, index) => { + if (line.kind === "blank") { + return ( + + {paletteLine(line.value, theme.color.t2)} + + ); + } + const textColor = line.selected ? theme.color.t1 : theme.color.t2; + return ( + + {line.value} + + ); + })} {paletteLine(bodyLine(selectedSummary, paletteWidth), theme.color.t3)} {paletteLine(footer, theme.color.t4)} diff --git a/apps/ade-cli/src/tuiClient/components/TerminalPane.tsx b/apps/ade-cli/src/tuiClient/components/TerminalPane.tsx new file mode 100644 index 000000000..e0ad66dbd --- /dev/null +++ b/apps/ade-cli/src/tuiClient/components/TerminalPane.tsx @@ -0,0 +1,348 @@ +import React, { useEffect, useMemo, useRef, useState } from "react"; +import { Box, Text } from "ink"; +import * as HeadlessXterm from "@xterm/headless"; +import type { + ChatTerminalPreviewResult, + TerminalSnapshotCell, + TerminalSnapshotRow, +} from "../../../../desktop/src/shared/types"; +import { theme } from "../theme"; + +type HeadlessXtermModule = typeof HeadlessXterm; + +const headlessXtermModule = ((HeadlessXterm as unknown as { default?: HeadlessXtermModule }).default + ?? HeadlessXterm) as HeadlessXtermModule; +const { Terminal: HeadlessTerminal } = headlessXtermModule; + +type TerminalPaneProps = { + title: string; + preview: ChatTerminalPreviewResult | null; + liveChunks: string[]; + attached: boolean; + width: number; + height: number; + hiddenBottomRows?: number; +}; + +type HeadlessTerminalInstance = InstanceType; +type XtermBufferCell = { + getWidth(): number; + getChars(): string; + getFgColor(): number; + getBgColor(): number; + isBold(): number; + isDim(): number; + isItalic(): number; + isUnderline(): number; + isInverse(): number; + isInvisible(): number; + isStrikethrough(): number; + isFgRGB(): boolean; + isBgRGB(): boolean; + isFgPalette(): boolean; + isBgPalette(): boolean; +}; +type TerminalRunStyle = { + color?: string; + backgroundColor?: string; + bold?: boolean; + dim?: boolean; + italic?: boolean; + underline?: boolean; + inverse?: boolean; + strikethrough?: boolean; +}; +type TerminalRun = { + text: string; + style: TerminalRunStyle; +}; +type TerminalStyledRow = { + runs: TerminalRun[]; +}; + +export const MAX_TERMINAL_PANE_COLS = 400; + +function finiteFloor(value: number, fallback: number): number { + return Number.isFinite(value) ? Math.floor(value) : fallback; +} + +export function clampTerminalPaneCols(width: number): number { + return Math.max(20, Math.min(MAX_TERMINAL_PANE_COLS, finiteFloor(width, 20))); +} + +function clampTerminalPaneRows(height: number): number { + return Math.max(4, Math.min(120, finiteFloor(height, 4))); +} + +const XTERM_16_COLORS = [ + "#000000", + "#cd3131", + "#0dbc79", + "#e5e510", + "#2472c8", + "#bc3fbc", + "#11a8cd", + "#e5e5e5", + "#666666", + "#f14c4c", + "#23d18b", + "#f5f543", + "#3b8eea", + "#d670d6", + "#29b8db", + "#ffffff", +] as const; + +function stripTerminalControls(value: string): string { + return value + .replace(/\x1b\][^\x07]*(?:\x07|\x1b\\)/g, "") + .replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, "") + .replace(/\x1b[()][0-2AB]/g, "") + .replace(/\x1b(?:[@-Z\\-_]|[0-9=>])/g, "") + .replace(/\r(?!\n)/g, "\n"); +} + +function rgbColor(value: number | null | undefined): string | undefined { + if (typeof value !== "number" || !Number.isFinite(value)) return undefined; + const safe = Math.max(0, Math.min(0xffffff, Math.floor(value))); + return `#${safe.toString(16).padStart(6, "0")}`; +} + +function paletteColor(value: number | null | undefined): string | undefined { + if (typeof value !== "number" || !Number.isFinite(value)) return undefined; + const index = Math.max(0, Math.min(255, Math.floor(value))); + if (index < XTERM_16_COLORS.length) return XTERM_16_COLORS[index]; + if (index >= 16 && index <= 231) { + const offset = index - 16; + const r = Math.floor(offset / 36); + const g = Math.floor((offset % 36) / 6); + const b = offset % 6; + const channel = (part: number) => part === 0 ? 0 : 55 + part * 40; + return rgbColor((channel(r) << 16) + (channel(g) << 8) + channel(b)); + } + const gray = 8 + (index - 232) * 10; + return rgbColor((gray << 16) + (gray << 8) + gray); +} + +function cellColor(mode: "default" | "palette" | "rgb", value: number | null): string | undefined { + if (mode === "rgb") return rgbColor(value); + if (mode === "palette") return paletteColor(value); + return undefined; +} + +function styleForCell(cell: TerminalSnapshotCell): TerminalRunStyle { + let color = cellColor(cell.fgMode, cell.fg); + let backgroundColor = cellColor(cell.bgMode, cell.bg); + if (cell.inverse) { + const nextColor = backgroundColor; + backgroundColor = color; + color = nextColor; + } + return { + ...(color ? { color } : {}), + ...(backgroundColor ? { backgroundColor } : {}), + ...(cell.bold ? { bold: true } : {}), + ...(cell.dim ? { dim: true } : {}), + ...(cell.italic ? { italic: true } : {}), + ...(cell.underline ? { underline: true } : {}), + ...(cell.inverse ? { inverse: true } : {}), + ...(cell.strikethrough ? { strikethrough: true } : {}), + }; +} + +function styleKey(style: TerminalRunStyle): string { + return [ + style.color ?? "", + style.backgroundColor ?? "", + style.bold ? "b" : "", + style.dim ? "d" : "", + style.italic ? "i" : "", + style.underline ? "u" : "", + style.inverse ? "v" : "", + style.strikethrough ? "s" : "", + ].join("|"); +} + +function styledRowFromCells(cells: TerminalSnapshotCell[], fallbackText = ""): TerminalStyledRow { + if (!cells.length) return { runs: [{ text: fallbackText || " ", style: {} }] }; + const runs: TerminalRun[] = []; + for (const cell of cells) { + const text = cell.text || " "; + const style = styleForCell(cell); + const last = runs[runs.length - 1]; + if (last && styleKey(last.style) === styleKey(style)) { + last.text += text; + } else { + runs.push({ text, style }); + } + } + if (!runs.length) runs.push({ text: fallbackText || " ", style: {} }); + return { runs }; +} + +function snapshotCellFromXtermCell(cell: XtermBufferCell | undefined): TerminalSnapshotCell { + if (!cell || cell.getWidth() === 0 || cell.isInvisible()) { + return { text: " ", fg: null, bg: null, fgMode: "default", bgMode: "default" }; + } + const fgMode = cell.isFgRGB() ? "rgb" : cell.isFgPalette() ? "palette" : "default"; + const bgMode = cell.isBgRGB() ? "rgb" : cell.isBgPalette() ? "palette" : "default"; + return { + text: cell.getChars() || " ", + fg: fgMode === "default" ? null : cell.getFgColor(), + bg: bgMode === "default" ? null : cell.getBgColor(), + fgMode, + bgMode, + ...(cell.isBold() ? { bold: true } : {}), + ...(cell.isDim() ? { dim: true } : {}), + ...(cell.isItalic() ? { italic: true } : {}), + ...(cell.isUnderline() ? { underline: true } : {}), + ...(cell.isInverse() ? { inverse: true } : {}), + ...(cell.isStrikethrough() ? { strikethrough: true } : {}), + }; +} + +function styledRowsFromTerminal(terminal: HeadlessTerminalInstance, maxRows: number): TerminalStyledRow[] { + const buffer = terminal.buffer.active; + const start = Math.max(0, buffer.viewportY); + const rows: TerminalStyledRow[] = []; + for (let row = 0; row < Math.max(0, maxRows); row += 1) { + const line = buffer.getLine(start + row); + if (!line) { + rows.push({ runs: [{ text: " ", style: {} }] }); + continue; + } + const cells: TerminalSnapshotCell[] = []; + for (let column = 0; column < terminal.cols; column += 1) { + cells.push(snapshotCellFromXtermCell(line.getCell(column) as unknown as XtermBufferCell | undefined)); + } + rows.push(styledRowFromCells(cells, line.translateToString(true))); + } + return rows; +} + +export function styledRowsFromSnapshotRows(rows: TerminalSnapshotRow[], maxRows: number): TerminalStyledRow[] { + return rows.slice(0, Math.max(0, maxRows)).map((row) => styledRowFromCells(row.cells, row.text)); +} + +function fallbackPreviewRows(preview: ChatTerminalPreviewResult | null, maxRows: number): TerminalStyledRow[] { + if (!preview) return [{ runs: [{ text: "No terminal preview yet.", style: {} }] }]; + if (preview.snapshot?.visibleRows?.length) { + return styledRowsFromSnapshotRows(preview.snapshot.visibleRows, maxRows); + } + const text = stripTerminalControls(preview.transcript ?? "").trimEnd(); + if (!text) return [{ runs: [{ text: "No terminal output yet.", style: {} }] }]; + return text.split(/\r\n|\n|\r/).slice(-maxRows).map((line) => ({ runs: [{ text: line || " ", style: {} }] })); +} + +export function TerminalPane({ + title, + preview, + liveChunks, + attached, + width, + height, + hiddenBottomRows = 0, +}: TerminalPaneProps) { + const cols = clampTerminalPaneCols(width); + const rows = clampTerminalPaneRows(height); + const emulatedRows = clampTerminalPaneRows(rows + Math.max(0, finiteFloor(hiddenBottomRows, 0))); + const useSnapshotRows = Boolean( + preview?.snapshot?.visibleRows?.length + && (preview.session.status === "running" || !preview.transcript), + ); + const snapshotRows = useMemo( + () => useSnapshotRows && preview?.snapshot?.visibleRows + ? styledRowsFromSnapshotRows(preview.snapshot.visibleRows, rows) + : null, + [preview?.snapshot, rows, useSnapshotRows], + ); + const seed = useSnapshotRows ? "" : preview?.transcript ?? ""; + const terminalRef = useRef(null); + const chunkIndexRef = useRef(0); + const [renderTick, setRenderTick] = useState(0); + + useEffect(() => { + const terminal = new HeadlessTerminal({ + allowProposedApi: true, + cols, + rows: emulatedRows, + scrollback: 2_000, + }); + terminalRef.current = terminal; + chunkIndexRef.current = 0; + if (seed) { + terminal.write(seed, () => setRenderTick((tick) => tick + 1)); + } else { + setRenderTick((tick) => tick + 1); + } + return () => { + terminal.dispose(); + if (terminalRef.current === terminal) terminalRef.current = null; + }; + }, [cols, emulatedRows, preview?.terminalId, seed]); + + useEffect(() => { + const terminal = terminalRef.current; + if (!terminal) return; + if (terminal.cols !== cols || terminal.rows !== emulatedRows) { + terminal.resize(cols, emulatedRows); + setRenderTick((tick) => tick + 1); + } + }, [cols, emulatedRows]); + + useEffect(() => { + const terminal = terminalRef.current; + if (!terminal) return; + const start = chunkIndexRef.current; + if (liveChunks.length < start) chunkIndexRef.current = 0; + for (let index = chunkIndexRef.current; index < liveChunks.length; index += 1) { + terminal.write(liveChunks[index] ?? "", () => setRenderTick((tick) => tick + 1)); + } + chunkIndexRef.current = liveChunks.length; + }, [liveChunks]); + + const lines = useMemo(() => { + if (snapshotRows?.length) return snapshotRows.slice(0, rows); + const terminal = terminalRef.current; + if (terminal) return styledRowsFromTerminal(terminal, rows); + return fallbackPreviewRows(preview, rows); + }, [preview, renderTick, rows, snapshotRows]); + + const status = attached + ? "attached · Ctrl+] returns to ADE" + : preview?.session.status === "running" + ? hiddenBottomRows > 0 ? "ADE prompt sends to Claude Code" : "live preview" + : preview?.session.resumeCommand + ? "closed, resumable" + : "closed"; + + return ( + + + {title} + {status} + + + {lines.slice(0, height).map((line, index) => ( + + {line.runs.map((run, runIndex) => ( + + {run.text || " "} + + ))} + + ))} + + + ); +} diff --git a/apps/ade-cli/src/tuiClient/connection.ts b/apps/ade-cli/src/tuiClient/connection.ts index f1a996838..864c5dc17 100644 --- a/apps/ade-cli/src/tuiClient/connection.ts +++ b/apps/ade-cli/src/tuiClient/connection.ts @@ -7,6 +7,7 @@ import { resolveMachineAdeLayout } from "../services/projects/machineLayout"; import { JsonRpcClient } from "./jsonRpcClient"; import type { AdeCodeConnection, ProjectLaunchContext } from "./types"; import type { AgentChatEventEnvelope } from "../../../desktop/src/shared/types/chat"; +import type { BufferedEvent } from "../eventBuffer"; type RpcResponseEnvelope = | T @@ -42,6 +43,10 @@ type EmbeddedRuntime = { callback: (event: AgentChatEventEnvelope) => void, ) => () => void; }; + eventBuffer?: { + drain?: (cursor: number, limit?: number) => { events: BufferedEvent[]; nextCursor: number; hasMore: boolean }; + subscribe?: (listener: (event: BufferedEvent) => void) => () => void; + }; }; type DirectHandler = { @@ -204,6 +209,29 @@ function createAdeActionHelpers(request: AdeRpcRequest): AdeActionHelpers { }; } +function isBufferedEvent(value: unknown): value is BufferedEvent { + if (!value || typeof value !== "object" || Array.isArray(value)) return false; + const event = value as Partial; + return ( + typeof event.id === "number" && + typeof event.timestamp === "string" && + typeof event.category === "string" && + Boolean(event.payload) && + typeof event.payload === "object" && + !Array.isArray(event.payload) + ); +} + +type RuntimeEventNotification = { + subscriptionId?: string; + event?: unknown; +}; + +type PendingRuntimeEvent = { + subscriptionId?: string; + event: BufferedEvent; +}; + async function initialize(request: AdeRpcRequest): Promise { const result = await request("ade/initialize", { protocolVersion: "2025-06-18", @@ -358,6 +386,42 @@ async function connectAttachedSocket(args: { attachedClient.onNotification("chat/event", (params) => callback(params as AgentChatEventEnvelope), ), + subscribeRuntimeEvents: async (subscriptionArgs, callback) => { + let subscriptionId: string | null = null; + const pending: PendingRuntimeEvent[] = []; + const stopNotification = attachedClient.onNotification("runtime/event", (params) => { + const payload = params as RuntimeEventNotification; + if (!isBufferedEvent(payload.event)) return; + if (!subscriptionId) { + pending.push({ subscriptionId: payload.subscriptionId, event: payload.event }); + return; + } + if (payload.subscriptionId === subscriptionId) callback(payload.event); + }); + try { + const response = await request<{ subscriptionId: string }>("runtimeEvents.subscribe", { + category: subscriptionArgs.category ?? "runtime", + cursor: subscriptionArgs.cursor ?? 0, + limit: subscriptionArgs.limit ?? 100, + }); + subscriptionId = response.subscriptionId; + for (const payload of pending) { + if (payload.subscriptionId === subscriptionId) { + callback(payload.event); + } + } + pending.length = 0; + return () => { + stopNotification(); + if (subscriptionId) { + request("runtimeEvents.unsubscribe", { subscriptionId }).catch(() => {}); + } + }; + } catch (error) { + stopNotification(); + throw error; + } + }, close: async () => attachedClient.close(), }; } catch (error) { @@ -527,6 +591,22 @@ export async function connectToAde(args: { request, ...createAdeActionHelpers(request), onChatEvent: (callback) => chatEvents(callback), + subscribeRuntimeEvents: async (subscriptionArgs, callback) => { + const category = subscriptionArgs.category ?? "runtime"; + const eventBuffer = runtime.eventBuffer; + if (!eventBuffer) return () => {}; + const shouldForward = (event: BufferedEvent) => !category || event.category === category; + const replay = typeof eventBuffer.drain === "function" + ? eventBuffer.drain(subscriptionArgs.cursor ?? 0, subscriptionArgs.limit ?? 100) + : { events: [] }; + for (const event of replay.events) { + if (shouldForward(event)) callback(event); + } + if (typeof eventBuffer.subscribe !== "function") return () => {}; + return eventBuffer.subscribe((event) => { + if (shouldForward(event)) callback(event); + }); + }, close: async () => { handler.dispose(); runtime.dispose(); diff --git a/apps/ade-cli/src/tuiClient/feedback.ts b/apps/ade-cli/src/tuiClient/feedback.ts new file mode 100644 index 000000000..530717561 --- /dev/null +++ b/apps/ade-cli/src/tuiClient/feedback.ts @@ -0,0 +1,125 @@ +import type { FeedbackCategory, FeedbackDraftInput, FeedbackSubmission } from "../../../desktop/src/shared/types/feedback"; +import type { LaneSummary } from "../../../desktop/src/shared/types/lanes"; +import type { ProjectLaunchContext } from "./types"; + +export type FeedbackFormFieldName = + | "category" + | "summary" + | "details" + | "expected" + | "actual" + | "environment" + | "additionalContext"; + +export type FeedbackFormValues = Partial>; + +export type FeedbackFormField = { + name: FeedbackFormFieldName; + label: string; + required?: boolean; + placeholder?: string; + initialValue?: string; +}; + +function clean(value: string | null | undefined): string { + return (value ?? "").trim(); +} + +function appendContext(...parts: Array): string { + return parts.map(clean).filter(Boolean).join("\n\n"); +} + +export function normalizeFeedbackCategory(value: string | null | undefined): FeedbackCategory { + const normalized = clean(value).toLowerCase(); + if (normalized === "feature") return "feature"; + if (normalized === "enhancement") return "enhancement"; + if (normalized === "question") return "question"; + return "bug"; +} + +export function buildFeedbackEnvironment(project: ProjectLaunchContext, lane: LaneSummary | null): string { + return [ + `Source: ADE Code`, + `Project: ${project.projectRoot}`, + `Workspace: ${project.workspaceRoot}`, + lane ? `Lane: ${lane.name}` : null, + lane ? `Branch: ${lane.branchRef}` : null, + lane ? `Worktree: ${lane.worktreePath}` : null, + ].filter(Boolean).join("\n"); +} + +export function feedbackFormFields(environment: string): FeedbackFormField[] { + return [ + { name: "category", label: "Category", initialValue: "bug", placeholder: "bug, feature, enhancement, or question" }, + { name: "summary", label: "Summary", required: true, placeholder: "What changed, broke, or should be improved?" }, + { name: "details", label: "Details", placeholder: "Steps to reproduce, use case, or question context" }, + { name: "expected", label: "Expected", placeholder: "What should have happened or what guidance would help?" }, + { name: "actual", label: "Actual", placeholder: "What happened instead, or the current behavior" }, + { name: "environment", label: "Environment", initialValue: environment, placeholder: "ADE version, lane, OS, or runtime context" }, + { name: "additionalContext", label: "Additional", placeholder: "Links, screenshots, workaround notes, or anything else useful" }, + ]; +} + +export function buildFeedbackDraftInput(values: FeedbackFormValues): FeedbackDraftInput { + const category = normalizeFeedbackCategory(values.category); + const summary = clean(values.summary); + const details = clean(values.details); + const expected = clean(values.expected); + const actual = clean(values.actual); + const environment = clean(values.environment); + const additionalContext = clean(values.additionalContext); + + if (category === "feature" || category === "enhancement") { + return { + category, + summary, + useCase: details, + proposedSolution: expected, + alternativesConsidered: actual, + additionalContext: appendContext(additionalContext, environment ? `Environment:\n${environment}` : null), + }; + } + + if (category === "question") { + return { + category: "question", + summary, + context: details, + expectedGuidance: expected, + additionalContext: appendContext( + additionalContext, + actual ? `Current behavior:\n${actual}` : null, + environment ? `Environment:\n${environment}` : null, + ), + }; + } + + return { + category: "bug", + summary, + stepsToReproduce: details, + expectedBehavior: expected, + actualBehavior: actual, + environment, + additionalContext, + }; +} + +export function feedbackSubmissionNotice(submission: FeedbackSubmission): { + text: string; + tone: "success" | "error"; +} { + if (submission.status === "posted") { + return { + text: submission.issueNumber + ? `Feedback posted as GitHub issue #${submission.issueNumber}.` + : "Feedback posted to GitHub.", + tone: "success", + }; + } + + return { + text: submission.error ?? "GitHub posting failed. The draft was saved in the feedback hub.", + tone: "error", + }; +} diff --git a/apps/ade-cli/src/tuiClient/format.ts b/apps/ade-cli/src/tuiClient/format.ts index 16c0519ac..f70091c79 100644 --- a/apps/ade-cli/src/tuiClient/format.ts +++ b/apps/ade-cli/src/tuiClient/format.ts @@ -1,9 +1,13 @@ import path from "node:path"; +import { Lexer, type Token, type Tokens } from "marked"; import type { AgentChatEventEnvelope, AgentChatSessionSummary } from "../../../desktop/src/shared/types/chat"; import type { LaneSummary } from "../../../desktop/src/shared/types/lanes"; +import { highlightCode, type HighlightedToken } from "./highlightCache"; import { glyphFor } from "./theme"; import type { LocalNotice } from "./types"; +export type { HighlightedToken } from "./highlightCache"; + function singleLine(value: unknown, max = 96): string { const text = (() => { if (typeof value === "string") return value; @@ -64,7 +68,7 @@ export type AssistantMarkdownBlock = | { kind: "bullet"; text: string } | { kind: "numbered"; number: string; text: string } | { kind: "quote"; text: string } - | { kind: "code"; language?: string; lines: string[] } + | { kind: "code"; language?: string; lines: string[]; tokens?: HighlightedToken[][] } | { kind: "table"; headers: string[]; rows: string[][] } | { kind: "hr" }; @@ -78,34 +82,101 @@ export type InlineRun = { dim?: boolean; }; -const INLINE_TOKEN_RE = /(`[^`\n]+`)|(\*\*[^*\n]+\*\*)|(__[^_\n]+__)|(\*[^*\s][^*\n]*\*)|(_[^_\s][^_\n]*_)|(\[[^\]\n]+\]\([^)\n]+\))/; +type InlineFlags = { bold?: boolean; italic?: boolean; code?: boolean; link?: boolean }; + +function pushInlineRun(runs: InlineRun[], text: string, flags: InlineFlags): void { + if (!text.length) return; + const last = runs[runs.length - 1]; + const sameFlags = last + && (last.bold ?? false) === (flags.bold ?? false) + && (last.italic ?? false) === (flags.italic ?? false) + && (last.code ?? false) === (flags.code ?? false) + && (last.link ?? false) === (flags.link ?? false); + if (sameFlags && last) { + last.text += text; + return; + } + const run: InlineRun = { text }; + if (flags.bold) run.bold = true; + if (flags.italic) run.italic = true; + if (flags.code) run.code = true; + if (flags.link) run.link = true; + runs.push(run); +} + +function walkInlineTokens(tokens: Token[], runs: InlineRun[], flags: InlineFlags): void { + for (const token of tokens) { + switch (token.type) { + case "text": { + const text = token as Tokens.Text; + if (text.tokens && text.tokens.length) walkInlineTokens(text.tokens, runs, flags); + else pushInlineRun(runs, text.text, flags); + break; + } + case "escape": + pushInlineRun(runs, (token as Tokens.Escape).text, flags); + break; + case "codespan": + pushInlineRun(runs, (token as Tokens.Codespan).text, { ...flags, code: true }); + break; + case "strong": { + const strong = token as Tokens.Strong; + walkInlineTokens(strong.tokens, runs, { ...flags, bold: true }); + break; + } + case "em": { + const em = token as Tokens.Em; + walkInlineTokens(em.tokens, runs, { ...flags, italic: true }); + break; + } + case "del": { + const del = token as Tokens.Del; + walkInlineTokens(del.tokens, runs, flags); + break; + } + case "link": { + const link = token as Tokens.Link; + const child: InlineRun[] = []; + if (link.tokens && link.tokens.length) walkInlineTokens(link.tokens, child, { ...flags, link: true }); + else pushInlineRun(child, link.text, { ...flags, link: true }); + for (const c of child) runs.push(c); + break; + } + case "image": + pushInlineRun(runs, (token as Tokens.Image).text, flags); + break; + case "br": + pushInlineRun(runs, "\n", flags); + break; + case "html": + pushInlineRun(runs, (token as Tokens.HTML).text, flags); + break; + default: { + const generic = token as Tokens.Generic; + if (generic.tokens && generic.tokens.length) { + walkInlineTokens(generic.tokens, runs, flags); + } else if (typeof generic.text === "string") { + pushInlineRun(runs, generic.text, flags); + } else if (typeof generic.raw === "string") { + pushInlineRun(runs, generic.raw, flags); + } + break; + } + } + } +} export function parseInlineRuns(text: string): InlineRun[] { - const runs: InlineRun[] = []; - let remaining = text; - while (remaining.length) { - const match = INLINE_TOKEN_RE.exec(remaining); - if (!match) { - runs.push({ text: remaining }); - break; - } - const start = match.index; - if (start > 0) runs.push({ text: remaining.slice(0, start) }); - const token = match[0]; - if (match[1]) { - runs.push({ text: token.slice(1, -1), code: true }); - } else if (match[2] || match[3]) { - runs.push({ text: token.slice(2, -2), bold: true }); - } else if (match[4] || match[5]) { - runs.push({ text: token.slice(1, -1), italic: true }); - } else if (match[6]) { - const linkMatch = /^\[([^\]]+)\]\(([^)]+)\)$/.exec(token); - runs.push({ text: linkMatch?.[1] ?? token, link: true }); - } - remaining = remaining.slice(start + token.length); + if (!text.length) return [{ text: "" }]; + try { + const tokens = Lexer.lexInline(text); + const runs: InlineRun[] = []; + walkInlineTokens(tokens, runs, {}); + if (!runs.length) return [{ text }]; + return runs; + } catch { + return [{ text }]; } - if (!runs.length) runs.push({ text }); - return runs; } export function chatEventLineId(envelope: AgentChatEventEnvelope, index = 0): string { @@ -131,120 +202,178 @@ function multiLine(value: unknown, maxLines = 18): string { return renderObject(value, maxLines); } -function isMarkdownBoundary(line: string): boolean { - const trimmed = line.trim(); - return ( - trimmed.length === 0 - || /^```/.test(trimmed) - || /^#{1,6}\s+/.test(trimmed) - || /^>\s?/.test(trimmed) - || /^[-*+]\s+/.test(trimmed) - || /^\d+[.)]\s+/.test(trimmed) - || /^([-*_])(?:\s*\1){2,}\s*$/.test(trimmed) - ); -} - -export function parseAssistantMarkdown(text: string): AssistantMarkdownBlock[] { - const sourceLines = text.replace(/\r\n/g, "\n").split("\n"); - const blocks: AssistantMarkdownBlock[] = []; - let paragraph: string[] = []; - - const flushParagraph = () => { - const value = paragraph.join(" ").replace(/\s+/g, " ").trim(); - if (value.length) blocks.push({ kind: "paragraph", text: value }); - paragraph = []; - }; - - for (let index = 0; index < sourceLines.length; index += 1) { - const line = sourceLines[index] ?? ""; - const trimmed = line.trim(); - - if (!trimmed.length) { - flushParagraph(); +function flattenInlineTokensToText(tokens: Token[] | undefined): string { + if (!tokens || !tokens.length) return ""; + let out = ""; + for (const token of tokens) { + const generic = token as Tokens.Generic; + // Preserve inline markdown markers so the downstream re-tokenization in + // parseInlineRuns can re-create the styled runs (codespan, bold, italic). + // Without this, code spans like `LaneRuntimeBar` get flattened to plain + // text and lose their violet inline-code styling in the renderer. + if (token.type === "codespan") { + const text = typeof generic.text === "string" ? generic.text : ""; + out += `\`${text}\``; continue; } - - const fence = /^```([\w.+-]*)\s*$/.exec(trimmed); - if (fence) { - flushParagraph(); - const codeLines: string[] = []; - const language = fence[1]?.trim() || undefined; - index += 1; - for (; index < sourceLines.length; index += 1) { - const codeLine = sourceLines[index] ?? ""; - if (/^```\s*$/.test(codeLine.trim())) break; - codeLines.push(codeLine.replace(/\s+$/g, "")); - } - blocks.push({ kind: "code", ...(language ? { language } : {}), lines: codeLines }); - continue; - } - - const heading = /^(#{1,6})\s+(.+)$/.exec(trimmed); - if (heading) { - flushParagraph(); - blocks.push({ kind: "heading", level: heading[1]?.length ?? 1, text: heading[2]?.trim() ?? "" }); - continue; - } - - if (trimmed.startsWith("|") && trimmed.endsWith("|")) { - const next = sourceLines[index + 1]?.trim() ?? ""; - const isSeparator = /^\|[\s:|-]+\|$/.test(next) && /-/.test(next); - if (isSeparator) { - const parseRow = (raw: string): string[] => raw - .replace(/^\|/, "") - .replace(/\|$/, "") - .split("|") - .map((cell) => cell.trim()); - const headers = parseRow(trimmed); - const bodyRows: string[][] = []; - index += 2; - while (index < sourceLines.length) { - const candidate = sourceLines[index]?.trim() ?? ""; - if (!candidate.startsWith("|") || !candidate.endsWith("|")) break; - bodyRows.push(parseRow(candidate)); - index += 1; - } - index -= 1; - flushParagraph(); - blocks.push({ kind: "table", headers, rows: bodyRows }); - continue; - } + if (token.type === "strong") { + out += `**${flattenInlineTokensToText(generic.tokens)}**`; + continue; } - - if (/^([-*_])(?:\s*\1){2,}\s*$/.test(trimmed)) { - flushParagraph(); - blocks.push({ kind: "hr" }); + if (token.type === "em") { + out += `*${flattenInlineTokensToText(generic.tokens)}*`; continue; } - - const quote = /^>\s?(.*)$/.exec(trimmed); - if (quote) { - flushParagraph(); - blocks.push({ kind: "quote", text: quote[1]?.trim() ?? "" }); + if (token.type === "link") { + // Preserve only the visible label — the URL is dropped because the TUI + // doesn't render hyperlinks distinctly today. + out += flattenInlineTokensToText(generic.tokens) || (typeof generic.text === "string" ? generic.text : ""); continue; } + if (generic.tokens && generic.tokens.length) { + out += flattenInlineTokensToText(generic.tokens); + } else if (typeof generic.text === "string") { + out += generic.text; + } else if (typeof generic.raw === "string") { + out += generic.raw; + } + } + return out; +} - const bullet = /^[-*+]\s+(.+)$/.exec(trimmed); - if (bullet) { - flushParagraph(); - blocks.push({ kind: "bullet", text: bullet[1]?.trim() ?? "" }); - continue; +function blockTokenToText(token: Token): string { + const generic = token as Tokens.Generic; + if (generic.tokens && generic.tokens.length) { + const inline = flattenInlineTokensToText(generic.tokens); + if (inline.length) return inline; + } + if (typeof generic.text === "string") return generic.text; + if (typeof generic.raw === "string") return generic.raw.trim(); + return ""; +} + +function listItemText(item: Tokens.ListItem): string { + if (item.tokens && item.tokens.length) { + const parts: string[] = []; + for (const child of item.tokens) { + if (child.type === "list") continue; + parts.push(blockTokenToText(child)); } + const joined = parts.filter((p) => p.length).join(" "); + if (joined.length) return joined; + } + return item.text ?? ""; +} - const numbered = /^(\d+)[.)]\s+(.+)$/.exec(trimmed); - if (numbered) { - flushParagraph(); - blocks.push({ kind: "numbered", number: numbered[1] ?? "1", text: numbered[2]?.trim() ?? "" }); - continue; +function stripTaskCheckboxPrefix(value: string): string { + return value.replace(/^\[([ xX])\]\s*/, ""); +} + +function appendListBlocks(list: Tokens.List, blocks: AssistantMarkdownBlock[]): void { + let counter = typeof list.start === "number" && list.start > 0 ? list.start : 1; + for (const item of list.items) { + const checkboxPrefix = item.task ? (item.checked ? "[x] " : "[ ] ") : ""; + const rawText = listItemText(item).replace(/\s+/g, " ").trim(); + const baseText = item.task ? stripTaskCheckboxPrefix(rawText) : rawText; + const text = `${checkboxPrefix}${baseText}`.trim(); + if (list.ordered) { + blocks.push({ kind: "numbered", number: String(counter), text }); + counter += 1; + } else { + blocks.push({ kind: "bullet", text }); + } + if (item.tokens) { + for (const child of item.tokens) { + if (child.type === "list") { + appendListBlocks(child as Tokens.List, blocks); + } + } } + } +} - if (paragraph.length && isMarkdownBoundary(sourceLines[index - 1] ?? "")) { - flushParagraph(); +function appendBlockTokens(tokens: Token[], blocks: AssistantMarkdownBlock[]): void { + for (const token of tokens) { + switch (token.type) { + case "space": + break; + case "heading": { + const heading = token as Tokens.Heading; + const text = flattenInlineTokensToText(heading.tokens) || heading.text; + blocks.push({ kind: "heading", level: heading.depth, text: text.trim() }); + break; + } + case "paragraph": { + const paragraph = token as Tokens.Paragraph; + const text = flattenInlineTokensToText(paragraph.tokens) || paragraph.text; + const normalized = text.replace(/\s+/g, " ").trim(); + if (normalized.length) blocks.push({ kind: "paragraph", text: normalized }); + break; + } + case "code": { + const code = token as Tokens.Code; + const language = code.lang?.trim() || undefined; + const rawLines = code.text.split("\n"); + const block: AssistantMarkdownBlock = language + ? { kind: "code", language, lines: rawLines } + : { kind: "code", lines: rawLines }; + const highlighted = highlightCode(code.text, language); + if (language && highlighted.length === rawLines.length) { + (block as { tokens?: HighlightedToken[][] }).tokens = highlighted; + } + blocks.push(block); + break; + } + case "blockquote": { + const quote = token as Tokens.Blockquote; + const text = flattenInlineTokensToText(quote.tokens) || quote.text; + blocks.push({ kind: "quote", text: text.replace(/\s+/g, " ").trim() }); + break; + } + case "list": + appendListBlocks(token as Tokens.List, blocks); + break; + case "hr": + blocks.push({ kind: "hr" }); + break; + case "table": { + const table = token as Tokens.Table; + const headers = table.header.map((cell) => (flattenInlineTokensToText(cell.tokens) || cell.text).trim()); + const rows = table.rows.map((row) => row.map((cell) => (flattenInlineTokensToText(cell.tokens) || cell.text).trim())); + blocks.push({ kind: "table", headers, rows }); + break; + } + case "html": { + const html = token as Tokens.HTML; + const text = html.text.trim(); + if (text.length) blocks.push({ kind: "paragraph", text }); + break; + } + case "text": { + const text = token as Tokens.Text; + const value = flattenInlineTokensToText(text.tokens) || text.text; + const normalized = value.replace(/\s+/g, " ").trim(); + if (normalized.length) blocks.push({ kind: "paragraph", text: normalized }); + break; + } + default: { + const generic = token as Tokens.Generic; + if (generic.tokens && generic.tokens.length) appendBlockTokens(generic.tokens, blocks); + break; + } } - paragraph.push(trimmed); } +} - flushParagraph(); +export function parseAssistantMarkdown(text: string): AssistantMarkdownBlock[] { + if (!text.length) return []; + const blocks: AssistantMarkdownBlock[] = []; + try { + const tokens = Lexer.lex(text.replace(/\r\n/g, "\n")); + appendBlockTokens(tokens, blocks); + } catch { + // Marked lexer is robust; on the off chance it throws, fall through. + } if (!blocks.length && text.trim().length) { blocks.push({ kind: "paragraph", text: text.trim() }); } @@ -624,11 +753,15 @@ function headerSpeakerKey(header: string | undefined): string { } function smartConcat(prev: string, next: string): string { + // Codex/Claude stream assistant text as small chunks that may break mid-word + // (e.g., "Consolid" + "ated", or "complete" + "."). Joining with a forced + // space corrupted words and inserted spaces before punctuation. Streaming + // chunks already include the whitespace they intend — direct concat is + // correct. If the model wants a space or newline between fragments, it sends + // one explicitly. if (!prev) return next; if (!next) return prev; - if (/\s$/.test(prev) || /^\s/.test(next)) return `${prev}${next}`; - if (/\n$/.test(prev) || /^\n/.test(next)) return `${prev}${next}`; - return `${prev} ${next}`; + return `${prev}${next}`; } function coalesceLines(lines: RenderedChatLine[]): RenderedChatLine[] { diff --git a/apps/ade-cli/src/tuiClient/heartbeat.ts b/apps/ade-cli/src/tuiClient/heartbeat.ts index 23abf064d..2fa6eb34c 100644 --- a/apps/ade-cli/src/tuiClient/heartbeat.ts +++ b/apps/ade-cli/src/tuiClient/heartbeat.ts @@ -9,6 +9,10 @@ export type TuiHeartbeat = { readCount: () => number; }; +export type TuiHeartbeatOptions = { + beforeSignalExit?: (signal: NodeJS.Signals) => void | Promise; +}; + const EXIT_CODES_BY_SIGNAL: Partial> = { SIGHUP: 129, SIGINT: 130, @@ -16,8 +20,10 @@ const EXIT_CODES_BY_SIGNAL: Partial> = { }; const EXIT_SIGNALS = Object.keys(EXIT_CODES_BY_SIGNAL) as NodeJS.Signals[]; const activeHeartbeatCleanups = new Set<() => void>(); +const activeSignalExitHooks = new Set<(signal: NodeJS.Signals) => void | Promise>(); const signalHandlers = new Map void>(); let processHandlersRegistered = false; +let signalExitInProgress = false; function safeUnlink(filePath: string): void { try { @@ -79,8 +85,19 @@ function ensureProcessHandlers(): void { process.once("exit", onProcessExit); for (const signal of EXIT_SIGNALS) { const handler = () => { + if (signalExitInProgress) return; + signalExitInProgress = true; + const hooks = Array.from(activeSignalExitHooks); cleanupActiveHeartbeats(); - process.exit(EXIT_CODES_BY_SIGNAL[signal] ?? 1); + void Promise.race([ + Promise.allSettled(hooks.map((hook) => hook(signal))), + new Promise((resolve) => { + const timeout = setTimeout(resolve, 1_000); + timeout.unref?.(); + }), + ]).finally(() => { + process.exit(EXIT_CODES_BY_SIGNAL[signal] ?? 1); + }); }; signalHandlers.set(signal, handler); process.once(signal, handler); @@ -98,10 +115,13 @@ function removeProcessHandlersIfIdle(): void { processHandlersRegistered = false; } -export function startTuiHeartbeat(projectRoot: string): TuiHeartbeat { +export function startTuiHeartbeat(projectRoot: string, options: TuiHeartbeatOptions = {}): TuiHeartbeat { const dir = path.join(projectRoot, ".ade", "cache", "ade-code", "clients"); fs.mkdirSync(dir, { recursive: true }); const filePath = path.join(dir, `${process.pid}.json`); + if (options.beforeSignalExit) { + activeSignalExitHooks.add(options.beforeSignalExit); + } const startedAt = new Date().toISOString(); const write = () => { try { @@ -126,6 +146,9 @@ export function startTuiHeartbeat(projectRoot: string): TuiHeartbeat { stopped = true; clearInterval(timer); activeHeartbeatCleanups.delete(stop); + if (options.beforeSignalExit) { + activeSignalExitHooks.delete(options.beforeSignalExit); + } safeUnlink(filePath); removeProcessHandlersIfIdle(); }; diff --git a/apps/ade-cli/src/tuiClient/highlightCache.ts b/apps/ade-cli/src/tuiClient/highlightCache.ts new file mode 100644 index 000000000..c3659c8dc --- /dev/null +++ b/apps/ade-cli/src/tuiClient/highlightCache.ts @@ -0,0 +1,294 @@ +import hljs from "highlight.js/lib/core"; +import typescript from "highlight.js/lib/languages/typescript"; +import javascript from "highlight.js/lib/languages/javascript"; +import python from "highlight.js/lib/languages/python"; +import rust from "highlight.js/lib/languages/rust"; +import go from "highlight.js/lib/languages/go"; +import swift from "highlight.js/lib/languages/swift"; +import bash from "highlight.js/lib/languages/bash"; +import json from "highlight.js/lib/languages/json"; +import yaml from "highlight.js/lib/languages/yaml"; +import markdown from "highlight.js/lib/languages/markdown"; +import xml from "highlight.js/lib/languages/xml"; +import css from "highlight.js/lib/languages/css"; +import sql from "highlight.js/lib/languages/sql"; + +hljs.registerLanguage("typescript", typescript); +hljs.registerLanguage("javascript", javascript); +hljs.registerLanguage("python", python); +hljs.registerLanguage("rust", rust); +hljs.registerLanguage("go", go); +hljs.registerLanguage("swift", swift); +hljs.registerLanguage("bash", bash); +hljs.registerLanguage("json", json); +hljs.registerLanguage("yaml", yaml); +hljs.registerLanguage("markdown", markdown); +hljs.registerLanguage("xml", xml); +hljs.registerLanguage("css", css); +hljs.registerLanguage("sql", sql); + +export type HighlightCategory = + | "keyword" + | "string" + | "number" + | "comment" + | "function" + | "type" + | "variable" + | "tag" + | "meta"; + +export type HighlightedToken = { + text: string; + category?: HighlightCategory; + bold?: boolean; + italic?: boolean; +}; + +const LANGUAGE_ALIASES: Record = { + ts: "typescript", + tsx: "typescript", + js: "javascript", + jsx: "javascript", + py: "python", + rs: "rust", + sh: "bash", + shell: "bash", + zsh: "bash", + yml: "yaml", + md: "markdown", + html: "xml", + htm: "xml", +}; + +const CATEGORY_MAP: Record = { + "hljs-keyword": "keyword", + "hljs-built_in": "keyword", + "hljs-literal": "keyword", + "hljs-string": "string", + "hljs-regexp": "string", + "hljs-template-string": "string", + "hljs-template-variable": "string", + "hljs-number": "number", + "hljs-comment": "comment", + "hljs-doctag": "comment", + "hljs-quote": "comment", + "hljs-function": "function", + "hljs-title": "function", + "hljs-title_function": "function", + "hljs-title_function_": "function", + "hljs-class": "type", + "hljs-type": "type", + "hljs-title_class": "type", + "hljs-title_class_": "type", + "hljs-variable": "variable", + "hljs-attr": "variable", + "hljs-property": "variable", + "hljs-params": "variable", + "hljs-attribute": "variable", + "hljs-tag": "tag", + "hljs-name": "tag", + "hljs-symbol": "tag", + "hljs-selector-tag": "tag", + "hljs-selector-class": "tag", + "hljs-selector-id": "tag", + "hljs-meta": "meta", + "hljs-link": "meta", + "hljs-strong": "meta", + "hljs-emphasis": "meta", +}; + +const MAX_ENTRIES = 500; +const MAX_BYTES = 50 * 1024 * 1024; + +type CacheValue = { + tokens: HighlightedToken[][]; + bytes: number; +}; + +const cache = new Map(); +let totalBytes = 0; + +function fnv1a32(input: string): number { + let hash = 0x811c9dc5; + for (let i = 0; i < input.length; i += 1) { + hash ^= input.charCodeAt(i); + hash = (hash + ((hash << 1) + (hash << 4) + (hash << 7) + (hash << 8) + (hash << 24))) >>> 0; + } + return hash >>> 0; +} + +function resolveLanguage(language: string | undefined): string | undefined { + if (!language) return undefined; + const normalized = language.trim().toLowerCase(); + if (!normalized) return undefined; + const alias = LANGUAGE_ALIASES[normalized] ?? normalized; + if (hljs.getLanguage(alias)) return alias; + return undefined; +} + +function decodeEntities(value: string): string { + return value.replace(/&(amp|lt|gt|quot|#x27|#39|apos);/g, (_, entity) => { + switch (entity) { + case "amp": return "&"; + case "lt": return "<"; + case "gt": return ">"; + case "quot": return '"'; + case "#x27": + case "#39": + case "apos": + return "'"; + default: return _; + } + }); +} + +function categoryForClass(className: string): HighlightCategory | undefined { + if (CATEGORY_MAP[className]) return CATEGORY_MAP[className]; + const dotted = className.replace(/_/g, "."); + if (CATEGORY_MAP[dotted]) return CATEGORY_MAP[dotted]; + // hljs sometimes nests via `hljs-title hljs-function`; we map first known class. + const parts = className.split(/\s+/); + for (const part of parts) { + if (CATEGORY_MAP[part]) return CATEGORY_MAP[part]; + } + return undefined; +} + +type Span = { className?: string; text: string }; + +function parseHljsHtml(html: string): Span[] { + const spans: Span[] = []; + const stack: Array = []; + let i = 0; + let buffer = ""; + + const flush = (): void => { + if (!buffer.length) return; + const top = stack[stack.length - 1]; + spans.push({ className: top ? `hljs-${top}` : undefined, text: decodeEntities(buffer) }); + buffer = ""; + }; + + while (i < html.length) { + const ch = html[i] ?? ""; + if (ch === "<") { + // tag start + const closeIdx = html.indexOf(">", i + 1); + if (closeIdx === -1) { + buffer += html.slice(i); + i = html.length; + break; + } + const tag = html.slice(i + 1, closeIdx); + flush(); + if (tag.startsWith("/")) { + stack.pop(); + } else { + // + const classMatch = /class="([^"]+)"/.exec(tag); + const className = classMatch?.[1]; + const category = className ? categoryForClass(className) : undefined; + stack.push(category); + } + i = closeIdx + 1; + } else { + buffer += ch; + i += 1; + } + } + flush(); + return spans; +} + +function splitSpansByLine(spans: Span[]): HighlightedToken[][] { + const lines: HighlightedToken[][] = [[]]; + for (const span of spans) { + const text = span.text; + if (!text.length) continue; + const parts = text.split("\n"); + for (let p = 0; p < parts.length; p += 1) { + const piece = parts[p] ?? ""; + if (piece.length) { + const last = lines[lines.length - 1]!; + const category = span.className ? categoryForClass(span.className) : undefined; + last.push(category ? { text: piece, category } : { text: piece }); + } + if (p < parts.length - 1) lines.push([]); + } + } + return lines; +} + +function plainLines(code: string): HighlightedToken[][] { + return code.split("\n").map((line) => (line.length ? [{ text: line }] : [])); +} + +function estimateBytes(tokens: HighlightedToken[][]): number { + let bytes = 0; + for (const line of tokens) { + for (const token of line) { + bytes += token.text.length * 2 + 16; + } + bytes += 16; + } + return bytes; +} + +export function highlightCode(code: string, language: string | undefined): HighlightedToken[][] { + const resolved = resolveLanguage(language); + const key = `${fnv1a32(code).toString(36)}:${code.length}:${resolved ?? "_plain"}`; + const hit = cache.get(key); + if (hit) { + // Refresh LRU position. + cache.delete(key); + cache.set(key, hit); + return hit.tokens; + } + + let tokens: HighlightedToken[][]; + if (!resolved) { + tokens = plainLines(code); + } else { + try { + const html = hljs.highlight(code, { language: resolved, ignoreIllegals: true }).value; + const spans = parseHljsHtml(html); + tokens = splitSpansByLine(spans); + // Guard against highlight.js producing a different line count. + const expected = code.split("\n").length; + if (tokens.length !== expected) { + // Fall back to plain rather than risk misalignment. + tokens = plainLines(code); + } + } catch { + tokens = plainLines(code); + } + } + + const bytes = estimateBytes(tokens); + cache.set(key, { tokens, bytes }); + totalBytes += bytes; + + while (cache.size > MAX_ENTRIES || totalBytes > MAX_BYTES) { + const oldest = cache.keys().next().value; + if (oldest === undefined) break; + const evicted = cache.get(oldest); + cache.delete(oldest); + if (evicted) totalBytes -= evicted.bytes; + if (cache.size === 0) { + totalBytes = 0; + break; + } + } + + return tokens; +} + +export function __clearHighlightCacheForTests(): void { + cache.clear(); + totalBytes = 0; +} + +export function __getHighlightCacheStatsForTests(): { entries: number; bytes: number } { + return { entries: cache.size, bytes: totalBytes }; +} diff --git a/apps/ade-cli/src/tuiClient/keybindings/index.ts b/apps/ade-cli/src/tuiClient/keybindings/index.ts index 408a94304..44ad36a17 100644 --- a/apps/ade-cli/src/tuiClient/keybindings/index.ts +++ b/apps/ade-cli/src/tuiClient/keybindings/index.ts @@ -49,6 +49,7 @@ const SUPPORTED_ACTION_VALUES = [ "chat:fastMode", "chat:thinkingToggle", "chat:submit", + "chat:launchBackground", "chat:newline", "chat:new-line", "chat:undo", diff --git a/apps/ade-cli/src/tuiClient/planMode.ts b/apps/ade-cli/src/tuiClient/planMode.ts new file mode 100644 index 000000000..f587b23c2 --- /dev/null +++ b/apps/ade-cli/src/tuiClient/planMode.ts @@ -0,0 +1,26 @@ +import type { AgentChatEventEnvelope } from "../../../desktop/src/shared/types/chat"; +import type { AdeCodeModelState } from "./types"; + +export function isPlanMode(modelState: AdeCodeModelState): boolean { + switch (modelState.provider) { + case "claude": + return modelState.claudePermissionMode === "plan" || modelState.interactionMode === "plan"; + case "codex": + return modelState.codexApprovalPolicy === "on-request" && modelState.codexSandbox === "read-only"; + case "opencode": + return modelState.opencodePermissionMode === "plan"; + case "droid": + return modelState.droidPermissionMode === "read-only"; + case "cursor": + return modelState.cursorModeId === "plan"; + default: + return false; + } +} + +export function hasFirstUserMessage(events: AgentChatEventEnvelope[]): boolean { + for (const envelope of events) { + if ((envelope.event as { type?: string } | null)?.type === "user_message") return true; + } + return false; +} diff --git a/apps/ade-cli/src/tuiClient/spinTick.tsx b/apps/ade-cli/src/tuiClient/spinTick.tsx index 3f1f44a50..aa95b9b15 100644 --- a/apps/ade-cli/src/tuiClient/spinTick.tsx +++ b/apps/ade-cli/src/tuiClient/spinTick.tsx @@ -6,13 +6,14 @@ const DOT_FRAMES = [". ", ".. ", "...", " "] as const; const SpinTickContext = createContext(0); -export function SpinTickProvider({ children }: { children: React.ReactNode }) { +export function SpinTickProvider({ active = true, children }: { active?: boolean; children: React.ReactNode }) { const [tick, setTick] = useState(0); useEffect(() => { + if (!active) return; const id = setInterval(() => setTick((t) => (t + 1) % 1000), 100); return () => clearInterval(id); - }, []); + }, [active]); return ( diff --git a/apps/ade-cli/src/tuiClient/theme.ts b/apps/ade-cli/src/tuiClient/theme.ts index 93d9d6a97..3a709b551 100644 --- a/apps/ade-cli/src/tuiClient/theme.ts +++ b/apps/ade-cli/src/tuiClient/theme.ts @@ -51,6 +51,10 @@ const REASONING = T4; const NOTICE = T3; const APPROVAL = ATTENTION; +// Plan-mode signal color (used by the prompt border tint, the provider glyph +// tint, and the persistent plan-mode banner when permission mode === plan). +const PLAN_MODE = "#22D3EE"; + export type Tone = RenderedChatLine["tone"]; const TONE_COLORS: Record = { @@ -70,12 +74,15 @@ type ProviderTheme = { label: string; }; +// Provider brand glyphs: closest single-cell unicode approximations of each +// provider's official mark. `✻` (U+273B) is Anthropic's own canonical sparkle +// (same glyph Claude Code uses in its spinner). const PROVIDER_THEME: Record = { - claude: { glyph: "◆", wordmark: "Claude", color: CLAUDE, label: "Claude" }, - codex: { glyph: "◇", wordmark: "Codex", color: CODEX, label: "Codex" }, - cursor: { glyph: "▲", wordmark: "Cursor", color: CURSOR, label: "Cursor" }, - droid: { glyph: "▣", wordmark: "Droid", color: DROID, label: "Droid" }, - opencode: { glyph: "◈", wordmark: "OpenCode", color: OPENCODE, label: "OpenCode" }, + claude: { glyph: "✻", wordmark: "Claude", color: CLAUDE, label: "Claude" }, + codex: { glyph: "❋", wordmark: "Codex", color: CODEX, label: "Codex" }, + cursor: { glyph: "▰", wordmark: "Cursor", color: CURSOR, label: "Cursor" }, + droid: { glyph: "◉", wordmark: "Droid", color: DROID, label: "Droid" }, + opencode: { glyph: "▲", wordmark: "OpenCode", color: OPENCODE, label: "OpenCode" }, }; const FALLBACK_PROVIDER: ProviderTheme = { glyph: "•", wordmark: "Agent", color: T4, label: "Agent" }; @@ -176,6 +183,42 @@ export const theme = { // Misc tool: TOOL, + planMode: PLAN_MODE, + + // Code highlighting (highlight.js token category → terminal color) + // Inline `code` uses violet to match the desktop's purple chip styling. + codeInline: "#A78BFA", + codeKeyword: "#C792EA", + codeString: "#C3E88D", + codeNumber: "#F78C6C", + codeComment: "#676E95", + codeFunction: "#82AAFF", + codeType: "#FFCB6B", + codeVariable: "#EEFFFF", + codeTag: "#F07178", + codeMeta: "#6B6A7A", + + // Subagent / file badges + subagentRail: "#A78BFA", + fileBadge: { + tsx: "#3178C6", + ts: "#3178C6", + jsx: "#F7DF1E", + js: "#F7DF1E", + json: "#5C5C5C", + md: "#519ABA", + py: "#3776AB", + rs: "#CE412B", + go: "#00ADD8", + swift: "#FA7343", + sh: "#89E051", + yaml: "#CB171E", + yml: "#CB171E", + html: "#E34F26", + css: "#1572B6", + sql: "#336791", + default: "#6B6A7A", + }, }, tone(tone: Tone): string { return TONE_COLORS[tone] ?? T1; diff --git a/apps/ade-cli/src/tuiClient/types.ts b/apps/ade-cli/src/tuiClient/types.ts index 9d8659ea3..29ca23b60 100644 --- a/apps/ade-cli/src/tuiClient/types.ts +++ b/apps/ade-cli/src/tuiClient/types.ts @@ -18,6 +18,7 @@ import type { PendingInputRequest, } from "../../../desktop/src/shared/types/chat"; import type { LaneSummary } from "../../../desktop/src/shared/types/lanes"; +import type { BufferedEvent } from "../eventBuffer"; export type RuntimeMode = "attached" | "embedded"; @@ -52,6 +53,10 @@ export type AdeCodeConnection = { action(domain: string, action: string, args?: Record): Promise; actionList(domain: string, action: string, argsList: unknown[]): Promise; onChatEvent(callback: (event: AgentChatEventEnvelope) => void): () => void; + subscribeRuntimeEvents( + args: { category?: BufferedEvent["category"] | null; cursor?: number; limit?: number }, + callback: (event: BufferedEvent) => void, + ): Promise<() => void>; close(): Promise; }; @@ -138,8 +143,6 @@ export type RightPaneContent = } | { kind: "details"; title: string; body: string } | { kind: "diff"; title: string; files: Array<{ path: string; additions?: number; deletions?: number; body?: string }> } - | { kind: "models"; models: AgentChatModelInfo[]; activeModelId: string | null } - | { kind: "effort"; efforts: string[]; activeEffort: string | null } | { kind: "subagents"; tab: SubagentPaneTab; snapshots: SubagentSnapshot[]; provider: AdeCodeProvider } | { kind: "new-chat-setup"; @@ -147,18 +150,10 @@ export type RightPaneContent = laneLabel: string; rows: SetupPaneRow[]; } - | { - kind: "model-setup"; - rows: SetupPaneRow[]; - providerRows: ProviderReadinessRow[]; - activeProvider: AdeCodeProvider; - checkedAt: string | null; - desktopAttached: boolean; - } | { kind: "form"; title: string; - command: "new-lane" | "rename" | "pr-open"; + command: "new-lane" | "rename" | "pr-open" | "feedback"; fields: Array<{ name: string; label: string; diff --git a/apps/desktop/package-lock.json b/apps/desktop/package-lock.json index d3e12d64d..3506745cd 100644 --- a/apps/desktop/package-lock.json +++ b/apps/desktop/package-lock.json @@ -33,7 +33,9 @@ "@types/ssh2": "^1.15.5", "@wize-logic/nodejs-rfb": "^4.2.0", "@xterm/addon-fit": "^0.11.0", + "@xterm/addon-serialize": "^0.14.0", "@xterm/addon-webgl": "^0.19.0", + "@xterm/headless": "^6.0.0", "@xterm/xterm": "^6.0.0", "@xyflow/react": "^12.5.0", "antd": "^6.3.5", @@ -7744,12 +7746,27 @@ "integrity": "sha512-jYcgT6xtVYhnhgxh3QgYDnnNMYTcf8ElbxxFzX0IZo+vabQqSPAjC3c1wJrKB5E19VwQei89QCiZZP86DCPF7g==", "license": "MIT" }, + "node_modules/@xterm/addon-serialize": { + "version": "0.14.0", + "resolved": "https://registry.npmjs.org/@xterm/addon-serialize/-/addon-serialize-0.14.0.tgz", + "integrity": "sha512-uteyTU1EkrQa2Ux6P/uFl2fzmXI46jy5uoQMKEOM0fKTyiW7cSn0WrFenHm5vO5uEXX/GpwW/FgILvv3r0WbkA==", + "license": "MIT" + }, "node_modules/@xterm/addon-webgl": { "version": "0.19.0", "resolved": "https://registry.npmjs.org/@xterm/addon-webgl/-/addon-webgl-0.19.0.tgz", "integrity": "sha512-b3fMOsyLVuCeNJWxolACEUED0vm7qC0cy4wRvf3oURSzDTYVQiGPhTnhWZwIHdvC48Y+oLhvYXnY4XDXPoJo6A==", "license": "MIT" }, + "node_modules/@xterm/headless": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/@xterm/headless/-/headless-6.0.0.tgz", + "integrity": "sha512-5Yj1QINYCyzrZtf8OFIHi47iQtI+0qYFPHmouEfG8dHNxbZ9Tb9YGSuLcsEwj9Z+OL75GJqPyJbyoFer80a2Hw==", + "license": "MIT", + "workspaces": [ + "addons/*" + ] + }, "node_modules/@xterm/xterm": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-6.0.0.tgz", diff --git a/apps/desktop/package.json b/apps/desktop/package.json index ca8ed8974..a2ad7c074 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -73,7 +73,9 @@ "@types/ssh2": "^1.15.5", "@wize-logic/nodejs-rfb": "^4.2.0", "@xterm/addon-fit": "^0.11.0", + "@xterm/addon-serialize": "^0.14.0", "@xterm/addon-webgl": "^0.19.0", + "@xterm/headless": "^6.0.0", "@xterm/xterm": "^6.0.0", "@xyflow/react": "^12.5.0", "antd": "^6.3.5", diff --git a/apps/desktop/scripts/audit-chat-model-runtime.mjs b/apps/desktop/scripts/audit-chat-model-runtime.mjs index 1b1eebb8a..5b1516faf 100644 --- a/apps/desktop/scripts/audit-chat-model-runtime.mjs +++ b/apps/desktop/scripts/audit-chat-model-runtime.mjs @@ -802,12 +802,6 @@ async function runSmokeCase(client, testCase, laneId, options) { out.error = serializeError(error); } finally { if (out.created?.id) { - try { - await withTimeout(window.ade.agentChat.dispose({ sessionId: out.created.id }), "agentChat.dispose"); - cleanup.push({ action: "dispose", ok: true }); - } catch (error) { - cleanup.push({ action: "dispose", ok: false, error: serializeError(error) }); - } try { await withTimeout(window.ade.agentChat.delete({ sessionId: out.created.id }), "agentChat.delete"); cleanup.push({ action: "delete", ok: true }); diff --git a/apps/desktop/src/main/main.ts b/apps/desktop/src/main/main.ts index 3147378c5..4d4e80b85 100644 --- a/apps/desktop/src/main/main.ts +++ b/apps/desktop/src/main/main.ts @@ -5,7 +5,7 @@ import path from "node:path"; import { pathToFileURL } from "node:url"; import type * as NodePty from "node-pty"; type NodePtyType = typeof NodePty; -import { isAdeMcpNamedPipePath } from "../shared/adeMcpIpc"; +import { isAdeRuntimeNamedPipePath } from "../shared/adeRuntimeIpc"; import { registerIpc } from "./services/ipc/registerIpc"; import { createFileLogger } from "./services/logging/logger"; import { initPerfRunFromEnv } from "./services/perf/perfLog"; @@ -1977,6 +1977,9 @@ app.whenReady().then(async () => { logger, projectRoot, appDataDir: app.getPath("userData"), + credentialStore: new EncryptedFileCredentialStore({ + secretsDir: machineAdeLayout.secretsDir, + }), }); const projectScaffoldService = createProjectScaffoldService({ @@ -4004,7 +4007,7 @@ app.whenReady().then(async () => { : `${envSocketOverride}.${Buffer.from(normalizeProjectRoot(projectRoot)).toString("base64url").slice(0, 8)}` : adePaths.socketPath; - if (!isAdeMcpNamedPipePath(rpcSocketPath)) { + if (!isAdeRuntimeNamedPipePath(rpcSocketPath)) { try { fs.unlinkSync(rpcSocketPath); } catch {} @@ -4305,6 +4308,9 @@ app.whenReady().then(async () => { logger, projectRoot: normalizedRoot, appDataDir: app.getPath("userData"), + credentialStore: new EncryptedFileCredentialStore({ + secretsDir: machineAdeLayout.secretsDir, + }), }); const dormantProjectScaffoldService = createProjectScaffoldService({ logger, @@ -4430,7 +4436,7 @@ app.whenReady().then(async () => { // ignore } try { - if (ctx.rpcSocketPath && !isAdeMcpNamedPipePath(ctx.rpcSocketPath)) { + if (ctx.rpcSocketPath && !isAdeRuntimeNamedPipePath(ctx.rpcSocketPath)) { fs.unlinkSync(ctx.rpcSocketPath); } } catch { diff --git a/apps/desktop/src/main/services/adeActions/registry.test.ts b/apps/desktop/src/main/services/adeActions/registry.test.ts index 5f4f69058..84268a7ae 100644 --- a/apps/desktop/src/main/services/adeActions/registry.test.ts +++ b/apps/desktop/src/main/services/adeActions/registry.test.ts @@ -156,6 +156,11 @@ describe("ADE_ACTION_ALLOWLIST shape", () => { expect(actions).toContain("listSnapshots"); }); + it("exposes lane.listDeleteProgress for runtime-backed lane delete progress recovery", () => { + const actions = ADE_ACTION_ALLOWLIST.lane ?? []; + expect(actions).toContain("listDeleteProgress"); + }); + it("exposes pr.listPrsByLane for runtime-backed drawer PR pills", () => { const actions = ADE_ACTION_ALLOWLIST.pr ?? []; expect(actions).toContain("listPrsByLane"); diff --git a/apps/desktop/src/main/services/adeActions/registry.ts b/apps/desktop/src/main/services/adeActions/registry.ts index ff3e35661..a921a657f 100644 --- a/apps/desktop/src/main/services/adeActions/registry.ts +++ b/apps/desktop/src/main/services/adeActions/registry.ts @@ -214,6 +214,7 @@ export const ADE_ACTION_ALLOWLIST: Partial; + preview(args?: unknown): Promise; write(args?: unknown): unknown; + resize(args?: unknown): unknown; signal(args?: unknown): unknown; activeForChat(args?: unknown): unknown; }; @@ -3497,9 +3495,15 @@ function buildTerminalDomainService(runtime: AdeRuntime): TerminalDomainService read(args) { return runtime.ptyService.readTerminal(args as Parameters[0]); }, + preview(args) { + return runtime.ptyService.previewTerminal(args as Parameters[0]); + }, write(args) { return runtime.ptyService.writeTerminal(args as Parameters[0]); }, + resize(args) { + return runtime.ptyService.resizeTerminal(args as Parameters[0]); + }, signal(args) { return runtime.ptyService.signalTerminal(args as Parameters[0]); }, diff --git a/apps/desktop/src/main/services/ai/tools/ctoOperatorTools.test.ts b/apps/desktop/src/main/services/ai/tools/ctoOperatorTools.test.ts index 0aaf8b384..bd2cfd797 100644 --- a/apps/desktop/src/main/services/ai/tools/ctoOperatorTools.test.ts +++ b/apps/desktop/src/main/services/ai/tools/ctoOperatorTools.test.ts @@ -81,8 +81,6 @@ function buildDeps(overrides: Partial = {}): CtoOperatorToo ]), sendChatMessage: vi.fn().mockResolvedValue(undefined), interruptChat: vi.fn().mockResolvedValue(undefined), - resumeChat: vi.fn().mockResolvedValue(baseSession), - disposeChat: vi.fn().mockResolvedValue(undefined), ensureCtoSession: vi.fn().mockResolvedValue({ ...baseSession, id: "cto-session" }), ...overrides, }; @@ -101,8 +99,6 @@ describe("createCtoOperatorTools", () => { expect(toolKeys).toContain("spawnChat"); expect(toolKeys).toContain("sendChatMessage"); expect(toolKeys).toContain("interruptChat"); - expect(toolKeys).toContain("resumeChat"); - expect(toolKeys).toContain("endChat"); expect(toolKeys).toContain("getChatStatus"); expect(toolKeys).toContain("getChatTranscript"); @@ -333,34 +329,6 @@ describe("createCtoOperatorTools", () => { expect(result).toMatchObject({ success: true, sessionId: "chat-1" }); }); - it("resumes a chat session and returns navigation", async () => { - const deps = buildDeps(); - const tools = createCtoOperatorTools(deps); - - const result = await (tools.resumeChat as any).execute({ - sessionId: "chat-1", - }); - - expect(deps.resumeChat).toHaveBeenCalledWith({ sessionId: "chat-1" }); - expect(result).toMatchObject({ - success: true, - sessionId: "chat-1", - navigation: expect.objectContaining({ surface: "work" }), - }); - }); - - it("ends a chat session", async () => { - const deps = buildDeps(); - const tools = createCtoOperatorTools(deps); - - const result = await (tools.endChat as any).execute({ - sessionId: "chat-1", - }); - - expect(deps.disposeChat).toHaveBeenCalledWith({ sessionId: "chat-1" }); - expect(result).toMatchObject({ success: true, sessionId: "chat-1" }); - }); - it("gets chat status and returns not found for missing sessions", async () => { const deps = buildDeps({ getChatStatus: vi.fn().mockResolvedValue(null), diff --git a/apps/desktop/src/main/services/ai/tools/ctoOperatorTools.ts b/apps/desktop/src/main/services/ai/tools/ctoOperatorTools.ts index 39658e723..f0889819e 100644 --- a/apps/desktop/src/main/services/ai/tools/ctoOperatorTools.ts +++ b/apps/desktop/src/main/services/ai/tools/ctoOperatorTools.ts @@ -161,8 +161,6 @@ export interface CtoOperatorToolDeps { }) => string[]; sendChatMessage: (args: AgentChatSendArgs) => Promise; interruptChat: (args: AgentChatInterruptArgs) => Promise; - resumeChat: (args: { sessionId: string }) => Promise; - disposeChat: (args: { sessionId: string }) => Promise; ensureCtoSession: (args: { laneId: string; modelId?: string | null; @@ -862,46 +860,6 @@ export function createCtoOperatorTools(deps: CtoOperatorToolDeps): Record { - try { - const session = await deps.resumeChat({ sessionId }); - return { - success: true, - sessionId: session.id, - laneId: session.laneId, - status: session.status, - ...buildNavigationPayload(buildNavigationSuggestion({ - surface: "work", - laneId: session.laneId, - sessionId: session.id, - })), - }; - } catch (error) { - return { success: false, error: getErrorMessage(error) }; - } - }, - }); - - tools.endChat = tool({ - description: "End and archive an ADE chat session.", - inputSchema: z.object({ - sessionId: z.string().trim().min(1), - }), - execute: async ({ sessionId }) => { - try { - await deps.disposeChat({ sessionId }); - return { success: true, sessionId }; - } catch (error) { - return { success: false, error: getErrorMessage(error) }; - } - }, - }); - tools.getChatStatus = tool({ description: "Get the current status for an ADE chat session.", inputSchema: z.object({ diff --git a/apps/desktop/src/main/services/ai/tools/systemPrompt.test.ts b/apps/desktop/src/main/services/ai/tools/systemPrompt.test.ts index 4956165cc..8d6008179 100644 --- a/apps/desktop/src/main/services/ai/tools/systemPrompt.test.ts +++ b/apps/desktop/src/main/services/ai/tools/systemPrompt.test.ts @@ -302,7 +302,7 @@ describe("buildCodingAgentSystemPrompt", () => { expect(result).toContain("## Operating Loop"); expect(result).toContain("## ADE CLI"); expect(result).toContain("only normal reason to skip ADE CLI"); - expect(result).toContain("only supported from ADE desktop chats"); + expect(result).toContain("can be driven from ADE CLI sessions"); expect(result).toContain("ade help ios-sim"); expect(result).toContain("preview-render --source "); expect(result).toContain("## Editing Rules"); diff --git a/apps/desktop/src/main/services/chat/agentChatService.suggestLaneName.test.ts b/apps/desktop/src/main/services/chat/agentChatService.suggestLaneName.test.ts index 19be43dd9..c0fa94095 100644 --- a/apps/desktop/src/main/services/chat/agentChatService.suggestLaneName.test.ts +++ b/apps/desktop/src/main/services/chat/agentChatService.suggestLaneName.test.ts @@ -298,7 +298,10 @@ function createMockSessionService() { } as any; } -function createMockProjectConfigService() { +function createMockProjectConfigService(options: { titleGenerationEnabled?: boolean } = {}) { + const sessionIntelligence = typeof options.titleGenerationEnabled === "boolean" + ? { titles: { enabled: options.titleGenerationEnabled } } + : {}; return { get: vi.fn(() => ({ effective: { @@ -308,7 +311,7 @@ function createMockProjectConfigService() { inProcess: { mode: "edit" }, }, chat: {}, - sessionIntelligence: {}, + sessionIntelligence, }, }, })), @@ -387,11 +390,11 @@ function createMockIssueInventoryService() { } as any; } -function createService() { +function createService(options: { titleGenerationEnabled?: boolean } = {}) { const logger = createLogger(); const laneService = createMockLaneService(); const sessionService = createMockSessionService(); - const projectConfigService = createMockProjectConfigService(); + const projectConfigService = createMockProjectConfigService(options); const issueInventoryService = createMockIssueInventoryService(); const transcriptsDir = path.join(tmpRoot, "transcripts"); fs.mkdirSync(transcriptsDir, { recursive: true }); @@ -546,6 +549,28 @@ describe("suggestLaneNameFromPrompt", () => { ); }); + it("uses the explicit fallback name when title generation is disabled", async () => { + vi.mocked(detectAllAuth).mockResolvedValue([ + { type: "cli-subscription" as any, cli: "claude", authenticated: true, path: "/usr/bin/claude", verified: true }, + ]); + vi.mocked(runOpenCodeTextPrompt).mockResolvedValue({ + text: "Login Bug Fix", + inputTokens: 10, + outputTokens: 5, + } as any); + + const { service } = createService({ titleGenerationEnabled: false }); + const result = await service.suggestLaneNameFromPrompt({ + prompt: "Fix the authentication login failure in the dashboard", + modelId: "anthropic/claude-haiku-4-5", + laneId: "lane-1", + fallbackName: "chat-20260514-010203", + }); + + expect(result).toBe("chat-20260514-010203"); + expect(runOpenCodeTextPrompt).not.toHaveBeenCalled(); + }); + it("uses AI-generated name when the model runtime succeeds", async () => { vi.mocked(detectAllAuth).mockResolvedValue([ { type: "cli-subscription" as any, cli: "claude", authenticated: true, path: "/usr/bin/claude", verified: true }, diff --git a/apps/desktop/src/main/services/chat/agentChatService.test.ts b/apps/desktop/src/main/services/chat/agentChatService.test.ts index 26718ae48..edb88e9f0 100644 --- a/apps/desktop/src/main/services/chat/agentChatService.test.ts +++ b/apps/desktop/src/main/services/chat/agentChatService.test.ts @@ -623,11 +623,7 @@ function makeDefaultClaudeSession() { query: { interrupt: vi.fn(async () => undefined), setPermissionMode: vi.fn(async () => undefined), - setMcpServers: vi.fn(async () => undefined), - mcpServerStatus: vi.fn(async () => []), - reconnectMcpServer: vi.fn(async () => undefined), - toggleMcpServer: vi.fn(async () => undefined), - reloadPlugins: vi.fn(async () => ({ commands: [], agents: [], plugins: [], mcpServers: [], error_count: 0 })), + reloadPlugins: vi.fn(async () => ({ commands: [], agents: [], plugins: [], error_count: 0 })), supportedCommands: vi.fn(async () => []), }, }; @@ -761,42 +757,6 @@ function bridgeClaudeSessionToQuery(sessionHandle: any, prompt: unknown) { } return undefined; }), - setMcpServers: vi.fn(async (servers: unknown) => { - if (typeof session.setMcpServers === "function") { - return session.setMcpServers(servers); - } - if (typeof session.query?.setMcpServers === "function") { - return session.query.setMcpServers(servers); - } - return undefined; - }), - mcpServerStatus: vi.fn(async () => { - if (typeof session.mcpServerStatus === "function") { - return session.mcpServerStatus(); - } - if (typeof session.query?.mcpServerStatus === "function") { - return session.query.mcpServerStatus(); - } - return []; - }), - reconnectMcpServer: vi.fn(async (serverName: string) => { - if (typeof session.reconnectMcpServer === "function") { - return session.reconnectMcpServer(serverName); - } - if (typeof session.query?.reconnectMcpServer === "function") { - return session.query.reconnectMcpServer(serverName); - } - return undefined; - }), - toggleMcpServer: vi.fn(async (serverName: string, enabled: boolean) => { - if (typeof session.toggleMcpServer === "function") { - return session.toggleMcpServer(serverName, enabled); - } - if (typeof session.query?.toggleMcpServer === "function") { - return session.query.toggleMcpServer(serverName, enabled); - } - return undefined; - }), reloadPlugins: vi.fn(async () => { if (typeof session.reloadPlugins === "function") { return session.reloadPlugins(); @@ -804,7 +764,7 @@ function bridgeClaudeSessionToQuery(sessionHandle: any, prompt: unknown) { if (typeof session.query?.reloadPlugins === "function") { return session.query.reloadPlugins(); } - return { commands: [], agents: [], plugins: [], mcpServers: [], error_count: 0 }; + return { commands: [], agents: [], plugins: [], error_count: 0 }; }), applyFlagSettings: vi.fn(async (settings: unknown) => { if (typeof session.applyFlagSettings === "function") { @@ -846,7 +806,6 @@ function bridgeClaudeSessionToQuery(sessionHandle: any, prompt: unknown) { gridRows: [], model: "", memoryFiles: [], - mcpTools: [], }; }), rewindFiles: vi.fn(async (userMessageId: string, options?: { dryRun?: boolean }) => { @@ -1703,7 +1662,7 @@ describe("createAgentChatService", () => { ]); }); - it("passes ADE MCP servers upfront and keeps project setting source enabled", async () => { + it("disables Claude MCP while keeping project setting source enabled", async () => { fs.writeFileSync(path.join(tmpRoot, ".mcp.json"), JSON.stringify({ mcpServers: { projectTools: { @@ -1733,17 +1692,17 @@ describe("createAgentChatService", () => { }); const opts = vi.mocked(claudeSdkCreateSessionCompat).mock.calls[0]?.[0] as { - mcpServers?: Record; + managedSettings?: Record; settingSources?: string[]; } | undefined; + expect(opts).toBeTruthy(); expect(opts?.settingSources).toEqual(expect.arrayContaining(["project"])); - expect(opts?.mcpServers).toEqual(expect.objectContaining({ - ade: expect.objectContaining({ - type: "stdio", - args: expect.arrayContaining(["--headless", "--role", "agent", "mcp"]), - }), - })); - expect(opts?.mcpServers?.projectTools).toBeUndefined(); + expect(opts).not.toHaveProperty("mcpServers"); + expect(opts?.managedSettings).toMatchObject({ + allowedMcpServers: [], + allowManagedMcpServersOnly: true, + strictPluginOnlyCustomization: ["mcp"], + }); }); it("passes Claude subprocess spawns through the reaper", async () => { @@ -4285,60 +4244,6 @@ describe("createAgentChatService", () => { }); }); - describe("Claude MCP status", () => { - it("normalizes MCP status from the live Claude query", async () => { - const mcpServerStatus = vi.fn(async () => [ - { - name: "ade", - status: "connected", - scope: "project", - config: { type: "stdio", command: "ade", args: ["mcp"] }, - tools: [ - { - name: "list_tasks", - description: "List tasks", - annotations: { readOnly: true }, - }, - ], - }, - { - name: "broken", - status: "failed", - error: "connection refused", - }, - ]); - vi.mocked(claudeSdkCreateSessionCompat).mockReturnValue({ - ...makeDefaultClaudeSession(), - mcpServerStatus, - }); - const { service } = createService(); - const session = await service.createSession({ - laneId: "lane-1", - provider: "claude", - model: "sonnet", - }); - - await service.sendMessage({ sessionId: session.id, text: "hello" }); - const statuses = await service.getClaudeMcpStatus({ sessionId: session.id }); - - expect(mcpServerStatus).toHaveBeenCalled(); - expect(statuses).toEqual([ - expect.objectContaining({ - name: "ade", - status: "connected", - scope: "project", - config: { type: "stdio", command: "ade", args: ["mcp"] }, - tools: [expect.objectContaining({ name: "list_tasks", readOnly: true })], - }), - expect.objectContaining({ - name: "broken", - status: "failed", - error: "connection refused", - }), - ]); - }); - }); - describe("Claude context usage", () => { it("normalizes used and free context categories against the full context window", async () => { const getContextUsage = vi.fn(async () => ({ @@ -4353,7 +4258,6 @@ describe("createAgentChatService", () => { gridRows: [], model: "claude-sonnet", memoryFiles: [], - mcpTools: [], })); vi.mocked(claudeSdkCreateSessionCompat).mockReturnValue({ ...makeDefaultClaudeSession(), @@ -4407,13 +4311,12 @@ describe("createAgentChatService", () => { }); it("reloads plugins through the live Claude query", async () => { - const reloadPlugins = vi.fn(async () => ({ - plugins: [{ name: "review-plugin", path: "/tmp/review-plugin" }], - commands: [{ name: "review-plugin:audit", description: "Audit" }], - agents: [{ name: "reviewer", description: "Review code" }], - mcpServers: [{ name: "ade", status: "connected" }], - error_count: 0, - })); + const reloadPlugins = vi.fn(async () => ({ + plugins: [{ name: "review-plugin", path: "/tmp/review-plugin" }], + commands: [{ name: "review-plugin:audit", description: "Audit" }], + agents: [{ name: "reviewer", description: "Review code" }], + error_count: 0, + })); vi.mocked(claudeSdkCreateSessionCompat).mockReturnValue({ ...makeDefaultClaudeSession(), reloadPlugins, @@ -4430,12 +4333,11 @@ describe("createAgentChatService", () => { expect(reloadPlugins).toHaveBeenCalled(); expect(result).toEqual(expect.objectContaining({ - plugins: [expect.objectContaining({ name: "review-plugin", path: "/tmp/review-plugin" })], - commands: [expect.objectContaining({ name: "review-plugin:audit", description: "Audit" })], - agents: [expect.objectContaining({ name: "reviewer", description: "Review code" })], - mcpServers: [expect.objectContaining({ name: "ade", status: "connected" })], - errorCount: 0, - })); + plugins: [expect.objectContaining({ name: "review-plugin", path: "/tmp/review-plugin" })], + commands: [expect.objectContaining({ name: "review-plugin:audit", description: "Audit" })], + agents: [expect.objectContaining({ name: "reviewer", description: "Review code" })], + errorCount: 0, + })); expect(service.getSlashCommands({ sessionId: session.id })).toEqual(expect.arrayContaining([ expect.objectContaining({ name: "/review-plugin:audit", description: "Audit" }), ])); @@ -5975,6 +5877,65 @@ describe("createAgentChatService", () => { ); }); + it("emits only one user_message for a Codex send while turn/start is delayed", async () => { + const events: AgentChatEventEnvelope[] = []; + mockState.delayedCodexMethods.add("turn/start"); + const { service } = createService({ + onEvent: (event: AgentChatEventEnvelope) => { + events.push(event); + }, + }); + + const session = await service.createSession({ + laneId: "lane-1", + provider: "codex", + model: "gpt-5.4", + }); + + const sendPromise = service.sendMessage({ + sessionId: session.id, + text: "Fix the duplicate first message render.", + }, { awaitDispatch: true }); + + await waitForEvent( + events, + (event): event is AgentChatEventEnvelope => + event.event.type === "user_message" + && event.event.text === "Fix the duplicate first message render.", + ); + expect(events.filter((event) => event.event.type === "user_message")).toHaveLength(1); + + mockState.emitCodexPayload({ + jsonrpc: "2.0", + method: "turn/started", + params: { + turn: { + id: "turn-1", + status: "in_progress", + }, + }, + }); + mockState.emitCodexPayload({ + jsonrpc: "2.0", + method: "item/agentMessage/delta", + params: { + turnId: "turn-1", + delta: "Checking the renderer path.", + }, + }); + + mockState.flushCodexResponses(); + await sendPromise; + + await waitForEvent( + events, + (event): event is AgentChatEventEnvelope => + event.event.type === "text" + && event.event.text === "Checking the renderer path.", + ); + expect(events.filter((event) => event.event.type === "user_message")).toHaveLength(1); + }); + it("ignores unsolicited Codex turn notifications when no turn is active", async () => { const events: Array<{ type: string; turnId?: string; text?: string }> = []; const { service } = createService({ @@ -6721,6 +6682,103 @@ describe("createAgentChatService", () => { expect(completedResults).toHaveLength(0); }); + it("emits Codex subagent events for current collabAgentToolCall app-server items", async () => { + const events: AgentChatEventEnvelope[] = []; + const { service } = createService({ + onEvent: (event: AgentChatEventEnvelope) => events.push(event), + }); + + const session = await service.createSession({ + laneId: "lane-1", + provider: "codex", + model: "gpt-5.4", + }); + + await service.sendMessage({ + sessionId: session.id, + text: "Run parallel repository scans.", + }, { awaitDispatch: true }); + await waitForEvent( + events, + (event): event is AgentChatEventEnvelope & { + event: Extract; + } => + event.event.type === "status" + && event.event.turnStatus === "started" + && event.event.turnId === "turn-1", + ); + + mockState.emitCodexPayload({ + jsonrpc: "2.0", + method: "item/started", + params: { + turnId: "turn-1", + item: { + id: "collab-1", + type: "collabAgentToolCall", + tool: "spawnAgent", + status: "inProgress", + senderThreadId: "thread-1", + receiverThreadIds: ["agent-thread-1"], + prompt: "Inspect the shared chat renderer", + agentsStates: {}, + }, + }, + }); + + expect(events).toEqual(expect.arrayContaining([ + expect.objectContaining({ + event: expect.objectContaining({ + type: "subagent_started", + taskId: "agent-thread-1", + description: "Inspect the shared chat renderer", + turnId: "turn-1", + }), + }), + ])); + expect( + service.listSubagents({ sessionId: session.id }).find((snapshot) => snapshot.taskId === "agent-thread-1")?.status, + ).toBe("running"); + + mockState.emitCodexPayload({ + jsonrpc: "2.0", + method: "item/completed", + params: { + turnId: "turn-1", + item: { + id: "collab-2", + type: "collabAgentToolCall", + tool: "wait", + status: "completed", + senderThreadId: "thread-1", + receiverThreadIds: ["agent-thread-1"], + prompt: null, + agentsStates: { + "agent-thread-1": { + status: "completed", + message: "Renderer path mapped.", + }, + }, + }, + }, + }); + + expect(events).toEqual(expect.arrayContaining([ + expect.objectContaining({ + event: expect.objectContaining({ + type: "subagent_result", + taskId: "agent-thread-1", + status: "completed", + summary: "Renderer path mapped.", + turnId: "turn-1", + }), + }), + ])); + expect( + service.listSubagents({ sessionId: session.id }).find((snapshot) => snapshot.taskId === "agent-thread-1")?.status, + ).toBe("completed"); + }); + it("does not add Codex cache breakdown tokens to derived totals", async () => { const events: AgentChatEventEnvelope[] = []; const { service } = createService({ @@ -13131,7 +13189,6 @@ describe("createAgentChatService", () => { expect(resumeSession).toHaveBeenCalledWith(expect.objectContaining({ sessionId: "persisted-droid-session-1", cwd: fs.realpathSync(tmpRoot), - mcpServers: expect.any(Array), })); expect(setSessionModel).toHaveBeenCalledWith({ sessionId: "persisted-droid-session-1", diff --git a/apps/desktop/src/main/services/chat/agentChatService.ts b/apps/desktop/src/main/services/chat/agentChatService.ts index 118c777b8..648cbc8db 100644 --- a/apps/desktop/src/main/services/chat/agentChatService.ts +++ b/apps/desktop/src/main/services/chat/agentChatService.ts @@ -1,6 +1,7 @@ import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process"; import { randomUUID } from "node:crypto"; import fs from "node:fs"; +import net from "node:net"; import os from "node:os"; import path from "node:path"; import readline from "node:readline"; @@ -27,7 +28,6 @@ import type { } from "@anthropic-ai/claude-agent-sdk"; import { buildClaudeV2Message, inferAttachmentMediaType } from "./buildClaudeV2Message"; import { ClaudeInputPump } from "./claudeInputPump"; -import { buildClaudeMcpServers } from "./claudeMcpServers"; import { discoverClaudePluginPaths, discoverClaudePlugins, @@ -90,10 +90,6 @@ import type { AgentChatClaudeOutputStylesArgs, AgentChatClaudePlugin, AgentChatClaudePluginsArgs, - AgentChatClaudeMcpReconnectArgs, - AgentChatClaudeMcpServerStatus, - AgentChatClaudeMcpStatusArgs, - AgentChatClaudeMcpToggleArgs, AgentChatReloadClaudePluginsArgs, AgentChatReloadClaudePluginsResult, AgentChatClaudePermissionMode, @@ -222,6 +218,7 @@ import { resolveAdeLayout } from "../../../shared/adeLayout"; import { ADE_CLI_AGENT_GUIDANCE } from "../../../shared/adeCliGuidance"; import { parseAgentChatTranscript } from "../../../shared/chatTranscript"; import { extractLeadingSlashCommand, isProviderSlashCommandInput } from "../../../shared/chatSlashCommands"; +import { stripAnsi } from "../../utils/ansiStrip"; import type { createMemoryService, Memory } from "../memory/memoryService"; import type { createCtoStateService } from "../cto/ctoStateService"; import type { createWorkerAgentService } from "../cto/workerAgentService"; @@ -380,6 +377,9 @@ type PersistedChatState = { sdkSessionId?: string; forkFromSdkSessionId?: string; providerSessionId?: string; + claudeBackgroundJobShort?: string; + claudeBackgroundResumeSessionId?: string; + claudeBackgroundLogText?: string; /** Cursor SDK agent/run ids for resume across app restarts. */ cursorSdkAgentProtocolVersion?: number; cursorSdkAgentId?: string; @@ -558,7 +558,6 @@ const CODEX_BUILT_IN_SLASH_COMMANDS: AgentChatSlashCommand[] = [ { name: "/goal", description: "Set, show, pause, resume, budget, or clear the thread goal.", source: "local", argumentHint: "[pause|resume|clear|budget |]" }, { name: "/inject", description: "Inject context text into Codex thread history.", source: "local", argumentHint: "" }, { name: "/logout", description: "Sign out of Codex.", source: "sdk" }, - { name: "/mcp", description: "List configured MCP tools.", source: "sdk" }, { name: "/model", description: "Choose the active model and reasoning effort.", source: "sdk" }, { name: "/fast", description: "Toggle Fast mode for supported models.", source: "local", argumentHint: "[on|off|status]" }, { name: "/plan", description: "Switch to plan mode and optionally send a prompt.", source: "local", argumentHint: "[prompt]" }, @@ -593,7 +592,6 @@ const CLAUDE_BUILT_IN_SLASH_COMMANDS: AgentChatSlashCommand[] = [ { name: "/ide", description: "Manage IDE integrations and show status.", source: "sdk" }, { name: "/init", description: "Initialize project with a CLAUDE.md guide.", source: "sdk" }, { name: "/logout", description: "Sign out from Anthropic.", source: "sdk" }, - { name: "/mcp", description: "Manage MCP server connections and OAuth authentication.", source: "sdk" }, { name: "/memory", description: "Edit CLAUDE.md memory files and memory settings.", source: "sdk" }, { name: "/model", description: "Select or change the AI model.", source: "sdk", argumentHint: "[model]" }, { name: "/output-style", description: "List or select the active Claude output style.", source: "sdk", argumentHint: "[style]" }, @@ -629,7 +627,12 @@ function slashCommandKey(value: string): string { } function isDispatchableClaudeSdkSlashCommand(command: { name: string }): boolean { - return command.name !== "/login"; + const key = slashCommandKey(command.name); + return key !== "/login" && key !== "/mcp"; +} + +function isVisibleCodexSlashCommand(command: { name: string }): boolean { + return slashCommandKey(command.name) !== "/mcp"; } type PendingOpenCodeApproval = { @@ -1117,6 +1120,269 @@ function uniqueNonEmpty(values: Array, limit = values return result; } +type ClaudeBackgroundJobState = { + state?: string; + detail?: string; + tempo?: string; + output?: unknown; + intent?: string; + sessionId?: string; + resumeSessionId?: string; + daemonShort?: string; + cwd?: string; + updatedAt?: string; + linkScanPath?: string; +}; + +type ClaudeDaemonRequest = { + proto?: number; + op: string; + short?: string; + text?: string; +}; + +type ClaudeDaemonResponse = { + ok?: boolean; + op?: string; + jobs?: Array>; + error?: string; + code?: string; + short?: string; + alive?: boolean; + present?: boolean; +}; + +function sleepMs(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +function claudeConfigDir(): string { + const configured = process.env.CLAUDE_CONFIG_DIR?.trim(); + return configured?.length ? configured : path.join(os.homedir(), ".claude"); +} + +function claudeBackgroundJobStatePath(short: string): string { + return path.join(claudeConfigDir(), "jobs", short, "state.json"); +} + +function readClaudeBackgroundJobState(short: string | null | undefined): ClaudeBackgroundJobState | null { + const normalizedShort = trimLine(short); + if (!normalizedShort) return null; + try { + const parsed = JSON.parse(fs.readFileSync(claudeBackgroundJobStatePath(normalizedShort), "utf8")) as unknown; + if (!parsed || typeof parsed !== "object") return null; + return parsed as ClaudeBackgroundJobState; + } catch { + return null; + } +} + +function normalizeClaudeBackgroundShort(value: unknown): string | null { + if (typeof value !== "string") return null; + const trimmed = value.trim(); + return /^[A-Za-z0-9_-]{4,64}$/.test(trimmed) ? trimmed : null; +} + +function parseClaudeBackgroundShort(text: string): string | null { + const cleaned = stripAnsi(text); + const backgrounded = cleaned.match(/backgrounded\s*[·•]\s*([A-Za-z0-9_-]{4,64})/i); + if (backgrounded?.[1]) return backgrounded[1]; + const attach = cleaned.match(/\bclaude\s+attach\s+([A-Za-z0-9_-]{4,64})\b/i); + return attach?.[1] ?? null; +} + +function claudeBackgroundStateIsTerminal(state: ClaudeBackgroundJobState | null): boolean { + const normalized = state?.state?.trim().toLowerCase(); + if (!normalized) return false; + return normalized === "done" + || normalized === "completed" + || normalized === "failed" + || normalized === "error" + || normalized === "errored" + || normalized === "stopped" + || normalized === "killed" + || normalized === "crashed"; +} + +function claudeBackgroundStateSucceeded(state: ClaudeBackgroundJobState | null): boolean { + const normalized = state?.state?.trim().toLowerCase(); + return normalized === "done" || normalized === "completed"; +} + +function extractClaudeBackgroundStateText(state: ClaudeBackgroundJobState | null): string { + const transcriptText = extractClaudeBackgroundTranscriptText(state); + if (transcriptText) return transcriptText; + const output = state?.output; + if (typeof output === "string" && output.trim().length) return output.trim(); + if (output && typeof output === "object") { + const record = output as Record; + for (const key of ["summary", "result", "message", "text"]) { + const value = record[key]; + if (typeof value === "string" && value.trim().length) return value.trim(); + } + } + return typeof state?.detail === "string" ? state.detail.trim() : ""; +} + +function sanitizeClaudeBackgroundLog(value: string): string { + return stripAnsi(value) + .replace(/\u001b[78]/g, "") + .replace(/\r\n/g, "\n") + .replace(/\r/g, "\n") + .replace(/\n{4,}/g, "\n\n\n") + .trim(); +} + +function readFileTailUtf8(filePath: string, maxBytes = 512 * 1024): string { + const stat = fs.statSync(filePath); + const start = Math.max(0, stat.size - maxBytes); + const length = stat.size - start; + const fd = fs.openSync(filePath, "r"); + try { + const buffer = Buffer.alloc(length); + fs.readSync(fd, buffer, 0, length, start); + const raw = buffer.toString("utf8"); + if (start === 0) return raw; + const firstNewline = raw.indexOf("\n"); + return firstNewline >= 0 ? raw.slice(firstNewline + 1) : raw; + } finally { + fs.closeSync(fd); + } +} + +function claudeMessageContentText(content: unknown): string | null { + if (typeof content === "string") return content.trim() || null; + if (!Array.isArray(content)) return null; + const parts = content + .map((part) => { + if (!part || typeof part !== "object") return ""; + const record = part as Record; + if (record.type !== "text") return ""; + return typeof record.text === "string" ? record.text : ""; + }) + .filter((part) => part.trim().length > 0); + return parts.length ? parts.join("\n").trim() : null; +} + +function extractClaudeBackgroundTranscriptText(state: ClaudeBackgroundJobState | null): string { + const rawPath = trimLine(state?.linkScanPath); + if (!rawPath) return ""; + const resolvedPath = path.resolve(rawPath); + const configRoot = path.resolve(claudeConfigDir()); + if (!resolvedPath.startsWith(`${configRoot}${path.sep}`)) return ""; + if (!fs.existsSync(resolvedPath)) return ""; + try { + const text = readFileTailUtf8(resolvedPath); + let latest = ""; + for (const line of text.split(/\n+/)) { + if (!line.trim()) continue; + let parsed: unknown; + try { + parsed = JSON.parse(line); + } catch { + continue; + } + if (!parsed || typeof parsed !== "object") continue; + const record = parsed as Record; + if (record.type !== "assistant") continue; + const message = record.message && typeof record.message === "object" + ? record.message as Record + : null; + if (message?.role !== "assistant") continue; + const contentText = claudeMessageContentText(message.content); + if (contentText) latest = contentText; + } + return latest.trim(); + } catch { + return ""; + } +} + +function claudeDaemonBaseDir(): string | null { + try { + const uid = typeof process.getuid === "function" ? process.getuid() : os.userInfo().uid; + return path.join(os.tmpdir(), `cc-daemon-${uid}`); + } catch { + return null; + } +} + +async function sendClaudeDaemonRequest( + socketPath: string, + request: ClaudeDaemonRequest, + timeoutMs = 2_500, +): Promise { + return await new Promise((resolve, reject) => { + const socket = net.createConnection(socketPath); + let settled = false; + let buffer = ""; + const finish = (fn: () => void) => { + if (settled) return; + settled = true; + clearTimeout(timer); + try { socket.destroy(); } catch { /* ignore */ } + fn(); + }; + const timer = setTimeout(() => { + finish(() => reject(new Error("Claude daemon request timed out."))); + }, timeoutMs); + socket.on("connect", () => { + socket.write(`${JSON.stringify({ proto: 1, ...request })}\n`); + }); + socket.on("data", (chunk) => { + buffer += chunk.toString("utf8"); + const newline = buffer.indexOf("\n"); + const raw = (newline >= 0 ? buffer.slice(0, newline) : buffer).trim(); + if (!raw) return; + try { + const parsed = JSON.parse(raw) as ClaudeDaemonResponse; + finish(() => resolve(parsed)); + } catch { + // Wait for more bytes. + } + }); + socket.on("end", () => { + const raw = buffer.trim(); + if (!raw) { + finish(() => reject(new Error("Claude daemon closed without a response."))); + return; + } + try { + const parsed = JSON.parse(raw) as ClaudeDaemonResponse; + finish(() => resolve(parsed)); + } catch (error) { + finish(() => reject(error instanceof Error ? error : new Error(String(error)))); + } + }); + socket.on("error", (error) => { + finish(() => reject(error)); + }); + }); +} + +async function resolveClaudeDaemonControlSocket(): Promise { + const baseDir = claudeDaemonBaseDir(); + if (!baseDir || !fs.existsSync(baseDir)) return null; + let entries; + try { + entries = fs.readdirSync(baseDir, { withFileTypes: true }); + } catch { + return null; + } + for (const entry of entries) { + if (!entry.isDirectory()) continue; + const socketPath = path.join(baseDir, entry.name, "control.sock"); + if (!fs.existsSync(socketPath)) continue; + try { + const pong = await sendClaudeDaemonRequest(socketPath, { op: "ping" }); + if (pong.ok === true) return socketPath; + } catch { + // Try the next candidate. + } + } + return null; +} + type ManagedChatSession = { session: AgentChatSession; transcriptPath: string; @@ -1180,6 +1446,9 @@ type ManagedChatSession = { eventSequence: number; lastActivityTimestamp: number; turnBeforeSha: string | null; + claudeBackgroundJobShort: string | null; + claudeBackgroundResumeSessionId: string | null; + claudeBackgroundLogText: string; }; type AgentChatTranscriptEntry = { @@ -1412,7 +1681,6 @@ const CODEX_REASONING_EFFORTS: Array<{ effort: string; description: string }> = const QUIET_CODEX_NOTIFICATION_METHODS = new Set([ "item/commandExecution/terminalInteraction", - "mcpServer/startupStatus/updated", "remoteControl/status/changed", "thread/archived", "thread/started", @@ -4942,8 +5210,6 @@ export function createAgentChatService(args: { updateChatSession: updateSession, sendChatMessage: sendMessage, interruptChat: interrupt, - resumeChat: resumeSession, - disposeChat: dispose, sessionService, ensureCtoSession: async ({ laneId: requestedLaneId, modelId, reasoningEffort, reuseExisting }) => ensureIdentitySession({ @@ -4975,10 +5241,6 @@ export function createAgentChatService(args: { const getClaudeQueryControl = ( sessionQuery: ClaudeQuery | null | undefined, ): { - setMcpServers?: ClaudeQuery["setMcpServers"]; - mcpServerStatus?: ClaudeQuery["mcpServerStatus"]; - reconnectMcpServer?: ClaudeQuery["reconnectMcpServer"]; - toggleMcpServer?: ClaudeQuery["toggleMcpServer"]; setPermissionMode?: (mode: AgentChatClaudePermissionMode) => Promise; applyFlagSettings?: ClaudeQuery["applyFlagSettings"]; reloadPlugins?: ClaudeQuery["reloadPlugins"]; @@ -4988,10 +5250,6 @@ export function createAgentChatService(args: { interrupt?: () => Promise; } => { return { - setMcpServers: typeof sessionQuery?.setMcpServers === "function" ? sessionQuery.setMcpServers.bind(sessionQuery) : undefined, - mcpServerStatus: typeof sessionQuery?.mcpServerStatus === "function" ? sessionQuery.mcpServerStatus.bind(sessionQuery) : undefined, - reconnectMcpServer: typeof sessionQuery?.reconnectMcpServer === "function" ? sessionQuery.reconnectMcpServer.bind(sessionQuery) : undefined, - toggleMcpServer: typeof sessionQuery?.toggleMcpServer === "function" ? sessionQuery.toggleMcpServer.bind(sessionQuery) : undefined, setPermissionMode: typeof sessionQuery?.setPermissionMode === "function" ? sessionQuery.setPermissionMode.bind(sessionQuery) as (mode: AgentChatClaudePermissionMode) => Promise : undefined, @@ -5837,6 +6095,11 @@ export function createAgentChatService(args: { sessionService.updateMeta({ sessionId: managed.session.id, title, manuallyNamed: false }); managed.manuallyNamed = false; + emitTransientChatEnvelope(managed.session.id, { + type: "session_meta_updated", + title, + manuallyNamed: false, + }); // Sync ADE-generated titles to Codex so the app-server and ADE agree. if (options.syncToRuntime !== false && managed.session.provider === "codex" && managed.session.threadId && managed.runtime?.kind === "codex") { @@ -5860,12 +6123,20 @@ export function createAgentChatService(args: { if (!title) return null; const currentTitle = sessionService.get(managed.session.id)?.title ?? null; - if (currentTitle?.trim() !== title) { + const titleChanged = currentTitle?.trim() !== title; + if (titleChanged) { sessionService.updateMeta({ sessionId: managed.session.id, title, manuallyNamed: false }); } managed.manuallyNamed = false; managed.runtimeTitleAdopted = true; managed.autoTitleStage = "initial"; + if (titleChanged) { + emitTransientChatEnvelope(managed.session.id, { + type: "session_meta_updated", + title, + manuallyNamed: false, + }); + } logger.info("agent_chat.runtime_title_adopted", { sessionId: managed.session.id, provider: managed.session.provider, @@ -6234,7 +6505,10 @@ export function createAgentChatService(args: { const prompt = String(args.prompt ?? "").trim(); const requestedModelId = String(args.modelId ?? "").trim(); const sourceLaneId = String(args.laneId ?? "").trim(); - const fallback = () => fallbackLaneNameFromPrompt(prompt); + const explicitFallback = typeof args.fallbackName === "string" + ? normalizeSuggestedLaneName(args.fallbackName) + : null; + const fallback = () => explicitFallback ?? fallbackLaneNameFromPrompt(prompt); if (!prompt.length) { return fallback(); @@ -6257,6 +6531,7 @@ export function createAgentChatService(args: { if (!availableModels.length) return fallback(); const config = resolveChatConfig(); + if (explicitFallback && config.titleGenerationEnabled === false) return fallback(); const preferredModelId = [ requestedModelId, @@ -6282,7 +6557,7 @@ export function createAgentChatService(args: { title: "ADE lane name from prompt", modelDescriptor: descriptor, system: LANE_NAME_FROM_PROMPT_SYSTEM_PROMPT, - prompt: `User message to parallelize across models:\n${prompt.slice(0, 2000)}`, + prompt: `User message for the new lane:\n${prompt.slice(0, 2000)}`, projectConfig: projectConfigService.get().effective, }); return normalizeSuggestedLaneName(result.text) ?? fallback(); @@ -6569,6 +6844,16 @@ export function createAgentChatService(args: { if (!managed.runtime && !managed.runtimeInvalidated) { try { prevPersisted = readPersistedState(managed.session.id); } catch { /* ignore */ } } + const liveClaudeSdkSessionId = managed.runtime?.kind === "claude" ? managed.runtime.sdkSessionId : null; + const claudeBackgroundResumeSessionId = managed.session.provider === "claude" + ? managed.claudeBackgroundResumeSessionId + ?? prevPersisted?.claudeBackgroundResumeSessionId + ?? prevPersisted?.sdkSessionId + ?? null + : null; + const claudePersistedSdkSessionId = managed.session.provider === "claude" + ? liveClaudeSdkSessionId ?? claudeBackgroundResumeSessionId + : null; const payload: PersistedChatState = { version: 2, sessionId: managed.session.id, @@ -6604,8 +6889,10 @@ export function createAgentChatService(args: { ...(managed.runtime?.kind === "droid" && managed.runtime.acpSessionId ? { acpSessionId: managed.runtime.acpSessionId } : {}), - ...(managed.runtime?.kind === "claude" - ? { sdkSessionId: managed.runtime.sdkSessionId ?? undefined } + ...(managed.session.provider === "claude" && claudePersistedSdkSessionId + ? { sdkSessionId: claudePersistedSdkSessionId } + : managed.runtime?.kind === "claude" + ? { sdkSessionId: managed.runtime.sdkSessionId ?? undefined } : prevPersisted?.sdkSessionId ? { sdkSessionId: prevPersisted.sdkSessionId } : {}), ...(managed.runtime?.kind === "claude" && managed.runtime.forkFromSdkSessionId ? { forkFromSdkSessionId: managed.runtime.forkFromSdkSessionId } @@ -6626,6 +6913,15 @@ export function createAgentChatService(args: { ...(managed.runtime?.kind === "opencode" ? { providerSessionId: managed.runtime.handle.sessionId } : managed.session.provider === "opencode" && prevPersisted?.providerSessionId ? { providerSessionId: prevPersisted.providerSessionId } : {}), + ...(managed.session.provider === "claude" && managed.claudeBackgroundJobShort + ? { claudeBackgroundJobShort: managed.claudeBackgroundJobShort } + : managed.session.provider === "claude" && prevPersisted?.claudeBackgroundJobShort ? { claudeBackgroundJobShort: prevPersisted.claudeBackgroundJobShort } : {}), + ...(managed.session.provider === "claude" && claudeBackgroundResumeSessionId + ? { claudeBackgroundResumeSessionId } + : {}), + ...(managed.session.provider === "claude" && managed.claudeBackgroundLogText + ? { claudeBackgroundLogText: managed.claudeBackgroundLogText.slice(-64_000) } + : managed.session.provider === "claude" && prevPersisted?.claudeBackgroundLogText ? { claudeBackgroundLogText: prevPersisted.claudeBackgroundLogText.slice(-64_000) } : {}), ...(managed.runtime?.kind === "cursor" && managed.runtime.sdkAgentId ? { cursorSdkAgentProtocolVersion: CURSOR_SDK_AGENT_PROTOCOL_VERSION, @@ -6771,6 +7067,19 @@ export function createAgentChatService(args: { const providerSessionId = typeof record.providerSessionId === "string" && record.providerSessionId.trim().length ? record.providerSessionId.trim() : undefined; + const claudeBackgroundJobShort = provider === "claude" + ? normalizeClaudeBackgroundShort(record.claudeBackgroundJobShort) + : null; + const claudeBackgroundResumeSessionId = provider === "claude" + ? ( + typeof record.claudeBackgroundResumeSessionId === "string" && record.claudeBackgroundResumeSessionId.trim().length + ? record.claudeBackgroundResumeSessionId.trim() + : sdkSessionId + ) + : undefined; + const claudeBackgroundLogText = provider === "claude" && typeof record.claudeBackgroundLogText === "string" + ? record.claudeBackgroundLogText.slice(-64_000) + : undefined; const cursorSdkAgentProtocolVersion = typeof record.cursorSdkAgentProtocolVersion === "number" ? record.cursorSdkAgentProtocolVersion : undefined; @@ -6852,6 +7161,9 @@ export function createAgentChatService(args: { ...(sdkSessionId ? { sdkSessionId } : {}), ...(forkFromSdkSessionId ? { forkFromSdkSessionId } : {}), ...(providerSessionId ? { providerSessionId } : {}), + ...(claudeBackgroundJobShort ? { claudeBackgroundJobShort } : {}), + ...(claudeBackgroundResumeSessionId ? { claudeBackgroundResumeSessionId } : {}), + ...(claudeBackgroundLogText ? { claudeBackgroundLogText } : {}), ...(cursorSdkAgentProtocolVersion ? { cursorSdkAgentProtocolVersion } : {}), ...(cursorSdkAgentId ? { cursorSdkAgentId } : {}), ...(cursorSdkRunId ? { cursorSdkRunId } : {}), @@ -7157,6 +7469,31 @@ export function createAgentChatService(args: { } }; + /** + * Push a transient envelope to renderer subscribers without persisting it + * to the transcript or session preview. Used for purely UI-state patches + * (e.g. session title changes) that the renderer applies to its local + * `sessions[]` array — there's no value in replaying them on reload. + */ + const emitTransientChatEnvelope = (sessionId: string, event: AgentChatEvent): void => { + const envelope: AgentChatEventEnvelope = { + sessionId, + timestamp: nowIso(), + event, + }; + onEvent?.(envelope); + for (const subscriber of eventSubscribers) { + try { + subscriber(envelope); + } catch (error) { + logger.warn("agent_chat.event_subscriber_failed", { + sessionId, + error: error instanceof Error ? error.message : String(error), + }); + } + } + }; + const flushBufferedText = (managed: ManagedChatSession): void => { const buffered = managed.bufferedText; if (!buffered) return; @@ -7892,6 +8229,9 @@ export function createAgentChatService(args: { eventSequence: 0, lastActivityTimestamp: Date.now(), turnBeforeSha: null, + claudeBackgroundJobShort: persisted?.claudeBackgroundJobShort ?? null, + claudeBackgroundResumeSessionId: persisted?.claudeBackgroundResumeSessionId ?? persisted?.sdkSessionId ?? null, + claudeBackgroundLogText: persisted?.claudeBackgroundLogText ?? "", }; managed.todoItems = readLatestTranscriptTodoItems(managed); normalizeSessionNativePermissionControls(managed.session, resolveChatConfig()); @@ -9679,6 +10019,488 @@ export function createAgentChatService(args: { } }; + type ClaudeBackgroundCliResult = { + exitCode: number | null; + signal: NodeJS.Signals | null; + stdout: string; + stderr: string; + }; + + const runClaudeBackgroundCliCommand = async ( + managed: ManagedChatSession, + cliArgs: string[], + timeoutMs = 45_000, + ): Promise => { + const env = buildAgentRuntimeEnv(managed); + const resolved = resolveClaudeCodeExecutable({ env }); + const invocation = resolveCliSpawnInvocation(resolved.path, cliArgs, env); + logger.debug("agent_chat.claude_background_cli_spawn", { + sessionId: managed.session.id, + source: resolved.source, + path: resolved.path, + argCount: cliArgs.length, + }); + return await new Promise((resolve, reject) => { + const child = spawn(invocation.command, invocation.args, { + cwd: managed.laneWorktreePath, + env, + windowsHide: true, + windowsVerbatimArguments: invocation.windowsVerbatimArguments, + stdio: ["ignore", "pipe", "pipe"], + }); + let stdout = ""; + let stderr = ""; + let settled = false; + const timer = setTimeout(() => { + if (settled) return; + terminateProcessTree(child, "SIGTERM"); + setTimeout(() => terminateProcessTree(child, "SIGKILL"), 2_000).unref?.(); + settled = true; + reject(new Error("Claude background command timed out.")); + }, timeoutMs); + child.stdout?.on("data", (chunk) => { stdout += chunk.toString("utf8"); }); + child.stderr?.on("data", (chunk) => { stderr += chunk.toString("utf8"); }); + child.on("error", (error) => { + if (settled) return; + settled = true; + clearTimeout(timer); + reject(error); + }); + child.on("close", (exitCode, signal) => { + if (settled) return; + settled = true; + clearTimeout(timer); + resolve({ exitCode, signal, stdout, stderr }); + }); + }); + }; + + const buildClaudeBackgroundSystemPrompt = (managed: ManagedChatSession): string => { + resolveManagedClaudeOutputStyle(managed); + const projectSlashCommands = (() => { + try { + return discoverClaudeSlashCommands(managed.laneWorktreePath).filter(isDispatchableClaudeSdkSlashCommand); + } catch { + return []; + } + })(); + const projectCommandFiles = projectSlashCommands.filter((cmd) => cmd.source === "command"); + const projectSkillFiles = projectSlashCommands.filter((cmd) => cmd.source === "skill"); + const slashCommandsSection = projectSlashCommands.length + ? [ + "", + "## Project slash commands and skills", + "ADE walks up from the lane worktree to discover `.claude/commands/*.md` (slash commands) and `.claude/skills//SKILL.md` (skills) at every ancestor directory plus `~/.claude/`. Claude Code itself may discover some of these, but ADE also tells you about the full project-visible set here.", + "**User-invoked (`/`):** When the user sends a message that is exactly `/` or `/ `, ADE may pre-expand the file's body and substitute `$ARGUMENTS` before it reaches you. You'll see the expanded instructions, not necessarily the literal `/`.", + "**Mid-sentence reference:** When the user mentions a command/skill mid-sentence, read the file at the path below and follow it.", + "**Autonomous skill use:** If, while working on a task, you decide a discovered skill applies, read its SKILL.md file and follow it as if it had been invoked.", + ...(projectCommandFiles.length ? [ + "", + "Commands (file-backed prompts):", + ...projectCommandFiles.map((cmd) => { + const desc = cmd.description.trim(); + const head = desc.length ? `- ${cmd.name}: ${desc}` : `- ${cmd.name}`; + return `${head}\n file: ${cmd.filePath}`; + }), + ] : []), + ...(projectSkillFiles.length ? [ + "", + "Skills (autonomously usable when relevant):", + ...projectSkillFiles.map((cmd) => { + const desc = cmd.description.trim(); + const head = desc.length ? `- ${cmd.name}: ${desc}` : `- ${cmd.name}`; + return `${head}\n file: ${cmd.filePath}`; + }), + ] : []), + ] + : []; + + return [ + "## Runtime Environment", + "**Runtime:** ADE Work chat is hosting you through the Claude Code CLI background-session supervisor. The user sees an ADE lane chat, not your terminal.", + "**Lifecycle:** Keep working in the background when useful. ADE will keep the transcript and session pointer, and later user messages may arrive by Agent View reply or by resuming your saved conversation.", + "**UI contract:** Do not ask the user to run `claude attach`, `claude logs`, or a resume command. ADE owns those mechanics.", + "", + "## ADE Workspace", + `ADE launched this session in lane worktree: ${managed.laneWorktreePath}.`, + "Read, edit, and run commands only inside that worktree. Do not switch to project root, another lane, or another repo unless ADE explicitly relaunches you there.", + "", + "## ADE Memory", + "Use the ADE CLI (`ade memory search`, `ade memory add`, `ade memory pin`) when you need project memory from a terminal-capable session.", + "**Search first:** Before starting non-trivial work, search memory for relevant conventions, past decisions, or known pitfalls when the CLI is available.", + "**Write sparingly and well:** Only save knowledge a developer joining this project would find useful on their first day. Each memory should be a single actionable insight.", + "GOOD memories: \"Convention: always use snake_case for DB columns\", \"Decision: chose Postgres over Mongo for ACID transactions\", \"Pitfall: CI silently skips tests if file doesn't match *.test.ts\"", + "DO NOT save: file paths, raw error messages without lessons, task progress updates, information derivable from git log or the code itself, obvious patterns already visible in the codebase.", + ...slashCommandsSection, + "", + ADE_CLI_AGENT_GUIDANCE, + ].join("\n"); + }; + + const buildClaudeBackgroundArgs = ( + managed: ManagedChatSession, + promptText: string, + resumeSessionId: string | null, + ): string[] => { + const permissionMode = resolveClaudeTurnPermissionMode(managed); + const model = resolveClaudeCliModel(managed.session.model); + const rowTitle = sessionService.get(managed.session.id)?.title?.trim(); + const cliArgs = [ + "--bg", + "--model", + model, + "--permission-mode", + permissionMode, + "--append-system-prompt", + buildClaudeBackgroundSystemPrompt(managed), + ]; + if (rowTitle) { + cliArgs.push("--name", rowTitle); + } + if (resumeSessionId) { + cliArgs.push("--resume", resumeSessionId); + } + const claudeDescriptor = resolveSessionModelDescriptor(managed.session); + const claudeSupportsReasoning = claudeDescriptor?.capabilities.reasoning ?? true; + if (claudeSupportsReasoning) { + const effort = managed.session.reasoningEffort; + if (effort === "low" || effort === "medium" || effort === "high" || effort === "xhigh" || effort === "max") { + cliArgs.push("--effort", effort); + } + } + cliArgs.push(promptText); + return cliArgs; + }; + + const rememberClaudeBackgroundJob = ( + managed: ManagedChatSession, + short: string, + state: ClaudeBackgroundJobState | null, + ): void => { + const normalizedShort = normalizeClaudeBackgroundShort(short); + if (!normalizedShort) return; + const resumeSessionId = trimLine(state?.resumeSessionId) + ?? trimLine(state?.sessionId) + ?? managed.claudeBackgroundResumeSessionId; + managed.claudeBackgroundJobShort = normalizedShort; + if (resumeSessionId) { + managed.claudeBackgroundResumeSessionId = resumeSessionId; + mirrorClaudeSessionPointer(managed, resumeSessionId); + } + sessionService.setResumeCommand(managed.session.id, resumeSessionId + ? `claude --resume ${resumeSessionId}` + : `claude attach ${normalizedShort}`); + persistChatState(managed); + }; + + const getLiveClaudeBackgroundSocket = async ( + short: string | null | undefined, + ): Promise => { + const normalizedShort = normalizeClaudeBackgroundShort(short); + if (!normalizedShort) return null; + const socketPath = await resolveClaudeDaemonControlSocket(); + if (!socketPath) return null; + try { + const response = await sendClaudeDaemonRequest(socketPath, { op: "has", short: normalizedShort }); + if (response.ok === true && (response.alive === true || response.present === true)) return socketPath; + } catch { + return null; + } + return null; + }; + + const dispatchClaudeBackgroundPrompt = async ( + managed: ManagedChatSession, + promptText: string, + ): Promise<{ short: string; state: ClaudeBackgroundJobState | null; dispatchedBy: "reply" | "spawn" }> => { + const existingShort = managed.claudeBackgroundJobShort; + const socketPath = await getLiveClaudeBackgroundSocket(existingShort); + if (socketPath && existingShort) { + let response: ClaudeDaemonResponse; + try { + response = await sendClaudeDaemonRequest(socketPath, { + op: "reply", + short: existingShort, + text: promptText, + }); + } catch (error) { + response = { + ok: false, + error: error instanceof Error ? error.message : String(error), + }; + } + if (response.ok === true) { + const state = readClaudeBackgroundJobState(existingShort); + rememberClaudeBackgroundJob(managed, existingShort, state); + return { short: existingShort, state, dispatchedBy: "reply" }; + } + logger.warn("agent_chat.claude_background_reply_failed", { + sessionId: managed.session.id, + short: existingShort, + error: response.error ?? response.code ?? "unknown", + }); + } + + const existingState = readClaudeBackgroundJobState(existingShort); + const resumeSessionId = trimLine(existingState?.resumeSessionId) + ?? trimLine(existingState?.sessionId) + ?? managed.claudeBackgroundResumeSessionId + ?? readPersistedState(managed.session.id)?.claudeBackgroundResumeSessionId + ?? readPersistedState(managed.session.id)?.sdkSessionId + ?? null; + const result = await runClaudeBackgroundCliCommand( + managed, + buildClaudeBackgroundArgs(managed, promptText, resumeSessionId), + 60_000, + ); + const output = `${result.stdout}\n${result.stderr}`; + if (result.exitCode !== 0) { + throw new Error(sanitizeClaudeBackgroundLog(output) || `Claude background session exited with code ${result.exitCode}.`); + } + const short = parseClaudeBackgroundShort(output); + if (!short) { + throw new Error(`Claude background session did not report a job id.\n${sanitizeClaudeBackgroundLog(output)}`); + } + const state = readClaudeBackgroundJobState(short); + rememberClaudeBackgroundJob(managed, short, state); + return { short, state, dispatchedBy: "spawn" }; + }; + + const buildClaudeBackgroundPromptText = async ( + managed: ManagedChatSession, + args: { + promptText: string; + userText: string; + attachments: AgentChatFileRef[]; + resolvedAttachments: ResolvedAgentChatFileRef[]; + providerSlashCommand?: boolean; + }, + ): Promise => { + const providerSlashCommand = args.providerSlashCommand === true; + const autoMemoryPlan = providerSlashCommand + ? null + : await buildAutoMemoryTurnPlan(managed, args.userText, args.attachments); + const autoMemoryNotice = autoMemoryPlan ? buildAutoMemorySystemNotice(autoMemoryPlan) : null; + if (autoMemoryNotice) { + emitChatEvent(managed, { + type: "system_notice", + noticeKind: "memory", + message: autoMemoryNotice.message, + detail: autoMemoryNotice.detail, + }); + } + + const reconstructionContext = providerSlashCommand ? "" : managed.pendingReconstructionContext?.trim() ?? ""; + if (reconstructionContext.length) { + managed.pendingReconstructionContext = null; + persistChatState(managed); + } + const attachmentText = args.resolvedAttachments.length + ? [ + "ADE attached files/images for this turn:", + ...args.resolvedAttachments.map((attachment) => { + const pathText = attachment._resolvedPath || attachment.path; + const label = path.basename(pathText) || pathText; + return `- ${label}: ${pathText}`; + }), + ].join("\n") + : null; + return [ + reconstructionContext.length + ? [ + "System context (identity reconstruction, do not echo verbatim):", + reconstructionContext, + ].join("\n") + : null, + autoMemoryPlan?.contextText.length ? autoMemoryPlan.contextText : null, + attachmentText, + args.promptText, + ].filter((section): section is string => Boolean(section)).join("\n\n"); + }; + + const monitorClaudeBackgroundJob = async ( + managed: ManagedChatSession, + short: string, + turnId: string, + ): Promise<{ status: "completed" | "interrupted" | "failed"; assistantText: string; state: ClaudeBackgroundJobState | null }> => { + const start = Date.now(); + const maxDurationMs = 6 * 60 * 60 * 1_000; + let assistantText = ""; + let lastState = readClaudeBackgroundJobState(short); + let lastDetail = ""; + let emittedStateText = ""; + const emitStateText = (state: ClaudeBackgroundJobState | null): void => { + const stateText = extractClaudeBackgroundStateText(state); + if (!stateText.length || stateText === emittedStateText) return; + emittedStateText = stateText; + assistantText += stateText; + emitChatEvent(managed, { type: "text", text: stateText, turnId }); + persistChatState(managed); + }; + + while (Date.now() - start < maxDurationMs) { + lastState = readClaudeBackgroundJobState(short); + if (lastState) { + rememberClaudeBackgroundJob(managed, short, lastState); + } + const detail = lastState?.detail?.trim() ?? ""; + if (detail && detail !== lastDetail && !claudeBackgroundStateIsTerminal(lastState)) { + lastDetail = detail; + emitChatEvent(managed, { + type: "activity", + activity: "working", + detail, + turnId, + }); + } + const normalizedState = lastState?.state?.trim().toLowerCase() ?? ""; + const waitingForUser = normalizedState === "needs_input" + || normalizedState === "blocked" + || normalizedState === "idle"; + if (claudeBackgroundStateIsTerminal(lastState) || waitingForUser) { + await sleepMs(500); + lastState = readClaudeBackgroundJobState(short) ?? lastState; + emitStateText(lastState); + if (waitingForUser || claudeBackgroundStateSucceeded(lastState)) { + return { status: "completed", assistantText, state: lastState }; + } + if (normalizedState === "stopped" || normalizedState === "killed") { + return { status: "interrupted", assistantText, state: lastState }; + } + return { status: "failed", assistantText, state: lastState }; + } + await sleepMs(1_500); + } + return { status: "failed", assistantText, state: lastState }; + }; + + const _runClaudeBackgroundTurn = async ( + managed: ManagedChatSession, + args: { + promptText: string; + userText?: string; + displayText?: string; + attachments?: AgentChatFileRef[]; + contextAttachments?: AgentChatContextAttachment[]; + resolvedAttachments?: ResolvedAgentChatFileRef[]; + laneDirectiveKey?: string | null; + providerSlashCommand?: boolean; + forceClaudeUserMessage?: boolean; + onDispatched?: () => void; + }, + ): Promise => { + if (managed.closed) { + logger.warn("agent_chat.turn_not_ready", { sessionId: managed.session.id, reason: "Session is disposed" }); + throw new Error("Session is disposed"); + } + if (hasLivePendingInput(managed)) { + logger.warn("agent_chat.turn_not_ready", { sessionId: managed.session.id, reason: PENDING_INPUT_SEND_BLOCKED_MESSAGE }); + throw new Error(PENDING_INPUT_SEND_BLOCKED_MESSAGE); + } + if (managed.session.status === "active") { + logger.warn("agent_chat.turn_not_ready", { sessionId: managed.session.id, reason: "Turn already active" }); + throw new Error("Turn already active"); + } + + const turnId = randomUUID(); + const userMessageId = randomUUID(); + setSessionActive(managed); + const attachments = args.attachments ?? []; + const contextAttachments = args.contextAttachments ?? []; + const resolvedAttachments = args.resolvedAttachments ?? attachments.map((attachment) => ({ + ...attachment, + _resolvedPath: attachment.path, + _rootPath: managed.laneWorktreePath, + })); + const displayText = args.displayText?.trim().length ? args.displayText.trim() : args.promptText; + const userText = args.userText?.trim().length ? args.userText.trim() : displayText; + emitPreparedUserMessage(managed, { + text: userText, + displayText, + attachments, + contextAttachments, + turnId, + messageId: userMessageId, + laneDirectiveKey: args.laneDirectiveKey, + onDispatched: args.onDispatched, + }); + emitChatEvent(managed, { type: "status", turnStatus: "started", turnId }); + captureTurnBeforeSha(managed); + emitChatEvent(managed, { + type: "activity", + ...initialTurnActivity(managed.session), + turnId, + }); + + const doneModel = resolveClaudeTurnModelPayload(managed.session, [resolveClaudeCliModel(managed.session.model)]); + try { + const promptText = await buildClaudeBackgroundPromptText(managed, { + promptText: args.promptText, + userText, + attachments, + resolvedAttachments, + providerSlashCommand: args.providerSlashCommand, + }); + const dispatched = await dispatchClaudeBackgroundPrompt(managed, promptText); + persistDeliveredLaneDirectiveKey(managed, args.laneDirectiveKey); + emitChatEvent(managed, { + type: "system_notice", + noticeKind: "info", + message: dispatched.dispatchedBy === "reply" + ? `Sent to Claude background session ${dispatched.short}.` + : `Started Claude background session ${dispatched.short}.`, + turnId, + }); + const result = await monitorClaudeBackgroundJob(managed, dispatched.short, turnId); + markSessionIdleWithFreshCache(managed); + if (result.status === "completed") { + reportProviderRuntimeReady("claude"); + } else { + reportProviderRuntimeFailure("claude", result.state?.detail ?? result.state?.state ?? result.status); + } + if (result.assistantText.trim().length > 0) { + appendWorkerActivityToCto(managed, { + activityType: "chat_turn", + summary: result.assistantText, + }); + } + const endSha = await computeHeadShaBestEffort(resolveManagedExecutionLaneId(managed)).catch(() => null); + if (endSha) { + sessionService.setHeadShaEnd(managed.session.id, endSha); + } + void emitTurnDiffSummaryIfChanged(managed, turnId); + emitChatEvent(managed, { type: "status", turnStatus: result.status, turnId }); + emitChatEvent(managed, { + type: "done", + turnId, + status: result.status, + ...doneModel, + }); + persistChatState(managed); + } catch (error) { + markSessionIdleWithFreshCache(managed); + const errorMessage = error instanceof Error ? error.message : String(error); + reportProviderRuntimeFailure("claude", errorMessage); + emitChatEvent(managed, { + type: "error", + message: errorMessage, + turnId, + }); + emitChatEvent(managed, { type: "status", turnStatus: "failed", turnId }); + emitChatEvent(managed, { + type: "done", + turnId, + status: "failed", + ...doneModel, + }); + appendWorkerActivityToCto(managed, { + activityType: "chat_turn", + summary: `Turn failed: ${errorMessage}`, + }); + persistChatState(managed); + } + }; + // ── Streaming turn for OpenCode runtime ── const runTurn = async ( @@ -10848,6 +11670,85 @@ export function createAgentChatService(args: { runtime.activeSubagents.clear(); }; + type CodexCollabAgentState = { + threadId: string; + status: string; + summary: string; + }; + + const normalizeCodexCollabToolName = (value: unknown): string => { + const raw = typeof value === "string" ? value.trim() : ""; + const compact = raw.replace(/[_-]/g, "").toLowerCase(); + if (compact === "spawnagent") return "spawn_agent"; + if (compact === "sendinput") return "send_input"; + if (compact === "resumeagent") return "resume_agent"; + if (compact === "closeagent") return "close_agent"; + if (compact === "wait") return "wait"; + return raw; + }; + + const readStringList = (value: unknown): string[] => { + if (!Array.isArray(value)) return []; + return value.flatMap((entry) => { + if (typeof entry !== "string") return []; + const trimmed = entry.trim(); + return trimmed.length ? [trimmed] : []; + }); + }; + + const readCodexCollabReceiverIds = (item: Record): string[] => { + const receiverIds = readStringList(item.receiverThreadIds); + if (receiverIds.length) return receiverIds; + const snakeReceiverIds = readStringList(item.receiver_thread_ids); + if (snakeReceiverIds.length) return snakeReceiverIds; + const newThreadId = typeof item.newThreadId === "string" ? item.newThreadId.trim() : ""; + if (newThreadId.length) return [newThreadId]; + return []; + }; + + const normalizeCodexCollabAgentStates = (value: unknown): CodexCollabAgentState[] => { + if (Array.isArray(value)) { + return value.flatMap((entry) => { + const record = asRecord(entry); + if (!record) return []; + const threadId = typeof record.threadId === "string" + ? record.threadId.trim() + : typeof record.thread_id === "string" + ? record.thread_id.trim() + : ""; + if (!threadId.length) return []; + return [{ + threadId, + status: String(record.status ?? "completed"), + summary: String(record.summary ?? record.result ?? record.message ?? ""), + }]; + }); + } + + const record = asRecord(value); + if (!record) return []; + return Object.entries(record).flatMap(([threadId, state]) => { + const normalizedThreadId = threadId.trim(); + if (!normalizedThreadId.length) return []; + const stateRecord = asRecord(state); + return [{ + threadId: normalizedThreadId, + status: String(stateRecord?.status ?? "completed"), + summary: String(stateRecord?.summary ?? stateRecord?.result ?? stateRecord?.message ?? ""), + }]; + }); + }; + + const mapCodexCollabAgentStatus = (value: unknown): "completed" | "failed" | "stopped" | null => { + const normalized = String(value ?? "").replace(/[_-]/g, "").toLowerCase(); + if (normalized === "completed") return "completed"; + if (normalized === "failed" || normalized === "errored") return "failed"; + if (normalized === "stopped" || normalized === "interrupted" || normalized === "shutdown" || normalized === "notfound") { + return "stopped"; + } + return null; + }; + const handleCodexItemEvent = ( managed: ManagedChatSession, runtime: CodexRuntime, @@ -11061,81 +11962,102 @@ export function createAgentChatService(args: { return; } - // collabToolCall items → subagent events (Codex parallel agents) - if (itemType === "collabToolCall") { - const tool = String(item.tool ?? ""); + // collabAgentToolCall items → subagent events (Codex parallel agents). + // Keep the legacy collabToolCall branch for older app-server builds/tests. + if (itemType === "collabAgentToolCall" || itemType === "collabToolCall") { + const tool = normalizeCodexCollabToolName(item.tool); const prompt = typeof item.prompt === "string" ? item.prompt : ""; - const agentsStates = Array.isArray(item.agentsStates) ? item.agentsStates : []; - const newThreadId = typeof item.newThreadId === "string" ? item.newThreadId : null; + const receiverIds = readCodexCollabReceiverIds(item); + const agentsStates = normalizeCodexCollabAgentStates(item.agentsStates ?? item.agents_states); if (tool === "spawn_agent" && eventKind === "started") { - const taskId = newThreadId ?? itemId; - runtime.activeSubagents.set(taskId, { - taskId, - description: prompt.slice(0, 120) || "Parallel agent", - background: isBackgroundTask(item as Record), - }); + const taskIds = receiverIds.length ? receiverIds : [itemId]; emitChatEvent(managed, { type: "activity", activity: "spawning_agent", detail: prompt.slice(0, 80) || "Spawning parallel agent", turnId, }); - emitChatEvent(managed, { - type: "subagent_started", - taskId, - description: prompt.slice(0, 120) || "Parallel agent", - background: isBackgroundTask(item as Record), - turnId, - }); + for (const taskId of taskIds) { + runtime.activeSubagents.set(taskId, { + taskId, + description: prompt.slice(0, 120) || "Parallel agent", + background: isBackgroundTask(item as Record), + }); + emitChatEvent(managed, { + type: "subagent_started", + taskId, + description: prompt.slice(0, 120) || "Parallel agent", + background: isBackgroundTask(item as Record), + turnId, + }); + } + } + + if (tool === "spawn_agent" && eventKind === "completed") { + const spawnStatus = mapCodexCollabAgentStatus(item.status); + if (spawnStatus === "failed") { + for (const taskId of receiverIds.length ? receiverIds : [itemId]) { + runtime.activeSubagents.delete(taskId); + emitChatEvent(managed, { + type: "subagent_result", + taskId, + status: "failed", + summary: String(item.error ?? item.result ?? "Agent spawn failed"), + turnId, + }); + } + } } if ((tool === "send_input" || tool === "resume_agent") && eventKind === "completed") { - const receiverIds = Array.isArray(item.receiverThreadIds) ? item.receiverThreadIds : []; - const targetId = typeof receiverIds[0] === "string" ? receiverIds[0] : itemId; - emitChatEvent(managed, { - type: "subagent_progress", - taskId: targetId, - summary: prompt || "Agent received input", - turnId, - }); + const targetIds = receiverIds.length ? receiverIds : [itemId]; + for (const targetId of targetIds) { + emitChatEvent(managed, { + type: "subagent_progress", + taskId: targetId, + summary: prompt || "Agent received input", + turnId, + }); + } } if (tool === "wait" && eventKind === "completed") { for (const agentState of agentsStates) { - if (!agentState || typeof agentState !== "object") continue; - const state = agentState as Record; - const agentThreadId = typeof state.threadId === "string" ? state.threadId : itemId; - const summary = typeof state.summary === "string" ? state.summary - : typeof state.result === "string" ? state.result - : ""; - const rawStatus = String(state.status ?? "completed"); - const subagentStatus: "completed" | "failed" | "stopped" = - rawStatus === "failed" ? "failed" - : rawStatus === "stopped" ? "stopped" - : "completed"; + const agentThreadId = agentState.threadId || itemId; + const subagentStatus = mapCodexCollabAgentStatus(agentState.status); + if (!subagentStatus) { + emitChatEvent(managed, { + type: "subagent_progress", + taskId: agentThreadId, + summary: agentState.summary || "Agent is still working", + turnId, + }); + continue; + } runtime.activeSubagents.delete(agentThreadId); emitChatEvent(managed, { type: "subagent_result", taskId: agentThreadId, status: subagentStatus, - summary, + summary: agentState.summary, turnId, }); } } if (tool === "close_agent" && eventKind === "completed") { - const receiverIds = Array.isArray(item.receiverThreadIds) ? item.receiverThreadIds : []; - const targetId = typeof receiverIds[0] === "string" ? receiverIds[0] : itemId; - runtime.activeSubagents.delete(targetId); - emitChatEvent(managed, { - type: "subagent_result", - taskId: targetId, - status: "stopped", - summary: "Agent closed", - turnId, - }); + const targetIds = receiverIds.length ? receiverIds : [itemId]; + for (const targetId of targetIds) { + runtime.activeSubagents.delete(targetId); + emitChatEvent(managed, { + type: "subagent_result", + taskId: targetId, + status: "stopped", + summary: "Agent closed", + turnId, + }); + } } return; @@ -12582,24 +13504,20 @@ export function createAgentChatService(args: { const claudeExecutable = resolveClaudeCodeExecutable({ env: claudeEnv }); const outputStyle = resolveManagedClaudeOutputStyle(managed); const pluginPaths = discoverClaudePluginPaths(managed.laneWorktreePath); - const mcpServers = lightweight - ? {} - : buildClaudeMcpServers({ - projectRoot, - workspaceRoot: managed.laneWorktreePath, - sessionId: managed.session.id, - laneId: managed.session.laneId, - }); const opts: ClaudeSDKOptions = { cwd: managed.laneWorktreePath, env: claudeEnv, pathToClaudeCodeExecutable: claudeExecutable.path, + managedSettings: { + allowedMcpServers: [], + allowManagedMcpServersOnly: true, + strictPluginOnlyCustomization: ["mcp"], + }, settings: { outputStyle, enabledPlugins: CLAUDE_SESSION_DISABLED_PLUGINS, }, ...(pluginPaths.length ? { plugins: pluginPaths.map((pluginPath) => ({ type: "local" as const, path: pluginPath })) } : {}), - ...(Object.keys(mcpServers).length ? { mcpServers } : {}), permissionMode: claudePermissionMode as any, ...(claudePermissionMode === "bypassPermissions" ? { allowDangerouslySkipPermissions: true } as any : {}), includePartialMessages: true, @@ -13319,6 +14237,9 @@ export function createAgentChatService(args: { eventSequence: 0, lastActivityTimestamp: Date.now(), turnBeforeSha: null, + claudeBackgroundJobShort: null, + claudeBackgroundResumeSessionId: null, + claudeBackgroundLogText: "", }; let runtime: CodexRuntime | null = null; @@ -13763,6 +14684,9 @@ export function createAgentChatService(args: { eventSequence: 0, lastActivityTimestamp: Date.now(), turnBeforeSha: null, + claudeBackgroundJobShort: null, + claudeBackgroundResumeSessionId: null, + claudeBackgroundLogText: "", }; normalizeSessionNativePermissionControls(managed.session, resolveChatConfig()); managed.transcriptLimitReached = managed.transcriptBytesWritten >= MAX_CHAT_TRANSCRIPT_BYTES; @@ -13796,8 +14720,6 @@ export function createAgentChatService(args: { prewarmClaudeQuery(managed); } - // Eager pre-warm: spawn the Claude runtime so it's ready by the time the - // user sends their first message (the ~30s cold-start runs in background). persistChatState(managed); return managed.session; }; @@ -13836,7 +14758,7 @@ export function createAgentChatService(args: { const sourceClaudeRuntime = handoffMode === "fork" ? ensureClaudeSessionRuntime(managed) : null; const sourceSdkSessionId = sourceClaudeRuntime?.sdkSessionId ?? null; if (handoffMode === "fork" && !sourceSdkSessionId) { - throw new Error("Full-history fork requires a Claude SDK session id. Send a Claude message first, then try Fork again."); + throw new Error("Full-history fork requires a Claude session id. Send a Claude message first, then try Fork again."); } const targetModel = targetDescriptor.isCliWrapped ? targetDescriptor.providerModelId : targetDescriptor.id; const targetReasoningEffort = pickHandoffReasoningEffort( @@ -17590,7 +18512,6 @@ export function createAgentChatService(args: { return { steerId, queued: false }; } - const runtime = ensureClaudeSessionRuntime(managed); const preparedSteer = prepareSendMessage({ sessionId, text: trimmed, @@ -17601,6 +18522,7 @@ export function createAgentChatService(args: { if (!preparedSteer) { return { steerId, queued: false }; } + const runtime = ensureClaudeSessionRuntime(managed); if (runtime.busy) { enqueueSteerOrDrop( managed, @@ -18165,6 +19087,15 @@ export function createAgentChatService(args: { ? DEFAULT_DROID_DESCRIPTOR?.id : undefined); const model = provider === "opencode" ? (hydratedModelId ?? fallbackModel) : fallbackModel; + const claudeBackgroundJobShort = provider === "claude" + ? liveManaged?.claudeBackgroundJobShort ?? persisted?.claudeBackgroundJobShort ?? null + : null; + const claudeBackgroundResumeSessionId = provider === "claude" + ? liveManaged?.claudeBackgroundResumeSessionId + ?? persisted?.claudeBackgroundResumeSessionId + ?? persisted?.sdkSessionId + ?? null + : null; return { sessionId: row.id, laneId: row.laneId, @@ -18184,6 +19115,8 @@ export function createAgentChatService(args: { ...(liveSession?.claudeOutputStyle || persisted?.claudeOutputStyle ? { claudeOutputStyle: liveSession?.claudeOutputStyle ?? persisted?.claudeOutputStyle } : {}), + ...(claudeBackgroundJobShort ? { claudeBackgroundJobShort } : {}), + ...(claudeBackgroundResumeSessionId ? { claudeBackgroundResumeSessionId } : {}), ...(liveSession?.codexApprovalPolicy || persisted?.codexApprovalPolicy ? { codexApprovalPolicy: liveSession?.codexApprovalPolicy ?? persisted?.codexApprovalPolicy } : {}), @@ -19517,10 +20450,11 @@ export function createAgentChatService(args: { if (title !== undefined) { const normalizedTitle = String(title ?? "").trim(); const hasExplicitTitle = normalizedTitle.length > 0; + const effectiveTitle = hasExplicitTitle ? normalizedTitle : defaultChatSessionTitle(managed.session.provider); sessionService.updateMeta({ sessionId, manuallyNamed: manuallyNamed ?? false, - title: hasExplicitTitle ? normalizedTitle : defaultChatSessionTitle(managed.session.provider), + title: effectiveTitle, }); if (manuallyNamed !== undefined) { managed.manuallyNamed = manuallyNamed && hasExplicitTitle; @@ -19530,6 +20464,11 @@ export function createAgentChatService(args: { managed.manuallyNamed = false; } managed.runtimeTitleAdopted = false; + emitTransientChatEnvelope(sessionId, { + type: "session_meta_updated", + title: effectiveTitle, + manuallyNamed: managed.manuallyNamed, + }); if (managed.session.provider === "claude" && managed.runtime?.kind === "claude" && managed.runtime.sdkSessionId && hasExplicitTitle) { await renameClaudeSession(managed.runtime.sdkSessionId, normalizedTitle, { dir: managed.laneWorktreePath }).catch((error) => { logger.warn("agent_chat.claude_rename_session_failed", { @@ -19625,15 +20564,6 @@ export function createAgentChatService(args: { return; } - // Warmup should never rewrite the live session model. It's only allowed to - // prime the currently-selected Claude runtime when the backend session is - // already aligned with the requested model and fully idle. - if (managed.session.provider !== "claude") return; - if (managed.session.modelId !== descriptor.id) return; - if (managed.session.status === "active") return; - if (managed.runtime && managed.runtime.kind !== "claude") return; - if (managed.runtime?.kind === "claude" && managed.runtime.busy) return; - // Only prewarm if the session is idle (not mid-turn) and not already warmed if (managed.runtime?.kind === "claude" && (managed.runtime.query || managed.runtime.warmQuery || managed.runtime.warmupDone)) return; @@ -19712,18 +20642,22 @@ export function createAgentChatService(args: { // Codex SDK commands if (provider === "codex") { const rt = managed?.runtime?.kind === "codex" ? managed.runtime : null; - const dynamicCommands: AgentChatSlashCommand[] = (rt?.slashCommands ?? []).map((cmd: { name: string; description: string; argumentHint?: string }) => ({ - name: cmd.name, - description: cmd.description, - argumentHint: cmd.argumentHint, - source: "sdk" as const, - })); - const promptCommands: AgentChatSlashCommand[] = discoverCodexSlashCommands(laneWorktreePath).map((cmd: { name: string; description: string; argumentHint?: string }) => ({ - name: cmd.name, - description: cmd.description, - argumentHint: cmd.argumentHint, - source: "sdk" as const, - })); + const dynamicCommands: AgentChatSlashCommand[] = (rt?.slashCommands ?? []) + .filter(isVisibleCodexSlashCommand) + .map((cmd: { name: string; description: string; argumentHint?: string }) => ({ + name: cmd.name, + description: cmd.description, + argumentHint: cmd.argumentHint, + source: "sdk" as const, + })); + const promptCommands: AgentChatSlashCommand[] = discoverCodexSlashCommands(laneWorktreePath) + .filter(isVisibleCodexSlashCommand) + .map((cmd: { name: string; description: string; argumentHint?: string }) => ({ + name: cmd.name, + description: cmd.description, + argumentHint: cmd.argumentHint, + source: "sdk" as const, + })); const claudeProjectCommands: AgentChatSlashCommand[] = discoverClaudeSlashCommands(laneWorktreePath) .filter(isDispatchableClaudeSdkSlashCommand) .map((cmd: { name: string; description: string; argumentHint?: string }) => ({ @@ -19961,73 +20895,9 @@ export function createAgentChatService(args: { tokens: Number.isFinite(file.tokens) ? Math.max(0, file.tokens) : 0, })) .filter((file) => typeof file.path === "string" && file.path.trim().length > 0), - mcpTools: (Array.isArray(usage.mcpTools) ? usage.mcpTools : []) - .map((tool) => ({ - name: tool.name, - serverName: tool.serverName, - tokens: Number.isFinite(tool.tokens) ? Math.max(0, tool.tokens) : 0, - ...(tool.isLoaded === true ? { isLoaded: true } : {}), - })) - .filter((tool) => typeof tool.name === "string" && tool.name.trim().length > 0 && typeof tool.serverName === "string"), }; }; - const normalizeClaudeMcpServerStatuses = ( - statuses: Awaited>, - ): AgentChatClaudeMcpServerStatus[] => { - if (!Array.isArray(statuses)) return []; - return statuses - .map((status): AgentChatClaudeMcpServerStatus | null => { - const record = status && typeof status === "object" ? status as Record : null; - if (!record) return null; - const name = typeof record?.name === "string" ? record.name.trim() : ""; - if (!name.length) return null; - const config = record.config && typeof record.config === "object" - ? record.config as Record - : null; - const tools = Array.isArray(record.tools) - ? record.tools - .map((tool): NonNullable[number] | null => { - const toolRecord = tool && typeof tool === "object" ? tool as Record : null; - if (!toolRecord) return null; - const toolName = typeof toolRecord?.name === "string" ? toolRecord.name.trim() : ""; - if (!toolName.length) return null; - const annotations = toolRecord.annotations && typeof toolRecord.annotations === "object" - ? toolRecord.annotations as Record - : null; - return { - name: toolName, - ...(typeof toolRecord.description === "string" && toolRecord.description.trim().length - ? { description: toolRecord.description.trim() } - : {}), - ...(typeof annotations?.readOnly === "boolean" ? { readOnly: annotations.readOnly } : {}), - ...(typeof annotations?.destructive === "boolean" ? { destructive: annotations.destructive } : {}), - ...(typeof annotations?.openWorld === "boolean" ? { openWorld: annotations.openWorld } : {}), - }; - }) - .filter((tool): tool is NonNullable[number] => Boolean(tool)) - : undefined; - return { - name, - status: typeof record?.status === "string" && record.status.trim().length ? record.status.trim() as AgentChatClaudeMcpServerStatus["status"] : "pending", - ...(typeof record?.error === "string" && record.error.trim().length ? { error: record.error.trim() } : {}), - ...(typeof record?.scope === "string" && record.scope.trim().length ? { scope: record.scope.trim() } : {}), - ...(config - ? { - config: { - ...(typeof config.type === "string" && config.type.trim().length ? { type: config.type.trim() } : {}), - ...(typeof config.command === "string" && config.command.trim().length ? { command: config.command.trim() } : {}), - ...(Array.isArray(config.args) ? { args: config.args.filter((arg): arg is string => typeof arg === "string") } : {}), - ...(typeof config.url === "string" && config.url.trim().length ? { url: config.url.trim() } : {}), - }, - } - : {}), - ...(tools?.length ? { tools } : {}), - }; - }) - .filter((status): status is AgentChatClaudeMcpServerStatus => Boolean(status)); - }; - const normalizeClaudeRewindFilesResult = ( result: ClaudeRewindFilesResult, dryRun: boolean, @@ -20055,42 +20925,6 @@ export function createAgentChatService(args: { return sessionQuery; }; - const getClaudeMcpStatus = async ({ sessionId }: AgentChatClaudeMcpStatusArgs): Promise => { - const managed = ensureManagedSession(sessionId); - const sessionQuery = await getClaudeControlQuery(managed, "/mcp"); - const control = getClaudeQueryControl(sessionQuery); - if (!control.mcpServerStatus) { - throw new Error("Claude MCP status is not supported by this SDK version."); - } - return normalizeClaudeMcpServerStatuses(await control.mcpServerStatus()); - }; - - const reconnectClaudeMcpServer = async ({ sessionId, serverName }: AgentChatClaudeMcpReconnectArgs): Promise => { - const name = String(serverName ?? "").trim(); - if (!name.length) throw new Error("MCP server name is required."); - const managed = ensureManagedSession(sessionId); - const sessionQuery = await getClaudeControlQuery(managed, "/mcp reconnect"); - const control = getClaudeQueryControl(sessionQuery); - if (!control.reconnectMcpServer) { - throw new Error("Claude MCP reconnect is not supported by this SDK version."); - } - await control.reconnectMcpServer(name); - return getClaudeMcpStatus({ sessionId }); - }; - - const toggleClaudeMcpServer = async ({ sessionId, serverName, enabled }: AgentChatClaudeMcpToggleArgs): Promise => { - const name = String(serverName ?? "").trim(); - if (!name.length) throw new Error("MCP server name is required."); - const managed = ensureManagedSession(sessionId); - const sessionQuery = await getClaudeControlQuery(managed, "/mcp toggle"); - const control = getClaudeQueryControl(sessionQuery); - if (!control.toggleMcpServer) { - throw new Error("Claude MCP toggle is not supported by this SDK version."); - } - await control.toggleMcpServer(name, Boolean(enabled)); - return getClaudeMcpStatus({ sessionId }); - }; - const listClaudePlugins = (args: AgentChatClaudePluginsArgs = {}): AgentChatClaudePlugin[] => { const { cwd } = resolveClaudeOutputStyleCwd(args); return discoverClaudePlugins(cwd); @@ -20148,7 +20982,6 @@ export function createAgentChatService(args: { plugins, commands, agents, - mcpServers: normalizeClaudeMcpServerStatuses(result.mcpServers), errorCount: Number.isFinite(result.error_count) ? Math.max(0, result.error_count) : 0, }; }; @@ -20629,9 +21462,6 @@ export function createAgentChatService(args: { getAvailableModels, getModelCatalog, getSlashCommands, - getClaudeMcpStatus, - reconnectClaudeMcpServer, - toggleClaudeMcpServer, listClaudePlugins, reloadClaudePlugins, listClaudeOutputStyles, diff --git a/apps/desktop/src/main/services/chat/claudeMcpServers.test.ts b/apps/desktop/src/main/services/chat/claudeMcpServers.test.ts deleted file mode 100644 index 47a4ae521..000000000 --- a/apps/desktop/src/main/services/chat/claudeMcpServers.test.ts +++ /dev/null @@ -1,112 +0,0 @@ -import fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { - buildAdeClaudeMcpServers, - buildClaudeMcpServers, - discoverProjectClaudeMcpServers, -} from "./claudeMcpServers"; - -let tmpRoot: string; - -beforeEach(() => { - tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-claude-mcp-test-")); - vi.stubEnv("ADE_CLI_PATH", ""); - vi.stubEnv("ADE_CLI_BIN_DIR", ""); -}); - -afterEach(() => { - vi.unstubAllEnvs(); - fs.rmSync(tmpRoot, { recursive: true, force: true }); -}); - -describe("discoverProjectClaudeMcpServers", () => { - it("reads project .mcp.json into SDK-compatible server configs", () => { - fs.writeFileSync(path.join(tmpRoot, ".mcp.json"), JSON.stringify({ - mcpServers: { - local: { - command: "node", - args: ["server.js"], - env: { TOKEN: "secret", IGNORED: 123 }, - alwaysLoad: true, - }, - remote: { - type: "streamable-http", - url: "https://mcp.example.test/mcp", - headers: { authorization: "Bearer token" }, - }, - invalid: { - type: "http", - }, - }, - })); - - expect(discoverProjectClaudeMcpServers(tmpRoot)).toEqual({ - local: { - command: "node", - args: ["server.js"], - env: { TOKEN: "secret" }, - alwaysLoad: true, - }, - remote: { - type: "http", - url: "https://mcp.example.test/mcp", - headers: { authorization: "Bearer token" }, - }, - }); - }); -}); - -describe("buildClaudeMcpServers", () => { - it("adds ADE's local stdio server and leaves project .mcp.json to SDK settingSources", () => { - const cliPath = path.join(tmpRoot, "ade-cli.cjs"); - fs.writeFileSync(cliPath, ""); - vi.stubEnv("ADE_CLI_PATH", cliPath); - fs.writeFileSync(path.join(tmpRoot, ".mcp.json"), JSON.stringify({ - mcpServers: { - custom: { - type: "sse", - url: "https://mcp.example.test/sse", - }, - }, - })); - - const servers = buildClaudeMcpServers({ - projectRoot: tmpRoot, - workspaceRoot: tmpRoot, - sessionId: "chat-1", - laneId: "lane-1", - }); - - expect(servers.ade).toEqual(expect.objectContaining({ - type: "stdio", - command: process.execPath, - args: expect.arrayContaining([cliPath, "--headless", "--project-root", tmpRoot, "--workspace-root", tmpRoot, "mcp"]), - env: expect.objectContaining({ - ADE_CHAT_SESSION_ID: "chat-1", - ADE_LANE_ID: "lane-1", - }), - })); - expect(discoverProjectClaudeMcpServers(tmpRoot).custom).toEqual({ - type: "sse", - url: "https://mcp.example.test/sse", - }); - expect(servers.custom).toBeUndefined(); - }); - - it("falls back to the ade executable when no local CLI path is discoverable", () => { - const servers = buildAdeClaudeMcpServers({ - projectRoot: tmpRoot, - workspaceRoot: tmpRoot, - sessionId: "chat-1", - laneId: "lane-1", - }); - - expect(servers.ade).toEqual(expect.objectContaining({ - type: "stdio", - command: expect.any(String), - args: expect.arrayContaining(["--headless", "--role", "agent", "mcp"]), - })); - }); -}); diff --git a/apps/desktop/src/main/services/chat/claudeMcpServers.ts b/apps/desktop/src/main/services/chat/claudeMcpServers.ts deleted file mode 100644 index 4ee7c4006..000000000 --- a/apps/desktop/src/main/services/chat/claudeMcpServers.ts +++ /dev/null @@ -1,156 +0,0 @@ -import fs from "node:fs"; -import path from "node:path"; -import type { McpServerConfig } from "@anthropic-ai/claude-agent-sdk"; - -type McpServerMap = Record; - -export type BuildAdeClaudeMcpServersArgs = { - projectRoot: string; - workspaceRoot: string; - sessionId: string; - laneId: string; -}; - -const MCP_CONFIG_FILENAMES = [".mcp.json", path.join(".claude", ".mcp.json")]; - -function trimToUndefined(value: unknown): string | undefined { - return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined; -} - -function fileExists(filePath: string | undefined | null): filePath is string { - return Boolean(filePath && fs.existsSync(filePath)); -} - -function isRecord(value: unknown): value is Record { - return Boolean(value && typeof value === "object" && !Array.isArray(value)); -} - -function stringArray(value: unknown): string[] | undefined { - if (!Array.isArray(value)) return undefined; - const values = value.filter((item): item is string => typeof item === "string"); - return values.length ? values : undefined; -} - -function stringRecord(value: unknown): Record | undefined { - if (!isRecord(value)) return undefined; - const entries = Object.entries(value) - .filter((entry): entry is [string, string] => typeof entry[1] === "string"); - return entries.length ? Object.fromEntries(entries) : undefined; -} - -function booleanValue(value: unknown): boolean | undefined { - return typeof value === "boolean" ? value : undefined; -} - -function normalizeMcpServerConfig(value: unknown): McpServerConfig | null { - if (!isRecord(value)) return null; - const type = trimToUndefined(value.type); - const alwaysLoad = booleanValue(value.alwaysLoad); - if (typeof value.command === "string" && value.command.trim().length > 0 && (!type || type === "stdio")) { - return { - ...(type ? { type: "stdio" as const } : {}), - command: value.command.trim(), - ...(stringArray(value.args) ? { args: stringArray(value.args) } : {}), - ...(stringRecord(value.env) ? { env: stringRecord(value.env) } : {}), - ...(alwaysLoad !== undefined ? { alwaysLoad } : {}), - }; - } - const remoteType = type === "streamable-http" ? "http" : type; - if ((remoteType === "http" || remoteType === "sse") && typeof value.url === "string" && value.url.trim().length > 0) { - return { - type: remoteType, - url: value.url.trim(), - ...(stringRecord(value.headers) ? { headers: stringRecord(value.headers) } : {}), - ...(alwaysLoad !== undefined ? { alwaysLoad } : {}), - } as McpServerConfig; - } - return null; -} - -function readMcpConfigFile(filePath: string): McpServerMap { - let raw = ""; - try { - raw = fs.readFileSync(filePath, "utf8"); - } catch { - return {}; - } - let parsed: unknown; - try { - parsed = JSON.parse(raw); - } catch { - return {}; - } - const mcpServers = isRecord(parsed) && isRecord(parsed.mcpServers) ? parsed.mcpServers : null; - if (!mcpServers) return {}; - const entries = Object.entries(mcpServers) - .map(([name, config]) => [name.trim(), normalizeMcpServerConfig(config)] as const) - .filter((entry): entry is readonly [string, McpServerConfig] => entry[0].length > 0 && Boolean(entry[1])); - return Object.fromEntries(entries); -} - -function resolveAdeCliCommandPath(): string | null { - const envCli = trimToUndefined(process.env.ADE_CLI_PATH); - if (fileExists(envCli)) return envCli; - - const envBin = trimToUndefined(process.env.ADE_CLI_BIN_DIR); - const envBinCli = envBin ? path.join(envBin, process.platform === "win32" ? "ade.cmd" : "ade") : null; - if (fileExists(envBinCli)) return envBinCli; - - const candidateRoots = [ - process.cwd(), - path.resolve(process.cwd(), ".."), - path.resolve(process.cwd(), "..", ".."), - ]; - for (const root of candidateRoots) { - const repoBuilt = path.join(root, "apps", "ade-cli", "dist", "cli.cjs"); - if (fileExists(repoBuilt)) return repoBuilt; - const siblingBuilt = path.join(root, "ade-cli", "dist", "cli.cjs"); - if (fileExists(siblingBuilt)) return siblingBuilt; - } - - return null; -} - -export function discoverProjectClaudeMcpServers(cwd: string): McpServerMap { - return MCP_CONFIG_FILENAMES.reduce((servers, filename) => ({ - ...servers, - ...readMcpConfigFile(path.join(cwd, filename)), - }), {}); -} - -export function buildAdeClaudeMcpServers(args: BuildAdeClaudeMcpServersArgs): McpServerMap { - const cliPath = resolveAdeCliCommandPath(); - const command = cliPath && /\.(?:cjs|mjs|js)$/i.test(cliPath) ? process.execPath : cliPath ?? "ade"; - const commandArgs = cliPath && /\.(?:cjs|mjs|js)$/i.test(cliPath) ? [cliPath] : []; - return { - ade: { - type: "stdio", - command, - args: [ - ...commandArgs, - "--headless", - "--project-root", - args.projectRoot, - "--workspace-root", - args.workspaceRoot, - "--role", - "agent", - "mcp", - ], - env: { - ADE_DEFAULT_ROLE: "agent", - ADE_CLI_HEADLESS: "1", - ADE_PROJECT_ROOT: args.projectRoot, - ADE_WORKSPACE_ROOT: args.workspaceRoot, - ADE_CHAT_SESSION_ID: args.sessionId, - ADE_LANE_ID: args.laneId, - ADE_CLI_NODE: process.execPath, - ...(process.env.PATH ? { PATH: process.env.PATH } : {}), - }, - }, - }; -} - -export function buildClaudeMcpServers(args: BuildAdeClaudeMcpServersArgs): McpServerMap { - return buildAdeClaudeMcpServers(args); -} diff --git a/apps/desktop/src/main/services/chat/claudeOutputStyles.test.ts b/apps/desktop/src/main/services/chat/claudeOutputStyles.test.ts index 260fa0ac9..b3b39e1a9 100644 --- a/apps/desktop/src/main/services/chat/claudeOutputStyles.test.ts +++ b/apps/desktop/src/main/services/chat/claudeOutputStyles.test.ts @@ -217,9 +217,6 @@ describe("discoverClaudePlugins", () => { fs.mkdirSync(path.join(disabledRoot, ".claude-plugin"), { recursive: true }); fs.writeFileSync(path.join(enabledRoot, ".claude-plugin", "plugin.json"), JSON.stringify({ name: "review-pack" })); fs.writeFileSync(path.join(disabledRoot, ".claude-plugin", "plugin.json"), JSON.stringify({ name: "serena" })); - fs.writeFileSync(path.join(disabledRoot, ".mcp.json"), JSON.stringify({ - serena: { command: "uvx", args: ["serena", "start-mcp-server"] }, - })); fs.writeFileSync(path.join(homeRoot, ".claude", "settings.json"), JSON.stringify({ enabledPlugins: { "review-pack@claude-plugins-official": true, diff --git a/apps/desktop/src/main/services/chat/cursorSdkProtocol.ts b/apps/desktop/src/main/services/chat/cursorSdkProtocol.ts index 7dba64aa2..5dcea2e1b 100644 --- a/apps/desktop/src/main/services/chat/cursorSdkProtocol.ts +++ b/apps/desktop/src/main/services/chat/cursorSdkProtocol.ts @@ -57,20 +57,6 @@ export type CursorSdkSendPrompt = { force?: boolean; }; -export type CursorSdkCloudMcpServerConfig = - | { - type?: "stdio"; - command: string; - args?: string[]; - env?: Record; - cwd?: string; - } - | { - type?: "http" | "sse"; - url: string; - headers?: Record; - }; - export type CursorSdkCloudRepoOverride = { url: string; startingRef?: string | null; @@ -90,7 +76,6 @@ export type CursorSdkCloudSendStreamPayload = { skipReviewerRequest?: boolean; envType?: "cloud" | "pool" | "machine" | null; envName?: string | null; - mcpServers?: Record; }; export type CursorSdkCloudFollowupPayload = { @@ -98,7 +83,6 @@ export type CursorSdkCloudFollowupPayload = { agentId: string; promptText: string; modelSdkId?: string | null; - mcpServers?: Record; }; export type CursorSdkCloudRunCancelPayload = { diff --git a/apps/desktop/src/main/services/chat/cursorSdkWorker.ts b/apps/desktop/src/main/services/chat/cursorSdkWorker.ts index c77a4faea..71d23964e 100644 --- a/apps/desktop/src/main/services/chat/cursorSdkWorker.ts +++ b/apps/desktop/src/main/services/chat/cursorSdkWorker.ts @@ -9,7 +9,6 @@ import type { CursorSdkCloudArtifactDescriptor, CursorSdkCloudArtifactDownloadResult, CursorSdkCloudFollowupPayload, - CursorSdkCloudMcpServerConfig, CursorSdkCloudRunStartedResult, CursorSdkCloudSendStreamPayload, CursorSdkHookDecision, @@ -332,9 +331,6 @@ function buildCloudCreateOptions(payload: CursorSdkCloudSendStreamPayload): Agen cloud, }; if (payload.modelSdkId?.trim()) options.model = { id: payload.modelSdkId.trim() }; - if (payload.mcpServers && Object.keys(payload.mcpServers).length) { - options.mcpServers = payload.mcpServers as Record as AgentOptions["mcpServers"]; - } return options; } @@ -343,9 +339,6 @@ function buildCloudResumeOptions(payload: CursorSdkCloudFollowupPayload): Partia apiKey: payload.apiKey?.trim() || undefined, }; if (payload.modelSdkId?.trim()) options.model = { id: payload.modelSdkId.trim() }; - if (payload.mcpServers && Object.keys(payload.mcpServers).length) { - options.mcpServers = payload.mcpServers as Record as AgentOptions["mcpServers"]; - } return options; } diff --git a/apps/desktop/src/main/services/cto/ctoStateService.ts b/apps/desktop/src/main/services/cto/ctoStateService.ts index ab68838a7..22992d135 100644 --- a/apps/desktop/src/main/services/cto/ctoStateService.ts +++ b/apps/desktop/src/main/services/cto/ctoStateService.ts @@ -304,10 +304,8 @@ const CTO_CAPABILITY_MANIFEST = [ "## Chats (AI work sessions)", " listChats — List all chat sessions, optionally filtered by lane.", " spawnChat — Create a new ADE chat session. THIS IS THE PRIMARY WAY TO LAUNCH AI AGENTS. Params: laneId, modelId (use full ID like 'anthropic/claude-sonnet-4-6'), reasoningEffort, title, initialPrompt, openInUi. The modelId is critical — always pass it when the user specifies a model.", - " sendChatMessage — Send a follow-up message to an existing chat. Params: sessionId, text.", + " sendChatMessage — Send a follow-up message to an existing chat; ended sessions continue from this message. Params: sessionId, text.", " interruptChat — Stop a running turn in a chat. Params: sessionId.", - " resumeChat — Resume a paused chat session. Params: sessionId.", - " endChat — Terminate a chat session. Params: sessionId.", " getChatStatus — Get the current status of a chat (running, idle, ended). Params: sessionId.", " getChatTranscript — Read the conversation history of a chat. Params: sessionId, limit.", "", diff --git a/apps/desktop/src/main/services/github/githubService.test.ts b/apps/desktop/src/main/services/github/githubService.test.ts index 76f795104..3c37838d9 100644 --- a/apps/desktop/src/main/services/github/githubService.test.ts +++ b/apps/desktop/src/main/services/github/githubService.test.ts @@ -69,11 +69,40 @@ function resetMocks() { runGitMock.mockReset(); } -function makeService() { +class MemoryCredentialStore { + values = new Map(); + + async get(key: string): Promise { + return this.getSync(key); + } + + async set(key: string, value: string): Promise { + this.setSync(key, value); + } + + async delete(key: string): Promise { + this.deleteSync(key); + } + + getSync(key: string): string | null { + return this.values.get(key) ?? null; + } + + setSync(key: string, value: string): void { + this.values.set(key, value); + } + + deleteSync(key: string): void { + this.values.delete(key); + } +} + +function makeService(options: { credentialStore?: MemoryCredentialStore } = {}) { return createGithubService({ logger: makeLogger(), projectRoot: "/tmp/test-project", appDataDir: "/tmp/test-appdata", + credentialStore: options.credentialStore as any, }); } @@ -217,6 +246,29 @@ describe("githubService.apiRequest", () => { service.apiRequest({ method: "GET", path: "/test" }), ).rejects.toThrow("GitHub token missing"); }); + + it("uses the shared machine credential store token when present", async () => { + const credentialStore = new MemoryCredentialStore(); + credentialStore.setSync("github.token.v1", "ghp_machine_token"); + mockFetch.mockResolvedValueOnce(jsonResponse(200, { ok: true })); + + const service = makeService({ credentialStore }); + await service.apiRequest({ method: "GET", path: "/test" }); + + const [, init] = mockFetch.mock.calls.at(-1) as [string, RequestInit]; + expect((init.headers as Record).authorization).toBe("Bearer ghp_machine_token"); + }); + + it("stores and clears GitHub tokens in the shared machine credential store", () => { + const credentialStore = new MemoryCredentialStore(); + const service = makeService({ credentialStore }); + + service.setToken("ghp_saved_token"); + expect(credentialStore.getSync("github.token.v1")).toBe("ghp_saved_token"); + + service.clearToken(); + expect(credentialStore.getSync("github.token.v1")).toBeNull(); + }); }); // --------------------------------------------------------------------------- diff --git a/apps/desktop/src/main/services/github/githubService.ts b/apps/desktop/src/main/services/github/githubService.ts index e4311a8fc..b35a5d245 100644 --- a/apps/desktop/src/main/services/github/githubService.ts +++ b/apps/desktop/src/main/services/github/githubService.ts @@ -6,10 +6,12 @@ import { runGit } from "../git/git"; import type { GitHubRepoRef, GitHubStatus } from "../../../shared/types"; import { resolveAdeLayout } from "../../../shared/adeLayout"; import { getGitHubTokenAccessState, parseGitHubScopeHeaders } from "../../../shared/githubScopes"; +import type { SyncCredentialStore } from "../../../../../ade-cli/src/services/credentials/credentialStore"; import { nowIso, asString } from "../shared/utils"; const AUTH_STORE_FILE_NAME = "github-token.v1.bin"; +const MACHINE_TOKEN_KEY = "github.token.v1"; const GITHUB_API_TIMEOUT_MS = 20_000; async function fetchGitHub(input: string | URL, init: RequestInit): Promise { @@ -137,10 +139,12 @@ export function createGithubService({ logger, projectRoot, appDataDir, + credentialStore, }: { logger: Logger; projectRoot: string; appDataDir: string; + credentialStore?: SyncCredentialStore | null; }) { const legacyGithubStateDir = resolveAdeLayout(projectRoot).githubSecretsDir; const legacyTokenPath = path.join(legacyGithubStateDir, AUTH_STORE_FILE_NAME); @@ -148,6 +152,49 @@ export function createGithubService({ const tokenPath = path.join(githubStateDir, AUTH_STORE_FILE_NAME); let tokenDecryptionFailed = false; + let machineTokenReadFailed = false; + + const readMachineToken = (): string | null => { + if (!credentialStore) return null; + try { + const token = credentialStore.getSync(MACHINE_TOKEN_KEY)?.trim() ?? ""; + machineTokenReadFailed = false; + return token.length > 0 ? token : null; + } catch (error) { + machineTokenReadFailed = true; + logger.warn("github.machine_token_read_failed", { + error: error instanceof Error ? error.message : String(error), + }); + return null; + } + }; + + const persistMachineToken = (token: string | null): void => { + if (!credentialStore) return; + try { + const clean = (token ?? "").trim(); + if (clean) { + credentialStore.setSync(MACHINE_TOKEN_KEY, clean); + } else { + credentialStore.deleteSync(MACHINE_TOKEN_KEY); + } + machineTokenReadFailed = false; + } catch (error) { + machineTokenReadFailed = true; + logger.warn("github.machine_token_write_failed", { + error: error instanceof Error ? error.message : String(error), + }); + throw error; + } + }; + + const syncMachineToken = (token: string | null): void => { + try { + persistMachineToken(token); + } catch { + // persistMachineToken already logged the write failure. + } + }; const readEncryptedToken = (candidatePath: string): string | null => { if (!fs.existsSync(candidatePath)) return null; @@ -210,6 +257,7 @@ export function createGithubService({ const migrateLegacyTokenIfNeeded = (): string | null => { const globalToken = readEncryptedToken(tokenPath); if (globalToken) { + syncMachineToken(globalToken); tokenDecryptionFailed = false; migrationDone = true; return globalToken; @@ -222,6 +270,7 @@ export function createGithubService({ try { persistEncryptedToken(tokenPath, legacyToken); + syncMachineToken(legacyToken); removeTokenFile(legacyTokenPath); logger.info("github.token_migrated_to_global_store", { projectRoot }); } catch (error) { @@ -236,15 +285,21 @@ export function createGithubService({ }; const readStoredToken = (): string | null => { + const machineToken = readMachineToken(); + if (machineToken) { + tokenDecryptionFailed = false; + return machineToken; + } const token = migrateLegacyTokenIfNeeded(); if (token) return token; - tokenDecryptionFailed = false; + tokenDecryptionFailed = machineTokenReadFailed; const envToken = (process.env.GITHUB_TOKEN ?? process.env.ADE_GITHUB_TOKEN ?? "").trim(); return envToken.length > 0 ? envToken : null; }; const persistToken = (token: string | null): void => { persistEncryptedToken(tokenPath, token); + persistMachineToken(token); if (!(token ?? "").trim()) { removeTokenFile(legacyTokenPath); } else if (fs.existsSync(legacyTokenPath)) { diff --git a/apps/desktop/src/main/services/ipc/registerIpc.ts b/apps/desktop/src/main/services/ipc/registerIpc.ts index 4201bf417..247c88caa 100644 --- a/apps/desktop/src/main/services/ipc/registerIpc.ts +++ b/apps/desktop/src/main/services/ipc/registerIpc.ts @@ -241,16 +241,11 @@ import type { AgentChatClaudeOutputStylesArgs, AgentChatClaudePlugin, AgentChatClaudePluginsArgs, - AgentChatClaudeMcpReconnectArgs, - AgentChatClaudeMcpServerStatus, - AgentChatClaudeMcpStatusArgs, - AgentChatClaudeMcpToggleArgs, AgentChatReloadClaudePluginsArgs, AgentChatReloadClaudePluginsResult, AgentChatClaudePermissionMode, AgentChatCreateArgs, AgentChatDeleteArgs, - AgentChatDisposeArgs, AgentChatGetSummaryArgs, AgentChatEventEnvelope, AgentChatHandoffArgs, @@ -263,7 +258,6 @@ import type { AgentChatParallelLaunchStateArgs, AgentChatPermissionMode, AgentChatRespondToInputArgs, - AgentChatResumeArgs, AgentChatSendArgs, AgentChatSetParallelLaunchStateArgs, AgentChatSuggestLaneNameArgs, @@ -341,8 +335,11 @@ import type { RecentProjectSummary, PtyCreateArgs, PtyCreateResult, + PtySendToSessionArgs, + PtySendToSessionResult, ChatTerminalActiveForChatArgs, ChatTerminalListArgs, + ChatTerminalPreviewArgs, ChatTerminalReadArgs, ChatTerminalSignalArgs, ChatTerminalWriteArgs, @@ -718,6 +715,7 @@ import { spawnInNewTerminalWindow, } from "../chat/codexCliLauncher"; import { sanitizeResumeTargetId } from "../../utils/terminalSessionSignals"; +import { probeLocalhostPort } from "../probeLocalhostPort"; export type AppContext = { db: AdeDb; @@ -2013,6 +2011,7 @@ export function registerIpc({ [IPC.appControlTypeText]: new Set(["text"]), [IPC.appControlDispatchKey]: new Set(["text", "unmodifiedText", "key", "code"]), [IPC.terminalWrite]: new Set(["data"]), + [IPC.ptySendToSession]: new Set(["text"]), [IPC.ptyWrite]: new Set(["data"]), [IPC.builtInBrowserNavigate]: new Set(["url"]), [IPC.builtInBrowserCreateTab]: new Set(["url"]), @@ -3065,6 +3064,15 @@ export function registerIpc({ }; }; + const parseTerminalPreviewArgs = (value: unknown): ChatTerminalPreviewArgs => { + const record = terminalRecord(value); + return { + terminalId: optionalTerminalString(record, "terminalId", 128), + chatSessionId: optionalTerminalString(record, "chatSessionId", 128), + maxBytes: optionalTerminalNumber(record, "maxBytes", 1, 8 * 1024 * 1024), + }; + }; + const parseTerminalWriteArgs = (value: unknown): ChatTerminalWriteArgs => { const record = terminalRecord(value); const data = optionalTerminalString(record, "data", 100_000, false); @@ -3210,6 +3218,13 @@ export function registerIpc({ ipcMain.handle(IPC.appPing, async () => "pong" as const); + ipcMain.handle( + IPC.localhostProbePort, + async (_event, args: { port: number }): Promise => { + return probeLocalhostPort(args?.port); + }, + ); + ipcMain.on( IPC.appLogDebugEvent, (event, arg: { event?: string; payload?: Record | null }) => { @@ -5712,6 +5727,11 @@ export function registerIpc({ return ctx.laneService.cancelDelete(arg.laneId); }); + ipcMain.handle(IPC.lanesListDeleteProgress, async () => { + const ctx = getCtx(); + return ctx.laneService.listDeleteProgress(); + }); + ipcMain.handle(IPC.lanesGetDeleteRisk, async (_event, arg: { laneId: string }) => { const ctx = getCtx(); return await ctx.laneService.getDeleteRisk(arg.laneId); @@ -6160,6 +6180,9 @@ export function registerIpc({ prompt: record.prompt.trim(), modelId: record.modelId.trim(), laneId: record.laneId.trim(), + ...(typeof record.fallbackName === "string" && record.fallbackName.trim().length + ? { fallbackName: record.fallbackName.trim() } + : {}), }; }; @@ -6531,11 +6554,6 @@ export function registerIpc({ await ctx.agentChatService.interrupt(arg); }); - ipcMain.handle(IPC.agentChatResume, async (_event, arg: AgentChatResumeArgs): Promise => { - const ctx = getCtx(); - return await ctx.agentChatService.resumeSession(arg); - }); - ipcMain.handle(IPC.agentChatApprove, async (_event, arg: AgentChatApproveArgs): Promise => { const ctx = getCtx(); await ctx.agentChatService.approveToolUse(arg); @@ -6551,11 +6569,6 @@ export function registerIpc({ return await ctx.agentChatService.getAvailableModels(arg); }); - ipcMain.handle(IPC.agentChatDispose, async (_event, arg: AgentChatDisposeArgs): Promise => { - const ctx = getCtx(); - await ctx.agentChatService.dispose(arg); - }); - ipcMain.handle(IPC.agentChatArchive, async (_event, arg: AgentChatArchiveArgs): Promise => { const ctx = getCtx(); await ctx.agentChatService.archiveSession(arg); @@ -6586,21 +6599,6 @@ export function registerIpc({ return ctx.agentChatService.getSlashCommands(arg); }); - ipcMain.handle(IPC.agentChatClaudeMcpStatus, async (_event, arg: AgentChatClaudeMcpStatusArgs): Promise => { - const ctx = getCtx(); - return ctx.agentChatService.getClaudeMcpStatus(arg); - }); - - ipcMain.handle(IPC.agentChatClaudeMcpReconnect, async (_event, arg: AgentChatClaudeMcpReconnectArgs): Promise => { - const ctx = getCtx(); - return ctx.agentChatService.reconnectClaudeMcpServer(arg); - }); - - ipcMain.handle(IPC.agentChatClaudeMcpToggle, async (_event, arg: AgentChatClaudeMcpToggleArgs): Promise => { - const ctx = getCtx(); - return ctx.agentChatService.toggleClaudeMcpServer(arg); - }); - ipcMain.handle(IPC.agentChatListClaudePlugins, async (_event, arg: AgentChatClaudePluginsArgs = {}): Promise => { const ctx = getCtx(); return ctx.agentChatService.listClaudePlugins(arg); @@ -7419,6 +7417,11 @@ export function registerIpc({ return await ctx.ptyService.create(arg); }); + ipcMain.handle(IPC.ptySendToSession, async (_event, arg: PtySendToSessionArgs): Promise => { + const ctx = getCtx(); + return await ctx.ptyService.sendToSession(arg); + }); + ipcMain.handle(IPC.ptyWrite, async (_event, arg: { ptyId: string; data: string }): Promise => { const ctx = getCtx(); ctx.ptyService.write(arg); @@ -7442,6 +7445,10 @@ export function registerIpc({ getCtx().ptyService.readTerminal(parseTerminalReadArgs(arg)), ); + ipcMain.handle(IPC.terminalPreview, async (_event, arg) => + getCtx().ptyService.previewTerminal(parseTerminalPreviewArgs(arg)), + ); + ipcMain.handle(IPC.terminalWrite, async (_event, arg) => getCtx().ptyService.writeTerminal(parseTerminalWriteArgs(arg)), ); diff --git a/apps/desktop/src/main/services/lanes/laneService.test.ts b/apps/desktop/src/main/services/lanes/laneService.test.ts index 35a87e9ef..13d4178e6 100644 --- a/apps/desktop/src/main/services/lanes/laneService.test.ts +++ b/apps/desktop/src/main/services/lanes/laneService.test.ts @@ -2794,6 +2794,40 @@ describe("laneService delete teardown + cancellation + streaming", () => { expect(wtStep?.status).toBe("completed"); }); + it("keeps recent delete progress queryable for remounted renderers", async () => { + const events: any[] = []; + const fake = makeFakeServices(); + const { service } = await setupWithLane({ teardown: fake, events }); + vi.mocked(runGit).mockImplementation(async (args: string[]) => { + const laneBranchGitStub = defaultLaneBranchGitStub(args); + if (laneBranchGitStub) return laneBranchGitStub; + if (args[0] === "status") return { exitCode: 0, stdout: "", stderr: "" } as any; + if (args[0] === "show-ref") return { exitCode: 1, stdout: "", stderr: "" } as any; + if (args[0] === "worktree" && args[1] === "remove") { + return { exitCode: 0, stdout: "", stderr: "" } as any; + } + return { exitCode: 0, stdout: "", stderr: "" } as any; + }); + vi.mocked(runGitOrThrow).mockResolvedValue({ exitCode: 0, stdout: "", stderr: "" } as any); + + const deletePromise = service.delete({ laneId: "lane-child", deleteBranch: false }); + const runningProgress = service.listDeleteProgress(); + + expect(runningProgress).toHaveLength(1); + expect(runningProgress[0]?.laneId).toBe("lane-child"); + expect(runningProgress[0]?.overallStatus).toBe("running"); + + runningProgress[0]!.steps[0]!.status = "failed"; + expect(service.listDeleteProgress()[0]?.steps[0]?.status).not.toBe("failed"); + + await deletePromise; + + const completedProgress = service.listDeleteProgress(); + expect(completedProgress).toHaveLength(1); + expect(completedProgress[0]?.laneId).toBe("lane-child"); + expect(completedProgress[0]?.overallStatus).toBe("completed"); + }); + it("deletes the lane locally when optional remote branch cleanup fails", async () => { const events: any[] = []; const fake = makeFakeServices(); diff --git a/apps/desktop/src/main/services/lanes/laneService.ts b/apps/desktop/src/main/services/lanes/laneService.ts index 0bc7cb222..1b6b1a149 100644 --- a/apps/desktop/src/main/services/lanes/laneService.ts +++ b/apps/desktop/src/main/services/lanes/laneService.ts @@ -654,6 +654,19 @@ function isActiveProcess(p: ProcessRuntime): boolean { return p.status === "starting" || p.status === "running" || p.status === "degraded" || p.status === "stopping"; } +const LANE_DELETE_PROGRESS_HISTORY_TTL_MS = 60_000; + +function cloneLaneDeleteProgress(progress: LaneDeleteProgress): LaneDeleteProgress { + return { + ...progress, + steps: progress.steps.map((step) => ({ ...step })), + }; +} + +function isTerminalLaneDeleteProgress(progress: LaneDeleteProgress): boolean { + return progress.overallStatus !== "running"; +} + export type LaneDeleteTeardownDeps = { processService?: { listRuntime: (laneId: string) => ProcessRuntime[]; @@ -1884,10 +1897,24 @@ export function createLaneService({ return false; }; + const deleteProgressByLaneId = new Map(); + + const pruneDeleteProgressHistory = (now = Date.now()): void => { + for (const [laneId, progress] of deleteProgressByLaneId.entries()) { + if (!isTerminalLaneDeleteProgress(progress)) continue; + const completedAtMs = progress.completedAt ? Date.parse(progress.completedAt) : Number.NaN; + if (!Number.isFinite(completedAtMs) || now - completedAtMs >= LANE_DELETE_PROGRESS_HISTORY_TTL_MS) { + deleteProgressByLaneId.delete(laneId); + } + } + }; + const broadcastDeleteEvent = (progress: LaneDeleteProgress): void => { + pruneDeleteProgressHistory(); + deleteProgressByLaneId.set(progress.laneId, cloneLaneDeleteProgress(progress)); if (!onDeleteEvent) return; try { - onDeleteEvent({ type: "lane-delete", progress: { ...progress, steps: progress.steps.map((s) => ({ ...s })) } }); + onDeleteEvent({ type: "lane-delete", progress: cloneLaneDeleteProgress(progress) }); } catch (err) { logger.warn("lane.delete.broadcast_failed", { laneId: progress.laneId, error: err instanceof Error ? err.message : String(err) }); } @@ -3514,6 +3541,13 @@ export function createLaneService({ invalidateLaneListCache(); }, + listDeleteProgress(): LaneDeleteProgress[] { + pruneDeleteProgressHistory(); + return Array.from(deleteProgressByLaneId.values()) + .filter((progress) => progress.overallStatus === "running" || progress.overallStatus === "completed" || progress.overallStatus === "completed_with_warnings") + .map(cloneLaneDeleteProgress); + }, + async delete( args: DeleteLaneArgs, runtimeOpts?: { teardownEnv?: () => Promise } @@ -3527,6 +3561,9 @@ export function createLaneService({ } = args; const row = getLaneRow(laneId); if (!row) throw new Error(`Lane not found: ${laneId}`); + if (deleteProgressByLaneId.get(laneId)?.overallStatus === "running") { + throw new Error(`Lane delete is already running: ${laneId}`); + } if (row.lane_type === "primary") { throw new Error("Primary lane cannot be deleted"); } diff --git a/apps/desktop/src/main/services/localRuntime/localRuntimeConnectionPool.ts b/apps/desktop/src/main/services/localRuntime/localRuntimeConnectionPool.ts index f35cb05af..742550b64 100644 --- a/apps/desktop/src/main/services/localRuntime/localRuntimeConnectionPool.ts +++ b/apps/desktop/src/main/services/localRuntime/localRuntimeConnectionPool.ts @@ -4,7 +4,7 @@ import fs from "node:fs"; import net from "node:net"; import path from "node:path"; import { app } from "electron"; -import { isAdeMcpNamedPipePath } from "../../../shared/adeMcpIpc"; +import { isAdeRuntimeNamedPipePath } from "../../../shared/adeRuntimeIpc"; import type { RemoteRuntimeActionRequest, RemoteRuntimeActionResult, @@ -132,7 +132,7 @@ function resolveCliScriptPath(): string { function openSocketTransport(socketPath: string, timeoutMs = 3_000): Promise { return new Promise((resolve, reject) => { - const socket = isAdeMcpNamedPipePath(socketPath) + const socket = isAdeRuntimeNamedPipePath(socketPath) ? net.createConnection(socketPath) : net.createConnection({ path: socketPath }); let settled = false; diff --git a/apps/desktop/src/main/services/opencode/openCodeRuntime.test.ts b/apps/desktop/src/main/services/opencode/openCodeRuntime.test.ts index d5b7575fd..defb304a2 100644 --- a/apps/desktop/src/main/services/opencode/openCodeRuntime.test.ts +++ b/apps/desktop/src/main/services/opencode/openCodeRuntime.test.ts @@ -140,41 +140,6 @@ describe("openCodeRuntime", () => { expect(mockState.sharedLease.close).toHaveBeenCalledWith("handle_close"); }); - it("registers ADE stdio MCP when requested for coordinator sessions", () => { - const config = buildOpenCodeMergedConfig({ - projectConfig: { ai: {} }, - adeMcp: { - projectRoot: "/repo", - workspaceRoot: "/repo/.ade/worktrees/mission", - missionId: "mission-1", - runId: "run-1", - role: "orchestrator", - }, - }) as any; - - expect(config.mcp?.ade).toEqual(expect.objectContaining({ - type: "local", - enabled: true, - timeout: 10_000, - environment: expect.objectContaining({ - ADE_DEFAULT_ROLE: "orchestrator", - ADE_MCP_TOOL_SCOPE: "coordinator", - ADE_MISSION_ID: "mission-1", - ADE_RUN_ID: "run-1", - }), - })); - expect(config.mcp?.ade.command).toEqual(expect.arrayContaining([ - "--headless", - "--project-root", - "/repo", - "--workspace-root", - "/repo/.ade/worktrees/mission", - "--role", - "orchestrator", - "mcp", - ])); - }); - it("omits the session title when ADE wants OpenCode to auto-name", async () => { await startOpenCodeSession({ directory: "/repo", diff --git a/apps/desktop/src/main/services/opencode/openCodeRuntime.ts b/apps/desktop/src/main/services/opencode/openCodeRuntime.ts index d554b2c05..8668127c7 100644 --- a/apps/desktop/src/main/services/opencode/openCodeRuntime.ts +++ b/apps/desktop/src/main/services/opencode/openCodeRuntime.ts @@ -24,7 +24,6 @@ import type { OpenCodeRuntimeSnapshot, ProjectConfigFile, } from "../../../shared/types"; -import { isAdeMcpNamedPipePath } from "../../../shared/adeMcpIpc"; import { stableStringify } from "../shared/utils"; import { resolveOpenCodeBinaryPath } from "./openCodeBinaryManager"; import type { PermissionMode } from "../ai/tools/universalTools"; @@ -54,14 +53,6 @@ const ADE_PLAN_TOOL_SELECTION: Record = { skills: false, }; -export type OpenCodeAdeMcpConfig = { - projectRoot: string; - workspaceRoot: string; - missionId: string; - runId: string; - role?: "orchestrator" | "agent" | "cto" | "external" | "evaluator"; -}; - export type OpenCodeSessionHandle = { client: OpencodeClient; server: { @@ -96,8 +87,6 @@ type BuildOpenCodeConfigArgs = { projectConfig: ProjectConfigFile | EffectiveProjectConfig; /** Dynamically discovered models from local provider endpoints (e.g. LM Studio /v1/models). */ discoveredLocalModels?: DiscoveredLocalModelEntry[]; - /** Optional ADE stdio MCP bridge used by mission coordinators. */ - adeMcp?: OpenCodeAdeMcpConfig | null; }; type StartOpenCodeSessionArgs = BuildOpenCodeConfigArgs & { @@ -289,70 +278,6 @@ function buildProviderConfig( return Object.keys(provider).length > 0 ? provider : undefined; } -function fileExists(filePath: string | null | undefined): filePath is string { - if (!filePath) return false; - try { - return fs.statSync(filePath).isFile(); - } catch { - return false; - } -} - -function resolveAdeCliCommandPath(): string | null { - const envCli = trimToUndefined(process.env.ADE_CLI_PATH); - if (fileExists(envCli)) return envCli; - - const envBin = trimToUndefined(process.env.ADE_CLI_BIN_DIR); - const envBinCli = envBin ? path.join(envBin, process.platform === "win32" ? "ade.cmd" : "ade") : null; - if (fileExists(envBinCli)) return envBinCli; - - const candidateRoots = [ - process.cwd(), - path.resolve(process.cwd(), ".."), - path.resolve(process.cwd(), "..", ".."), - ]; - for (const root of candidateRoots) { - const repoBuilt = path.join(root, "apps", "ade-cli", "dist", "cli.cjs"); - if (fileExists(repoBuilt)) return repoBuilt; - const siblingBuilt = path.join(root, "ade-cli", "dist", "cli.cjs"); - if (fileExists(siblingBuilt)) return siblingBuilt; - } - - return null; -} - -function buildAdeMcpCommand(args: OpenCodeAdeMcpConfig): { command: string[]; environment: Record } { - const cliPath = resolveAdeCliCommandPath(); - const commandPrefix = cliPath && /\.(?:cjs|mjs|js)$/i.test(cliPath) - ? [process.execPath, cliPath] - : [cliPath ?? "ade"]; - const role = args.role ?? "orchestrator"; - return { - command: [ - ...commandPrefix, - "--headless", - "--project-root", - args.projectRoot, - "--workspace-root", - args.workspaceRoot, - "--role", - role, - "mcp", - ], - environment: { - ADE_DEFAULT_ROLE: role, - ADE_CLI_HEADLESS: "1", - ADE_MCP_TOOL_SCOPE: "coordinator", - ADE_PROJECT_ROOT: args.projectRoot, - ADE_WORKSPACE_ROOT: args.workspaceRoot, - ADE_MISSION_ID: args.missionId, - ADE_RUN_ID: args.runId, - ADE_CLI_NODE: process.execPath, - ...(process.env.PATH ? { PATH: process.env.PATH } : {}), - }, - }; -} - export function buildOpenCodeConfig(args: BuildOpenCodeConfigArgs): OpenCodeConfig { const provider = buildProviderConfig(args.projectConfig, args.discoveredLocalModels); const helperPermission = { @@ -362,26 +287,12 @@ export function buildOpenCodeConfig(args: BuildOpenCodeConfigArgs): OpenCodeConf doom_loop: "deny", external_directory: "deny", } as const; - const adeMcp = args.adeMcp ? buildAdeMcpCommand(args.adeMcp) : null; return { share: "disabled", autoupdate: false, snapshot: false, ...(provider ? { provider } : {}), - ...(adeMcp - ? { - mcp: { - ade: { - type: "local", - command: adeMcp.command, - environment: adeMcp.environment, - enabled: true, - timeout: 10_000, - }, - }, - } - : {}), agent: { "ade-plan": { permission: buildPermissionConfig("plan"), diff --git a/apps/desktop/src/main/services/orchestrator/coordinatorAgent.test.ts b/apps/desktop/src/main/services/orchestrator/coordinatorAgent.test.ts index 3ec7c8c45..044460e3f 100644 --- a/apps/desktop/src/main/services/orchestrator/coordinatorAgent.test.ts +++ b/apps/desktop/src/main/services/orchestrator/coordinatorAgent.test.ts @@ -2533,12 +2533,7 @@ describe("CoordinatorAgent", () => { const startCalls = vi.mocked(startOpenCodeSession).mock.calls; expect(startCalls.length).toBeGreaterThan(0); expect(startCalls[0]?.[0]?.leaseKind).toBe("shared"); - expect(Object.keys(startCalls[0]?.[0] ?? {}).sort()).toEqual(expect.arrayContaining(["adeMcp", "directory", "leaseKind", "ownerId", "ownerKind"])); - expect(startCalls[0]?.[0]?.adeMcp).toEqual(expect.objectContaining({ - missionId: "mission-1", - runId: "run-1", - role: "orchestrator", - })); + expect(Object.keys(startCalls[0]?.[0] ?? {}).sort()).toEqual(expect.arrayContaining(["directory", "leaseKind", "ownerId", "ownerKind"])); expect(mockState.promptAsync).toHaveBeenCalledWith(expect.objectContaining({ body: expect.objectContaining({ agent: "ade-plan", diff --git a/apps/desktop/src/main/services/orchestrator/coordinatorAgent.ts b/apps/desktop/src/main/services/orchestrator/coordinatorAgent.ts index 2c87c075e..ec6774cee 100644 --- a/apps/desktop/src/main/services/orchestrator/coordinatorAgent.ts +++ b/apps/desktop/src/main/services/orchestrator/coordinatorAgent.ts @@ -1151,13 +1151,6 @@ export class CoordinatorAgent { ownerKey: `coordinator:${this.deps.runId}`, leaseKind: "shared", logger: this.deps.logger, - adeMcp: { - projectRoot: this.deps.projectRoot, - workspaceRoot: this.deps.workspaceRoot, - missionId: this.deps.missionId, - runId: this.deps.runId, - role: "orchestrator", - }, }); const registeredHandle = this.openCodeHandle; registeredHandle.setEvictionHandler((reason) => { @@ -1632,7 +1625,7 @@ export class CoordinatorAgent { "PLANNING STARTUP REQUIRED:", "Your next response must use ADE mission-control tools. Do not answer with text only.", "Call get_project_context first, then call spawn_worker exactly once to launch a read-only planning worker.", - "If the tools are exposed with ADE/MCP prefixes, use the available prefixed names such as ade_get_project_context and ade_spawn_worker.", + "Use the mission-control tool names directly or with an ade_ prefix, such as get_project_context and spawn_worker.", `${modelInstruction} The worker prompt must include the full mission goal, the planning phase instructions, and clear instructions to discover the plan, avoid file edits, and return the plan through report_result.`, "Do not call native OpenCode repo/search/shell/web/planner tools before the planner is spawned.", "", @@ -3497,7 +3490,7 @@ export class CoordinatorAgent { "Your previous response did not create the required planning worker.", "Your next response must use ADE mission-control tools only.", "Call get_project_context first, then call spawn_worker exactly once to launch the read-only planning worker.", - "If tools are exposed with ADE/MCP prefixes, use ade_get_project_context and ade_spawn_worker.", + "Use get_project_context and spawn_worker directly, or with an ade_ prefix if ADE asks for one.", "Do not answer with text, do not use native OpenCode tools, and do not do coordinator-side analysis before the planner is running.", ].join("\n")); return "retry_scheduled"; @@ -3910,8 +3903,8 @@ export class CoordinatorAgent { planningPhaseSection = `\n## Planning Startup Contract (hard gate) The first coordinator turn of a mission whose first phase is Planning must create the planner through ADE mission-control tools: - Do not reply with text only on the first turn. -- First call get_project_context, using the ADE/MCP-prefixed equivalent if that is how the tool is exposed. -- Then call spawn_worker exactly once, using the ADE/MCP-prefixed equivalent if needed. +- First call get_project_context, using the ade_ prefix only if ADE asks for it. +- Then call spawn_worker exactly once, using the ade_ prefix only if needed. - The planning worker must be read-only, must receive the full mission goal and planning phase instructions, and must return the plan through report_result. - Do not call native OpenCode repo/search/shell/web/planner tools before the planner exists. Runtime pauses the run on those startup violations. diff --git a/apps/desktop/src/main/services/orchestrator/delegationContracts.ts b/apps/desktop/src/main/services/orchestrator/delegationContracts.ts index 6ec208053..3852fcd0c 100644 --- a/apps/desktop/src/main/services/orchestrator/delegationContracts.ts +++ b/apps/desktop/src/main/services/orchestrator/delegationContracts.ts @@ -50,8 +50,6 @@ export function normalizeCoordinatorToolName(toolName: string): string { const trimmed = toolName.trim(); const prefixPatterns = [ /^ade[_:.](.+)$/i, - /^mcp[_:.]ade[_:.](.+)$/i, - /^mcp__ade__(.+)$/i, ]; for (const pattern of prefixPatterns) { const match = pattern.exec(trimmed); diff --git a/apps/desktop/src/main/services/probeLocalhostPort.test.ts b/apps/desktop/src/main/services/probeLocalhostPort.test.ts new file mode 100644 index 000000000..3b3445eb6 --- /dev/null +++ b/apps/desktop/src/main/services/probeLocalhostPort.test.ts @@ -0,0 +1,54 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { createServer, type Server } from "node:net"; +import { probeLocalhostPort } from "./probeLocalhostPort"; + +const openServers: Server[] = []; + +async function listenOnLoopback(): Promise<{ server: Server; port: number }> { + return new Promise((resolve, reject) => { + const server = createServer(); + server.unref(); + server.once("error", reject); + server.listen({ host: "127.0.0.1", port: 0 }, () => { + const address = server.address(); + if (!address || typeof address === "string") { + server.close(); + reject(new Error("unexpected server address")); + return; + } + openServers.push(server); + resolve({ server, port: address.port }); + }); + }); +} + +async function closeServer(server: Server): Promise { + await new Promise((resolve) => server.close(() => resolve())); +} + +afterEach(async () => { + while (openServers.length) { + const server = openServers.pop()!; + await closeServer(server); + } +}); + +describe("probeLocalhostPort", () => { + it("returns true when a TCP listener is bound to the loopback port", async () => { + const { port } = await listenOnLoopback(); + await expect(probeLocalhostPort(port)).resolves.toBe(true); + }); + + it("returns false when nothing is listening on the port", async () => { + const { server, port } = await listenOnLoopback(); + await closeServer(server); + openServers.splice(openServers.indexOf(server), 1); + await expect(probeLocalhostPort(port, 250)).resolves.toBe(false); + }); + + it("rejects invalid ports without crashing", async () => { + await expect(probeLocalhostPort(0)).resolves.toBe(false); + await expect(probeLocalhostPort(70_000)).resolves.toBe(false); + await expect(probeLocalhostPort(Number.NaN)).resolves.toBe(false); + }); +}); diff --git a/apps/desktop/src/main/services/probeLocalhostPort.ts b/apps/desktop/src/main/services/probeLocalhostPort.ts new file mode 100644 index 000000000..c8a5980e5 --- /dev/null +++ b/apps/desktop/src/main/services/probeLocalhostPort.ts @@ -0,0 +1,30 @@ +import { createConnection } from "node:net"; + +const DEFAULT_TIMEOUT_MS = 150; + +export async function probeLocalhostPort( + port: number, + timeoutMs: number = DEFAULT_TIMEOUT_MS, +): Promise { + if (!Number.isInteger(port) || port < 1 || port > 65_535) return false; + + return new Promise((resolve) => { + let settled = false; + const settle = (value: boolean) => { + if (settled) return; + settled = true; + try { + socket.destroy(); + } catch { + // ignore + } + resolve(value); + }; + + const socket = createConnection({ host: "127.0.0.1", port }); + socket.setTimeout(timeoutMs); + socket.once("connect", () => settle(true)); + socket.once("timeout", () => settle(false)); + socket.once("error", () => settle(false)); + }); +} diff --git a/apps/desktop/src/main/services/pty/ptyService.test.ts b/apps/desktop/src/main/services/pty/ptyService.test.ts index e01676906..ab186faea 100644 --- a/apps/desktop/src/main/services/pty/ptyService.test.ts +++ b/apps/desktop/src/main/services/pty/ptyService.test.ts @@ -97,8 +97,17 @@ const mocks = vi.hoisted(() => { }; return stream; }), + readFileSync: vi.fn((p: string) => { + if (!fileContents.has(p)) { + const error = new Error(`ENOENT: no such file or directory, open '${p}'`) as NodeJS.ErrnoException; + error.code = "ENOENT"; + throw error; + } + return fileContents.get(p) ?? ""; + }), unlinkSync: vi.fn(), writeFileSync: vi.fn(), + renameSync: vi.fn(), randomUUID: vi.fn(() => "uuid-" + Math.random().toString(36).slice(2, 10)), runGit: vi.fn(async () => ({ exitCode: 0, stdout: "abc123\n", stderr: "" })), stripAnsi: vi.fn((t: string) => t), @@ -124,8 +133,10 @@ vi.mock("node:fs", () => ({ closeSync: mocks.closeSync, chmodSync: mocks.chmodSync, createWriteStream: mocks.createWriteStream, + readFileSync: mocks.readFileSync, unlinkSync: mocks.unlinkSync, writeFileSync: mocks.writeFileSync, + renameSync: mocks.renameSync, }, existsSync: mocks.existsSync, lstatSync: mocks.lstatSync, @@ -138,8 +149,10 @@ vi.mock("node:fs", () => ({ closeSync: mocks.closeSync, chmodSync: mocks.chmodSync, createWriteStream: mocks.createWriteStream, + readFileSync: mocks.readFileSync, unlinkSync: mocks.unlinkSync, writeFileSync: mocks.writeFileSync, + renameSync: mocks.renameSync, })); vi.mock("node:crypto", () => ({ @@ -1168,6 +1181,150 @@ describe("ptyService", () => { ); }); + it("sendToSession writes to a live PTY without spawning another runtime", async () => { + const { service, mockPty, loadPty } = createHarness(); + const created = await service.create({ + sessionId: "session-live-send", + allowNewSessionId: true, + laneId: "lane-1", + title: "Codex CLI", + cols: 80, + rows: 24, + toolType: "codex", + startupCommand: "codex", + }); + (mockPty.write as unknown as { mockClear(): void }).mockClear(); + loadPty.mockClear(); + + const result = await service.sendToSession({ + sessionId: created.sessionId, + text: "keep going", + }); + + expect(result).toEqual(expect.objectContaining({ + sessionId: "session-live-send", + ptyId: created.ptyId, + resumed: false, + reusedExistingRuntime: true, + })); + expect(mockPty.write).toHaveBeenCalledWith("keep going\r"); + expect(loadPty).not.toHaveBeenCalled(); + }); + + it("sendToSession resumes an ended tracked CLI session and writes the message", async () => { + const { service, sessionService, mockPty } = createHarness(); + sessionService.create({ + sessionId: "session-ended-send", + laneId: "lane-1", + ptyId: null, + tracked: true, + title: "Codex CLI", + startedAt: "2026-04-09T12:00:00.000Z", + transcriptPath: "/tmp/transcripts/session-ended-send.log", + toolType: "codex", + resumeCommand: "codex resume thread-ended", + resumeMetadata: { + provider: "codex", + targetKind: "thread", + targetId: "thread-ended", + launch: { permissionMode: "plan" }, + }, + }); + sessionService.end({ + sessionId: "session-ended-send", + endedAt: "2026-04-09T12:30:00.000Z", + exitCode: 0, + status: "completed", + }); + + const result = await service.sendToSession({ + sessionId: "session-ended-send", + text: "fix failing tests", + cols: 120, + rows: 40, + model: "gpt-5.4", + reasoningEffort: "high", + permissionMode: "plan", + }); + + expect(result).toEqual(expect.objectContaining({ + sessionId: "session-ended-send", + resumed: true, + reusedExistingRuntime: false, + })); + expect(sessionService.reattach).toHaveBeenCalledWith({ + sessionId: "session-ended-send", + ptyId: expect.any(String), + startedAt: expect.any(String), + }); + expect(mockPty.write).toHaveBeenCalledWith("codex --no-alt-screen --model gpt-5.4 -c 'model_reasoning_effort=\"high\"' --sandbox read-only --ask-for-approval on-request resume thread-ended\r"); + expect(mockPty.write).toHaveBeenCalledWith("fix failing tests\r"); + }); + + it("sendToSession single-flights concurrent resumes for the same session", async () => { + const { service, sessionService, mockPty, loadPty } = createHarness(); + sessionService.create({ + sessionId: "session-concurrent-send", + laneId: "lane-1", + ptyId: null, + tracked: true, + title: "Codex CLI", + startedAt: "2026-04-09T12:00:00.000Z", + transcriptPath: "/tmp/transcripts/session-concurrent-send.log", + toolType: "codex", + resumeCommand: "codex resume thread-concurrent", + resumeMetadata: { + provider: "codex", + targetKind: "thread", + targetId: "thread-concurrent", + launch: { permissionMode: "default" }, + }, + }); + sessionService.end({ + sessionId: "session-concurrent-send", + endedAt: "2026-04-09T12:30:00.000Z", + exitCode: 0, + status: "completed", + }); + + const [first, second] = await Promise.all([ + service.sendToSession({ sessionId: "session-concurrent-send", text: "first" }), + service.sendToSession({ sessionId: "session-concurrent-send", text: "second" }), + ]); + + expect(first.ptyId).toBe(second.ptyId); + expect(loadPty).toHaveBeenCalledTimes(1); + expect(mockPty.write).toHaveBeenCalledWith("first\r"); + expect(mockPty.write).toHaveBeenCalledWith("second\r"); + }); + + it("sendToSession rejects ended shell sessions", async () => { + const { service, sessionService } = createHarness(); + sessionService.create({ + sessionId: "session-shell-ended", + laneId: "lane-1", + ptyId: null, + tracked: true, + title: "Shell", + startedAt: "2026-04-09T12:00:00.000Z", + transcriptPath: "/tmp/transcripts/session-shell-ended.log", + toolType: "shell", + resumeCommand: null, + resumeMetadata: null, + }); + sessionService.end({ + sessionId: "session-shell-ended", + endedAt: "2026-04-09T12:30:00.000Z", + exitCode: 0, + status: "completed", + }); + + await expect(service.sendToSession({ + sessionId: "session-shell-ended", + text: "hello", + })).rejects.toThrow(/not an agent CLI session/i); + }); + it("rejects reattaching a session into the wrong lane", async () => { const { service, sessionService } = createHarness(); sessionService.create({ @@ -1306,7 +1463,6 @@ describe("ptyService", () => { }); it.each([ - ["claude", "Claude session"], ["codex", "Codex session"], ["cursor-cli", "Cursor Agent CLI"], ["droid", "Factory Droid CLI"], @@ -1408,6 +1564,100 @@ describe("ptyService", () => { expect(sessionService.get(createdSessionId)?.goal).toBe("Fix the flaky login tests"); }); + it("drops accidental leading slash markers from natural-language Claude titles", async () => { + const { service, sessionService } = createHarness(); + const { ptyId } = await service.create({ + laneId: "lane-1", + title: "Claude Code", + cols: 80, + rows: 24, + toolType: "claude", + }); + + const createdSessionId = (sessionService.create as ReturnType).mock.calls[0]?.[0]?.sessionId; + expect(createdSessionId).toBeTruthy(); + + service.write({ ptyId, data: "/this is a test\r" }); + + expect(sessionService.get(createdSessionId)?.title).toBe("This is a test"); + expect(sessionService.get(createdSessionId)?.goal).toBe("/this is a test"); + }); + + it("adopts Claude Code runtime ai-title from local session storage", async () => { + vi.useFakeTimers(); + try { + const claudeSessionId = "123e4567-e89b-12d3-a456-426614174000"; + const claudeFilePath = path.join( + os.homedir(), + ".claude", + "projects", + "-tmp-test-worktree", + `${claudeSessionId}.jsonl`, + ); + mocks.fileContents.set(claudeFilePath, ""); + + const { service, sessionService } = createHarness(); + const { ptyId } = await service.create({ + laneId: "lane-1", + title: "Claude Code", + cols: 80, + rows: 24, + toolType: "claude", + startupCommand: `claude --session-id ${claudeSessionId} --append-system-prompt guidance`, + }); + + const createdSessionId = (sessionService.create as ReturnType).mock.calls[0]?.[0]?.sessionId; + expect(createdSessionId).toBeTruthy(); + + service.write({ ptyId, data: "/this is a test\r" }); + expect(sessionService.get(createdSessionId)?.title).toBe("This is a test"); + + const runtimeTitle = JSON.stringify({ + type: "ai-title", + sessionId: claudeSessionId, + aiTitle: "Test session setup", + }); + mocks.fileContents.set(claudeFilePath, `${runtimeTitle}\n`); + + await vi.advanceTimersByTimeAsync(1_000); + + expect(sessionService.get(createdSessionId)?.title).toBe("Test session setup"); + expect(sessionService.updateMeta).toHaveBeenCalledWith( + expect.objectContaining({ + sessionId: createdSessionId, + title: "Test session setup", + manuallyNamed: false, + }), + ); + } finally { + vi.useRealTimers(); + } + }); + + it("does not call ADE AI title generation for Claude CLI sessions", async () => { + const aiIntegrationService = { + getMode: vi.fn(() => "subscription"), + summarizeTerminal: vi.fn(async () => ({ text: "ADE generated title" })), + }; + const { service, sessionService } = createHarness({ aiIntegrationService }); + const { ptyId } = await service.create({ + laneId: "lane-1", + title: "Claude Code", + cols: 80, + rows: 24, + toolType: "claude", + }); + + const createdSessionId = (sessionService.create as ReturnType).mock.calls[0]?.[0]?.sessionId; + expect(createdSessionId).toBeTruthy(); + + service.write({ ptyId, data: "Fix the flaky login tests\r" }); + await Promise.resolve(); + + expect(sessionService.get(createdSessionId)?.title).toBe("Fix the flaky login tests"); + expect(aiIntegrationService.summarizeTerminal).not.toHaveBeenCalled(); + }); + it("treats legacy slash-command CLI titles as placeholders", async () => { const { service, sessionService } = createHarness(); const { ptyId } = await service.create({ @@ -1922,6 +2172,15 @@ describe("ptyService", () => { ); }); + it("marks signal-terminated sessions as disposed instead of failed", async () => { + const { service, mockPty, sessionService } = createHarness(); + const { sessionId } = await service.create({ laneId: "lane-1", title: "t", cols: 80, rows: 24 }); + mockPty._emitter.emit("exit", { exitCode: 143 }); + expect(sessionService.end).toHaveBeenCalledWith( + expect.objectContaining({ sessionId, exitCode: 143, status: "disposed" }), + ); + }); + it("marks session as completed when exit code is null", async () => { const { service, mockPty, sessionService } = createHarness(); const { sessionId } = await service.create({ laneId: "lane-1", title: "t", cols: 80, rows: 24 }); @@ -2015,8 +2274,8 @@ describe("ptyService", () => { it("backfills Codex storage resume targets during session-list hydration", async () => { // The session-list path is how older sessions (whose transcripts no // longer contain an explicit resume command) get their resume target - // backfilled. The same storage fallback also runs from resume-launch so - // pressing Resume does not fall through to Codex's picker. + // backfilled. The same storage fallback also runs from continuation + // launch so Codex does not fall through to the picker. vi.useFakeTimers(); try { const fakeNow = new Date("2026-04-15T22:00:00.000Z"); @@ -2103,6 +2362,54 @@ describe("ptyService", () => { } }); + it("captures Claude runtime titles for sessions that already have a resume target", async () => { + vi.useFakeTimers(); + try { + const claudeSessionId = "5647da1e-10de-4089-bce2-00b9c2552bfc"; + const filePath = path.join( + os.homedir(), + ".claude", + "projects", + "-tmp-worktree", + `${claudeSessionId}.jsonl`, + ); + mocks.fileContents.set(filePath, `${JSON.stringify({ + type: "ai-title", + sessionId: claudeSessionId, + aiTitle: "Patched exit works", + })}\n`); + + const { service, sessionService } = createHarness(); + sessionService.create({ + sessionId: "session-claude-existing", + laneId: "lane-1", + ptyId: null, + tracked: true, + title: "Say exactly: patched exit works", + startedAt: "2026-04-15T21:30:00.000Z", + transcriptPath: "/tmp/worktree/.ade/transcripts/session-claude-existing.log", + toolType: "claude", + resumeMetadata: { + provider: "claude", + targetKind: "session", + targetId: claudeSessionId, + launch: { permissionMode: "default" }, + }, + }); + + await service.ensureResumeTargets(["session-claude-existing"]); + await vi.advanceTimersByTimeAsync(0); + + expect(sessionService.updateMeta).toHaveBeenCalledWith(expect.objectContaining({ + sessionId: "session-claude-existing", + title: "Patched exit works", + manuallyNamed: false, + })); + } finally { + vi.useRealTimers(); + } + }); + it("captures a fresh Codex storage target for a new launch without choosing older same-cwd sessions", async () => { vi.useFakeTimers(); try { @@ -2342,6 +2649,34 @@ describe("ptyService", () => { expect(list[0]?.active).toBe(true); }); + it("listTerminals excludes persisted agent chat transcript rows", async () => { + const { service, sessionService } = createChatHarness(); + + const terminal = await service.create({ + laneId: "lane-1", + title: "Claude Code", + cols: 80, + rows: 24, + toolType: "claude", + }); + sessionService.create({ + sessionId: "chat-row", + laneId: "lane-1", + ptyId: null, + tracked: true, + title: "Claude Chat", + startedAt: new Date().toISOString(), + transcriptPath: "/tmp/chat-row.jsonl", + toolType: "claude-chat", + }); + + const ids = service.listTerminals({ laneId: "lane-1" }).map((session) => session.terminalId); + + expect(ids).toContain(terminal.sessionId); + expect(ids).not.toContain("chat-row"); + await expect(service.previewTerminal({ terminalId: "chat-row" })).rejects.toThrow("not a terminal"); + }); + it("readTerminal returns transcript bytes from `since` and reports nextSince", async () => { const { service, sessionService } = createChatHarness(); const created = await service.create({ @@ -2377,6 +2712,30 @@ describe("ptyService", () => { ); }); + it("previewTerminal returns a transcript fallback without resuming the terminal", async () => { + const { service, sessionService } = createChatHarness(); + const created = await service.create({ + laneId: "lane-1", + title: "Preview", + cols: 80, + rows: 24, + chatSessionId: "chat-preview", + }); + sessionService.readTranscriptTail.mockResolvedValueOnce("raw transcript"); + + const preview = await service.previewTerminal({ terminalId: created.sessionId, maxBytes: 4096 }); + + expect(preview.terminalId).toBe(created.sessionId); + expect(preview.source).toBe("transcript"); + expect(preview.transcript).toBe("raw transcript"); + expect(preview.snapshot).toBeNull(); + expect(sessionService.readTranscriptTail).toHaveBeenCalledWith( + expect.stringContaining("/tmp/transcripts/"), + 4096, + { raw: true, alignToLineBoundary: true }, + ); + }); + it("writeTerminal routes data via the active chat terminal and the underlying PTY", async () => { const { service, mockPty } = createChatHarness(); await service.create({ @@ -2392,6 +2751,22 @@ describe("ptyService", () => { expect(mockPty.write).toHaveBeenCalledWith("y\n"); }); + it("resizeTerminal resizes the active chat terminal", async () => { + const { service, mockPty } = createChatHarness(); + await service.create({ + laneId: "lane-1", + title: "Resize", + cols: 80, + rows: 24, + chatSessionId: "chat-resize", + }); + + const result = service.resizeTerminal({ chatSessionId: "chat-resize", cols: 120, rows: 30 }); + + expect(result).toEqual({ ok: true, cols: 120, rows: 30 }); + expect(mockPty.resize).toHaveBeenCalledWith(120, 30); + }); + it("signalTerminal sends ^C for SIGINT and forwards SIGTERM to pty.kill", async () => { const { service, mockPty } = createChatHarness(); await service.create({ diff --git a/apps/desktop/src/main/services/pty/ptyService.ts b/apps/desktop/src/main/services/pty/ptyService.ts index 20fa4401c..89fd7c077 100644 --- a/apps/desktop/src/main/services/pty/ptyService.ts +++ b/apps/desktop/src/main/services/pty/ptyService.ts @@ -5,6 +5,9 @@ import { randomUUID } from "node:crypto"; import { spawnSync } from "node:child_process"; import type { IPty, IWindowsPtyForkOptions } from "node-pty"; import type * as ptyNs from "node-pty"; +import * as HeadlessXterm from "@xterm/headless"; +import type { IBufferCell } from "@xterm/headless"; +import * as XtermSerialize from "@xterm/addon-serialize"; import type { Logger } from "../logging/logger"; import type { createLaneService } from "../lanes/laneService"; import { resolveLaneLaunchContext } from "../lanes/laneLaunchContext"; @@ -18,13 +21,21 @@ import type { PtyExitEvent, PtyCreateArgs, PtyCreateResult, + PtySendToSessionArgs, + PtySendToSessionResult, ChatTerminalActiveForChatArgs, ChatTerminalListArgs, ChatTerminalReadArgs, ChatTerminalReadResult, + ChatTerminalResizeArgs, ChatTerminalSession, ChatTerminalSignalArgs, ChatTerminalWriteArgs, + ChatTerminalPreviewArgs, + ChatTerminalPreviewResult, + TerminalSerializedSnapshot, + TerminalSnapshotCell, + TerminalSnapshotRow, TerminalResumeMetadata, TerminalRuntimeState, TerminalSessionStatus, @@ -32,17 +43,31 @@ import type { TerminalToolType } from "../../../shared/types"; import { isProviderSlashCommandInput } from "../../../shared/chatSlashCommands"; +import { withCodexNoAltScreen } from "../../../shared/cliLaunch"; import { stripAnsi } from "../../utils/ansiStrip"; import { summarizeTerminalSession } from "../../utils/sessionSummary"; import { derivePreviewFromChunk } from "../../utils/terminalPreview"; import { + buildTrackedCliResumeCommand, defaultResumeCommandForTool, extractResumeCommandFromOutput, + normalizeResumeCommand, parseTrackedCliLaunchConfig, + providerFromTool, runtimeStateFromOsc133Chunk, sanitizeResumeTargetId, } from "../../utils/terminalSessionSignals"; +type HeadlessXtermModule = typeof HeadlessXterm; +type XtermSerializeModule = typeof XtermSerialize; + +const headlessXtermModule = ((HeadlessXterm as unknown as { default?: HeadlessXtermModule }).default + ?? HeadlessXterm) as HeadlessXtermModule; +const xtermSerializeModule = ((XtermSerialize as unknown as { default?: XtermSerializeModule }).default + ?? XtermSerialize) as XtermSerializeModule; +const { Terminal: HeadlessTerminal } = headlessXtermModule; +const { SerializeAddon } = xtermSerializeModule; + /** Delay before auto-generating a title from CLI output; keep in sync with tests. */ export const PTY_AI_TITLE_DEBOUNCE_MS = 6000; export const PTY_AI_TITLE_TIMEOUT_MS = 60_000; @@ -66,10 +91,17 @@ const CLI_USER_TITLE_SEED_MAX_LEN = 180; const CLI_USER_TITLE_FALLBACK_MAX_LEN = 72; const CODEX_ADE_GUIDANCE_SCAN_BYTES = 160 * 1024; const CODEX_THREAD_NAME_SCAN_BYTES = 512 * 1024; +const CLAUDE_TITLE_SCAN_BYTES = 512 * 1024; const PTY_DATA_BATCH_INTERVAL_MS = 16; const PTY_DATA_BATCH_MAX_CHARS = 64 * 1024; const PTY_DATA_SUMMARY_INTERVAL_MS = 10_000; const DEFAULT_TERMINAL_READ_MAX_BYTES = 220_000; +const TERMINAL_SNAPSHOT_DEBOUNCE_MS = 500; +const TERMINAL_SNAPSHOT_SCROLLBACK = 2_000; +const TERMINAL_SNAPSHOT_TRANSCRIPT_FALLBACK_BYTES = 220_000; +const PTY_SEND_DEFAULT_COLS = 100; +const PTY_SEND_DEFAULT_ROWS = 30; +const CLAUDE_INITIAL_INPUT_CONFIRM_DELAY_MS = 1200; type CodexStorageSessionMatch = { id: string; @@ -77,6 +109,12 @@ type CodexStorageSessionMatch = { threadName: string | null; }; +type ClaudeStorageSessionMatch = { + id: string; + filePath: string; + title: string | null; +}; + function hasEnvValue(env: NodeJS.ProcessEnv, key: string): boolean { return typeof env[key] === "string" && env[key]!.trim().length > 0; } @@ -199,7 +237,10 @@ function sentenceCase(raw: string): string { } function deterministicCliTitleFromSeed(seed: string): string { - const cleaned = trimPromptLeadIn(seed) + const naturalLanguageSlashTitle = seed.startsWith("/") && !isProviderSlashCommandInput(seed) + ? seed.slice(1).trim() + : seed; + const cleaned = trimPromptLeadIn(naturalLanguageSlashTitle) .replace(/^["'`]+|["'`]+$/g, "") .replace(/\s+/g, " ") .trim(); @@ -224,7 +265,7 @@ function isCliPlaceholderTitle(title: string | null | undefined, toolType: Termi } function sanitizeGeneratedCliTitle(raw: string): string { - const title = stripAnsi(raw) + let title = stripAnsi(raw) .replace(/\p{Extended_Pictographic}/gu, "") .replace(/^["'`]+|["'`]+$/g, "") .replace(/\s+/g, " ") @@ -234,10 +275,14 @@ function sanitizeGeneratedCliTitle(raw: string): string { .trim(); if (!title.length) return ""; if (isProviderSlashCommandInput(title)) return ""; + if (title.startsWith("/")) { + title = title.slice(1).trim(); + if (!title.length) return ""; + } const collapsed = title.toLowerCase().replace(/[^\p{L}\p{N}]+/gu, " ").trim(); const rejected = new Set([ "model", "models", "status", "help", "clear", "compact", "resume", - "chat", "session", "claude", "claude code", "codex", "codex cli", + "chat", "session", "claude", "claude code", "claude chat", "claude cli", "codex", "codex cli", "untitled", "untitled chat", "new chat", "new session", "completed", "done", "finished", "success", ]); @@ -283,6 +328,7 @@ type PtyEntry = { pendingDataChunks: string[]; pendingDataChars: number; pendingDataTimer: ReturnType | null; + terminalSnapshot: TerminalSnapshotMirror | null; /** Output-snippet title timer (skipped for interactive Claude/Codex; see CLI user-title path). */ aiTitleTimer: ReturnType | null; cliUserTitleLineBuffer: string; @@ -302,6 +348,16 @@ type PtyExitListener = (event: PtyExitEvent & { laneId: string }) => void; type ShellSpec = { file: string; args: string[] }; +type HeadlessTerminalInstance = InstanceType; +type SerializeAddonInstance = InstanceType; + +type TerminalSnapshotMirror = { + terminal: HeadlessTerminalInstance; + serializeAddon: SerializeAddonInstance; + flushTimer: ReturnType | null; + lastErrorAt: number; +}; + function resolveShellCandidates(): ShellSpec[] { if (process.platform === "win32") { return [ @@ -337,6 +393,7 @@ function clampDims(cols: number, rows: number): { cols: number; rows: number } { function statusFromExit(exitCode: number | null): TerminalSessionStatus { if (exitCode == null) return "completed"; if (exitCode === 0) return "completed"; + if (exitCode === 130 || exitCode === 143) return "disposed"; return "failed"; } @@ -374,7 +431,7 @@ function normalizeToolType(raw: unknown): TerminalToolType | null { /** Extract --session-id from a Claude startup command if present. */ function extractClaudeSessionIdFromCommand(command: string): string | null { - const match = command.match(/--session-id\s+([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})/i); + const match = command.match(/--session-id(?:=|\s+)([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})/i); return match?.[1] ?? null; } @@ -383,7 +440,7 @@ function buildInitialResumeMetadata(args: { startupCommand: string; }): TerminalResumeMetadata | null { const parsedLaunch = parseTrackedCliLaunchConfig(args.startupCommand, args.toolType); - const isClaude = args.toolType === "claude" || args.toolType === "claude-orchestrated"; + const isClaude = isClaudeTrackedCliToolType(args.toolType); const isCodex = args.toolType === "codex" || args.toolType === "codex-orchestrated"; const isCursor = args.toolType === "cursor-cli"; const isDroid = args.toolType === "droid"; @@ -434,6 +491,18 @@ function isTrackedCliToolType(toolType: TerminalToolType | null): toolType is "c || toolType === "codex-orchestrated"; } +function isClaudeTrackedCliToolType(toolType: TerminalToolType | null | undefined): toolType is "claude" | "claude-orchestrated" { + return toolType === "claude" || toolType === "claude-orchestrated"; +} + +function isPersistedChatToolType(toolType: TerminalToolType | null): boolean { + return toolType === "codex-chat" + || toolType === "claude-chat" + || toolType === "opencode-chat" + || toolType === "cursor" + || toolType === "droid-chat"; +} + function inferSessionCwdFromTranscriptPath(transcriptPath: string | null | undefined): string | null { if (!transcriptPath) return null; const normalized = transcriptPath.replace(/\\/g, "/"); @@ -491,6 +560,8 @@ export function createPtyService({ const terminalChatSessions = new Map(); const activeTerminalByChatSession = new Map(); const missingResumeTargetBackfillFailures = new Map(); + const claudeTitleCaptureKeys = new Set(); + const resumeRuntimeFlights = new Map>(); /** Timers for auto-closing tool-typed PTYs when the CLI tool exits back to shell prompt */ const toolAutoCloseTimers = new Map>(); let ptyDataSummaryTimer: ReturnType | null = null; @@ -499,12 +570,206 @@ export function createPtyService({ let ptyDataBatchCount = 0; let ptyDataCharCount = 0; let ptyDataMaxBatchChars = 0; + const terminalSnapshotDir = path.join(projectRoot, ".ade", "cache", "terminal-snapshots"); const getSessionIntelligence = () => { const ai = projectConfigService?.get().effective.ai; return ai?.sessionIntelligence; }; + const safeTerminalSnapshotPathFor = (sessionId: string): string => { + const safeName = sessionId.replace(/[^a-zA-Z0-9._-]/g, "_"); + return path.join(terminalSnapshotDir, `${safeName}.json`); + }; + + const fgColorMode = (cell: { isFgPalette(): boolean; isFgRGB(): boolean }): TerminalSnapshotCell["fgMode"] => { + if (cell.isFgRGB()) return "rgb"; + if (cell.isFgPalette()) return "palette"; + return "default"; + }; + + const bgColorMode = (cell: { isBgDefault(): boolean; isBgPalette(): boolean; isBgRGB(): boolean }): TerminalSnapshotCell["bgMode"] => { + if (cell.isBgRGB()) return "rgb"; + if (cell.isBgPalette()) return "palette"; + return "default"; + }; + + type TerminalCellLike = IBufferCell; + + const snapshotCellFromXtermCell = (cell: TerminalCellLike | undefined): TerminalSnapshotCell => { + if (!cell || cell.getWidth() === 0 || cell.isInvisible()) { + return { text: " ", fg: null, bg: null, fgMode: "default", bgMode: "default" }; + } + const fgMode = fgColorMode(cell); + const bgMode = bgColorMode(cell); + return { + text: cell.getChars() || " ", + fg: fgMode === "default" ? null : cell.getFgColor(), + bg: bgMode === "default" ? null : cell.getBgColor(), + fgMode, + bgMode, + ...(cell.isBold() ? { bold: true } : {}), + ...(cell.isDim() ? { dim: true } : {}), + ...(cell.isItalic() ? { italic: true } : {}), + ...(cell.isUnderline() ? { underline: true } : {}), + ...(cell.isInverse() ? { inverse: true } : {}), + ...(cell.isStrikethrough() ? { strikethrough: true } : {}), + }; + }; + + const visibleRowsFromTerminal = (terminal: HeadlessTerminalInstance): TerminalSnapshotRow[] => { + const buffer = terminal.buffer.active; + const start = Math.max(0, buffer.viewportY); + const reusable = buffer.getNullCell() as unknown as TerminalCellLike; + const rows: TerminalSnapshotRow[] = []; + for (let y = 0; y < terminal.rows; y += 1) { + const line = buffer.getLine(start + y); + if (!line) { + rows.push({ cells: [], text: "", wrapped: false }); + continue; + } + const cells: TerminalSnapshotCell[] = []; + for (let x = 0; x < terminal.cols; x += 1) { + cells.push(snapshotCellFromXtermCell(line.getCell(x, reusable) as unknown as TerminalCellLike | undefined)); + } + rows.push({ + cells, + text: line.translateToString(true), + wrapped: line.isWrapped, + }); + } + return rows; + }; + + const createTerminalSnapshotMirror = (cols: number, rows: number): TerminalSnapshotMirror | null => { + try { + const safe = clampDims(cols, rows); + const terminal = new HeadlessTerminal({ + allowProposedApi: true, + cols: safe.cols, + rows: safe.rows, + scrollback: TERMINAL_SNAPSHOT_SCROLLBACK, + }); + const serializeAddon = new SerializeAddon(); + terminal.loadAddon(serializeAddon as Parameters[0]); + return { terminal, serializeAddon, flushTimer: null, lastErrorAt: 0 }; + } catch (err) { + logger.warn("pty.terminal_snapshot_init_failed", { err: String(err) }); + return null; + } + }; + + const buildTerminalSnapshot = (entry: PtyEntry): TerminalSerializedSnapshot | null => { + const mirror = entry.terminalSnapshot; + if (!mirror || !entry.tracked) return null; + const session = sessionService.get(entry.sessionId); + const status = session?.status ?? "running"; + const runtimeState = computeRuntimeState(entry.sessionId, status); + const buffer = mirror.terminal.buffer.active; + return { + version: 1, + terminalId: entry.sessionId, + cols: mirror.terminal.cols, + rows: mirror.terminal.rows, + capturedAt: new Date().toISOString(), + status, + runtimeState, + bufferType: buffer.type, + cursorX: buffer.cursorX, + cursorY: buffer.cursorY, + baseY: buffer.baseY, + viewportY: buffer.viewportY, + serialized: mirror.serializeAddon.serialize({ scrollback: TERMINAL_SNAPSHOT_SCROLLBACK }), + visibleRows: visibleRowsFromTerminal(mirror.terminal), + }; + }; + + const writeTerminalSnapshot = (entry: PtyEntry): void => { + if (!entry.tracked) return; + const mirror = entry.terminalSnapshot; + if (!mirror) return; + const snapshot = buildTerminalSnapshot(entry); + if (!snapshot) return; + try { + fs.mkdirSync(terminalSnapshotDir, { recursive: true }); + const finalPath = safeTerminalSnapshotPathFor(entry.sessionId); + const tmpPath = `${finalPath}.${process.pid}.${randomUUID()}.tmp`; + fs.writeFileSync(tmpPath, `${JSON.stringify(snapshot)}\n`, "utf8"); + fs.renameSync(tmpPath, finalPath); + } catch (err) { + const now = Date.now(); + if (now - mirror.lastErrorAt > 10_000) { + mirror.lastErrorAt = now; + logger.warn("pty.terminal_snapshot_write_failed", { sessionId: entry.sessionId, err: String(err) }); + } + } + }; + + const scheduleTerminalSnapshotWrite = (entry: PtyEntry, delayMs = TERMINAL_SNAPSHOT_DEBOUNCE_MS): void => { + const mirror = entry.terminalSnapshot; + if (!mirror || !entry.tracked || entry.disposed) return; + if (mirror.flushTimer) return; + mirror.flushTimer = setTimeout(() => { + mirror.flushTimer = null; + writeTerminalSnapshot(entry); + }, delayMs); + mirror.flushTimer.unref?.(); + }; + + const feedTerminalSnapshot = (entry: PtyEntry, data: string): void => { + const mirror = entry.terminalSnapshot; + if (!mirror || !entry.tracked || entry.disposed || !data) return; + try { + mirror.terminal.write(data, () => { + scheduleTerminalSnapshotWrite(entry); + }); + } catch (err) { + const now = Date.now(); + if (now - mirror.lastErrorAt > 10_000) { + mirror.lastErrorAt = now; + logger.warn("pty.terminal_snapshot_feed_failed", { sessionId: entry.sessionId, err: String(err) }); + } + } + }; + + const flushTerminalSnapshot = (entry: PtyEntry): void => { + const mirror = entry.terminalSnapshot; + if (!mirror) return; + if (mirror.flushTimer) { + clearTimeout(mirror.flushTimer); + mirror.flushTimer = null; + } + writeTerminalSnapshot(entry); + }; + + const resizeTerminalSnapshot = (entry: PtyEntry, cols: number, rows: number): void => { + const mirror = entry.terminalSnapshot; + if (!mirror) return; + try { + const safe = clampDims(cols, rows); + if (mirror.terminal.cols === safe.cols && mirror.terminal.rows === safe.rows) return; + mirror.terminal.resize(safe.cols, safe.rows); + scheduleTerminalSnapshotWrite(entry, 0); + } catch (err) { + const now = Date.now(); + if (now - mirror.lastErrorAt > 10_000) { + mirror.lastErrorAt = now; + logger.warn("pty.terminal_snapshot_resize_failed", { sessionId: entry.sessionId, err: String(err) }); + } + } + }; + + const readStoredTerminalSnapshot = (sessionId: string): TerminalSerializedSnapshot | null => { + try { + const raw = fs.readFileSync(safeTerminalSnapshotPathFor(sessionId), "utf8"); + const parsed = JSON.parse(raw) as TerminalSerializedSnapshot; + if (parsed?.version !== 1 || parsed.terminalId !== sessionId) return null; + return parsed; + } catch { + return null; + } + }; + const isTitleGenerationEnabled = (): boolean => { const si = getSessionIntelligence(); return si?.titles?.enabled ?? true; @@ -551,6 +816,10 @@ export function createPtyService({ sessionService.updateMeta({ sessionId: entry.sessionId, title: fallbackTitle, manuallyNamed: false }); } } + // Claude Code writes its own generated `ai-title` into local session + // storage. Keep ADE's prompt summarizer out of this path so that native + // Claude names win when they arrive. + if (isClaudeTrackedCliToolType(entry.toolTypeHint)) return; if (!aiIntegrationService || aiIntegrationService.getMode() === "guest") return; if (!isTitleGenerationEnabled()) return; @@ -729,6 +998,16 @@ export function createPtyService({ .then(async () => { const session = sessionService.get(sessionId); if (!session) return; + const summaryToolType = session.toolType ?? null; + const summaryTargetId = sanitizeResumeTargetId(session.resumeMetadata?.targetId ?? null); + const titleCaptureCwd = summaryCwd || laneService.getLaneBaseAndBranch(session.laneId).worktreePath; + if ( + summaryTargetId + && isClaudeTrackedCliToolType(summaryToolType) + && titleCaptureCwd + ) { + scheduleClaudeRuntimeTitleCaptureBestEffort(sessionId, summaryTargetId, titleCaptureCwd); + } const transcript = session.tracked ? await sessionService.readTranscriptTail(session.transcriptPath, 220_000) @@ -872,28 +1151,35 @@ export function createPtyService({ }); }; + function claudeProjectDirForCwd(cwd: string): string { + return path.join(os.homedir(), ".claude", "projects", cwd.replace(/\//g, "-")); + } + + function claudeSessionFilePathForCwd(cwd: string, claudeSessionId: string): string { + return path.join(claudeProjectDirForCwd(cwd), `${claudeSessionId}.jsonl`); + } + /** - * Try to find the Claude session ID from Claude's local JSONL storage. + * Try to find the Claude session from Claude's local JSONL storage. * Claude Code stores conversations at ~/.claude/projects//.jsonl. - * We find the most recently modified JSONL in the project dir and return its UUID. + * We find the most recently modified JSONL in the project dir and return its UUID + * plus any runtime-generated title Claude has already written. */ - const resolveClaudeSessionIdFromStorage = (cwd: string): string | null => { + const resolveClaudeSessionFromStorage = (cwd: string): ClaudeStorageSessionMatch | null => { try { - const homedir = require("node:os").homedir(); - // Claude encodes the cwd by replacing / with - (and leading -) - // Claude encodes cwd by replacing all / with - (e.g. /Users/admin/Projects/ADE → -Users-admin-Projects-ADE) - const escapedCwd = cwd.replace(/\//g, "-"); - const claudeProjectDir = path.join(homedir, ".claude", "projects", escapedCwd); + const claudeProjectDir = claudeProjectDirForCwd(cwd); if (!fs.existsSync(claudeProjectDir)) return null; // Find the most recently modified .jsonl that is a direct session (not in subagents/) - const entries = fs.readdirSync(claudeProjectDir, { withFileTypes: true }); + const entries = fs.readdirSync(claudeProjectDir, { withFileTypes: true }) as Array; let newest: { name: string; mtimeMs: number } | null = null; for (const entry of entries) { - if (!entry.isFile() || !entry.name.endsWith(".jsonl")) continue; - const stat = fs.statSync(path.join(claudeProjectDir, entry.name)); + const name = typeof entry === "string" ? entry : entry.name; + const isFile = typeof entry === "string" ? true : entry.isFile(); + if (!isFile || !name.endsWith(".jsonl")) continue; + const stat = fs.statSync(path.join(claudeProjectDir, name)); if (!newest || stat.mtimeMs > newest.mtimeMs) { - newest = { name: entry.name, mtimeMs: stat.mtimeMs }; + newest = { name, mtimeMs: stat.mtimeMs }; } } if (!newest) return null; @@ -903,7 +1189,12 @@ export function createPtyService({ if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(uuid)) return null; // Only consider if modified within the last 5 minutes (to avoid picking up stale sessions) if (Date.now() - newest.mtimeMs > 5 * 60 * 1000) return null; - return uuid; + const filePath = path.join(claudeProjectDir, newest.name); + return { + id: uuid, + filePath, + title: readClaudeRuntimeTitle(filePath, uuid), + }; } catch { return null; } @@ -991,6 +1282,38 @@ export function createPtyService({ } } + function titleFromClaudeRecord(record: unknown, claudeSessionId: string): string | null { + if (!record || typeof record !== "object") return null; + const obj = record as Record; + const recordSessionId = typeof obj.sessionId === "string" ? obj.sessionId.trim() : ""; + if (recordSessionId && recordSessionId !== claudeSessionId) return null; + const type = typeof obj.type === "string" ? obj.type : ""; + if (type === "ai-title") { + return sanitizeGeneratedCliTitle(typeof obj.aiTitle === "string" ? obj.aiTitle : ""); + } + if (type === "custom-title") { + return sanitizeGeneratedCliTitle(typeof obj.customTitle === "string" ? obj.customTitle : ""); + } + return null; + } + + function readClaudeRuntimeTitle(filePath: string, claudeSessionId: string): string | null { + const prefix = readFilePrefix(filePath, CLAUDE_TITLE_SCAN_BYTES) ?? ""; + const suffix = readFileSuffix(filePath, CLAUDE_TITLE_SCAN_BYTES) ?? ""; + const text = prefix && suffix && prefix !== suffix ? `${prefix}\n${suffix}` : (suffix || prefix); + if (!text) return null; + const lines = text.split(/\r?\n/).filter(Boolean); + for (let i = lines.length - 1; i >= 0; i -= 1) { + try { + const title = titleFromClaudeRecord(JSON.parse(lines[i]!), claudeSessionId); + if (title) return title; + } catch { + // Ignore malformed or partial JSONL fragments from prefix/suffix reads. + } + } + return null; + } + function sanitizeCodexRuntimeThreadName(raw: unknown): string | null { const title = sanitizeGeneratedCliTitle(typeof raw === "string" ? raw : ""); if (!title) return null; @@ -1270,7 +1593,14 @@ export function createPtyService({ if (!session?.tracked) return false; const effectiveToolType = preferredToolType ?? session.toolType ?? null; if (!isTrackedCliToolType(effectiveToolType)) return false; - if (sanitizeResumeTargetId(session.resumeMetadata?.targetId ?? null)) return true; + const existingTargetId = sanitizeResumeTargetId(session.resumeMetadata?.targetId ?? null); + if (existingTargetId) { + const cwd = sessionCwd ?? inferSessionCwdFromTranscriptPath(session.transcriptPath); + if (isClaudeTrackedCliToolType(effectiveToolType) && cwd) { + scheduleClaudeRuntimeTitleCaptureBestEffort(sessionId, existingTargetId, cwd); + } + return true; + } const recentMissing = missingResumeTargetBackfillFailures.get(sessionId); if ( reason === "session-list" @@ -1293,13 +1623,15 @@ export function createPtyService({ // Strategy 2: Read the session/thread ID from the CLI's local storage const cwd = sessionCwd ?? inferSessionCwdFromTranscriptPath(session.transcriptPath); - if ((effectiveToolType === "claude" || effectiveToolType === "claude-orchestrated") && cwd) { - const claudeSessionId = resolveClaudeSessionIdFromStorage(cwd); - if (claudeSessionId) { - const resumeCmd = `claude --resume ${claudeSessionId}`; + if (isClaudeTrackedCliToolType(effectiveToolType) && cwd) { + const claudeSession = resolveClaudeSessionFromStorage(cwd); + if (claudeSession) { + const resumeCmd = `claude --resume ${claudeSession.id}`; missingResumeTargetBackfillFailures.delete(sessionId); sessionService.setResumeCommand(sessionId, resumeCmd); - logger.info("pty.resume_target_backfilled", { sessionId, toolType: effectiveToolType, reason, source: "claude-storage", claudeSessionId }); + adoptClaudeRuntimeTitle(sessionId, claudeSession.title, "claude-storage-backfill"); + scheduleClaudeRuntimeTitleCaptureBestEffort(sessionId, claudeSession.id, cwd); + logger.info("pty.resume_target_backfilled", { sessionId, toolType: effectiveToolType, reason, source: "claude-storage", claudeSessionId: claudeSession.id }); return true; } } @@ -1307,7 +1639,7 @@ export function createPtyService({ // The session-list and resume-launch paths both need this Codex storage // fallback. Fresh launches still use the live capture watcher below; this // path handles existing tracked sessions whose transcript did not yield a - // usable Codex thread id before the user presses Resume. + // usable Codex thread id before the user types to continue. if ((effectiveToolType === "codex" || effectiveToolType === "codex-orchestrated") && cwd) { const codexSession = resolveCodexSessionFromStorage({ cwd, @@ -1386,9 +1718,77 @@ export function createPtyService({ // ~1 s) and falls off so a slow startup doesn't keep timers alive forever. const CODEX_FALLBACK_POLL_DELAYS_MS = [500, 2_000, 5_000, 12_000, 30_000]; const CODEX_TITLE_POLL_DELAYS_MS = [2_000, 5_000, 12_000, 30_000, 60_000]; + const CLAUDE_TITLE_POLL_DELAYS_MS = [1_000, 2_500, 5_000, 12_000, 30_000, 60_000]; const CODEX_LIVE_CAPTURE_HARD_TIMEOUT_MS = 60_000; const CODEX_WATCH_DEBOUNCE_MS = 200; + const adoptClaudeRuntimeTitle = ( + sessionId: string, + rawTitle: string | null | undefined, + source: string, + ): boolean => { + const title = sanitizeGeneratedCliTitle(rawTitle ?? ""); + if (!title) return false; + if (isSessionManuallyNamed(sessionService, sessionId)) { + logger.info("pty.claude_runtime_title_skipped_user_renamed", { sessionId, source }); + return true; + } + const session = sessionService.get(sessionId); + if (!session) return true; + if (session.title?.trim() === title) return true; + sessionService.updateMeta({ sessionId, title, manuallyNamed: false }); + logger.info("pty.claude_runtime_title_adopted", { + sessionId, + source, + titleLength: title.length, + }); + return true; + }; + + const scheduleClaudeRuntimeTitleCaptureBestEffort = ( + sessionId: string, + claudeSessionId: string | null | undefined, + cwd: string, + ): void => { + const cleanClaudeSessionId = sanitizeResumeTargetId(claudeSessionId ?? null); + if (!cleanClaudeSessionId) return; + const key = `${sessionId}:${cleanClaudeSessionId}`; + if (claudeTitleCaptureKeys.has(key)) return; + claudeTitleCaptureKeys.add(key); + + const filePath = claudeSessionFilePathForCwd(cwd, cleanClaudeSessionId); + const timers = new Set>(); + const cleanup = (): void => { + claudeTitleCaptureKeys.delete(key); + for (const timer of timers) clearTimeout(timer); + timers.clear(); + }; + const tryTitle = (source: string): boolean => { + const title = readClaudeRuntimeTitle(filePath, cleanClaudeSessionId); + return adoptClaudeRuntimeTitle(sessionId, title, source); + }; + + if (tryTitle("claude-storage-initial")) { + cleanup(); + return; + } + + for (let i = 0; i < CLAUDE_TITLE_POLL_DELAYS_MS.length; i += 1) { + const timer = setTimeout(() => { + timers.delete(timer); + try { + if (tryTitle(`claude-storage-poll-${i}`)) cleanup(); + } catch (err) { + logger.warn("pty.claude_runtime_title_capture_failed", { sessionId, attempt: i, err: String(err) }); + } finally { + if (i === CLAUDE_TITLE_POLL_DELAYS_MS.length - 1) cleanup(); + } + }, CLAUDE_TITLE_POLL_DELAYS_MS[i]); + timer.unref?.(); + timers.add(timer); + } + }; + const adoptCodexRuntimeThreadName = ( sessionId: string, rawTitle: string | null | undefined, @@ -1569,6 +1969,7 @@ export function createPtyService({ const endedAt = new Date().toISOString(); const status = statusFromExit(exitCode); sessionService.end({ sessionId: entry.sessionId, endedAt, exitCode, status }); + flushTerminalSnapshot(entry); scheduleTranscriptDependentWork(entry, "close"); clearIdleTimer(entry.sessionId); const finalRuntimeState = runtimeFromStatus(status); @@ -1796,6 +2197,8 @@ export function createPtyService({ laneId: summary.laneId, laneName: summary.laneName, title: summary.title, + toolType: summary.toolType, + goal: summary.goal, status: fallbackStatus, runtimeState: computeRuntimeState(summary.id, fallbackStatus), active: Boolean(activeTerminalId && activeTerminalId === summary.id), @@ -1803,6 +2206,10 @@ export function createPtyService({ endedAt: live ? null : summary.endedAt, exitCode: live ? null : summary.exitCode, pid: live?.[1].pty.pid ?? null, + resumeCommand: summary.resumeCommand, + resumeMetadata: summary.resumeMetadata ?? null, + lastOutputPreview: live?.[1].latestPreviewLine ?? summary.lastOutputPreview, + summary: summary.summary, }; }; @@ -1827,7 +2234,7 @@ export function createPtyService({ return null; }; - return { + const service = { async ensureResumeTargets(sessionIds: string[]): Promise { const uniqueSessionIds = Array.from(new Set( sessionIds @@ -2141,6 +2548,7 @@ export function createPtyService({ pendingDataChunks: [], pendingDataChars: 0, pendingDataTimer: null, + terminalSnapshot: tracked ? createTerminalSnapshotMirror(cols, rows) : null, aiTitleTimer: null, cliUserTitleLineBuffer: "", cliUserTitleCommitted: false, @@ -2166,6 +2574,7 @@ export function createPtyService({ // flight. if (entry.disposed) return; writeTranscript(entry, data); + feedTerminalSnapshot(entry, data); updatePreviewThrottled(entry, data); enqueuePtyData(entry, { ptyId, sessionId, data }); @@ -2211,7 +2620,7 @@ export function createPtyService({ ); } - // Resume-command scanning runs an ANSI strip + 2 regex passes over a + // Continuation-command scanning runs an ANSI strip + 2 regex passes over a // 12KB rolling buffer on every output chunk. Claude/codex print the // resume command near startup, so cap the window — long-running // sessions otherwise pay this cost forever. Storage-based backfill @@ -2274,8 +2683,17 @@ export function createPtyService({ ) { scheduleCodexSessionIdCaptureBestEffort(sessionId, cwd, startedAt); } + if (isClaudeTrackedCliToolType(toolTypeHint) && cwd) { + scheduleClaudeRuntimeTitleCaptureBestEffort( + sessionId, + initialResumeMetadata?.provider === "claude" ? initialResumeMetadata.targetId : null, + cwd, + ); + } - // Fire-and-forget: after 6s, attempt AI title from initial PTY output (not used for interactive Claude/Codex — those title from the first submitted user input via pty.write). + // Fire-and-forget: after 6s, attempt AI title from initial PTY output + // (not used for interactive agent TUIs, which title from native runtime + // storage or the first submitted user input). if ( aiIntegrationService && aiIntegrationService.getMode() !== "guest" @@ -2344,6 +2762,126 @@ export function createPtyService({ return { ptyId, sessionId, pid: pty.pid ?? null }; }, + async sendToSession(args: PtySendToSessionArgs): Promise { + const sessionId = typeof args.sessionId === "string" ? args.sessionId.trim() : ""; + const text = typeof args.text === "string" ? args.text.trim() : ""; + if (!sessionId) throw new Error("Session id is required."); + if (!text) throw new Error("Message text is required."); + + const buildResult = ( + created: PtyCreateResult, + flags: { resumed: boolean; reusedExistingRuntime: boolean }, + ): PtySendToSessionResult => { + const session = sessionService.get(created.sessionId); + const enriched = session ? service.enrichSessions([session])[0] ?? session : null; + return { + ...created, + session: enriched, + resumed: flags.resumed, + reusedExistingRuntime: flags.reusedExistingRuntime, + }; + }; + + const live = liveEntryBySessionId(sessionId); + if (live) { + const [ptyId, entry] = live; + const written = service.writeBySessionId(sessionId, `${text}\r`); + if (!written) throw new Error(`Terminal session '${sessionId}' is not accepting input.`); + return buildResult( + { ptyId, sessionId, pid: entry.pty.pid ?? null }, + { resumed: false, reusedExistingRuntime: true }, + ); + } + + const session = sessionService.get(sessionId); + if (!session) throw new Error(`Terminal session '${sessionId}' was not found.`); + if (!session.tracked) throw new Error(`Terminal session '${sessionId}' is not tracked and cannot be continued.`); + if (session.toolType === "shell" || session.toolType === "run-shell" || isPersistedChatToolType(session.toolType)) { + throw new Error(`Terminal session '${sessionId}' is not an agent CLI session.`); + } + + const provider = session.resumeMetadata?.provider ?? providerFromTool(session.toolType); + if (!provider) throw new Error(`Terminal session '${sessionId}' does not have a resumable CLI provider.`); + + const requestedModel = typeof args.model === "string" && args.model.trim().length + ? args.model.trim() + : null; + const requestedReasoningEffort = typeof args.reasoningEffort === "string" && args.reasoningEffort.trim().length + ? args.reasoningEffort.trim() + : null; + const requestedPermissionMode = typeof args.permissionMode === "string" && args.permissionMode.trim().length + ? args.permissionMode + : null; + const metadataResumeCommand = session.resumeMetadata + ? buildTrackedCliResumeCommand(session.resumeMetadata, { + model: requestedModel, + reasoningEffort: requestedReasoningEffort, + permissionMode: requestedPermissionMode, + }) + : null; + const rawResumeCommand = metadataResumeCommand != null + ? metadataResumeCommand + : normalizeResumeCommand(session.resumeCommand, session.toolType); + const resumeCommand = provider === "codex" && rawResumeCommand + ? withCodexNoAltScreen(rawResumeCommand) + : rawResumeCommand; + if (!resumeCommand) { + throw new Error(`Terminal session '${sessionId}' does not have a resume command.`); + } + + let resumeFlight = resumeRuntimeFlights.get(sessionId); + if (!resumeFlight) { + const { cols, rows } = clampDims( + typeof args.cols === "number" ? args.cols : PTY_SEND_DEFAULT_COLS, + typeof args.rows === "number" ? args.rows : PTY_SEND_DEFAULT_ROWS, + ); + resumeFlight = service.create({ + sessionId, + laneId: session.laneId, + cols, + rows, + title: session.goal?.trim() || session.title || "Terminal", + tracked: session.tracked, + toolType: session.toolType, + startupCommand: resumeCommand, + }); + resumeRuntimeFlights.set(sessionId, resumeFlight); + void resumeFlight + .finally(() => { + if (resumeRuntimeFlights.get(sessionId) === resumeFlight) { + resumeRuntimeFlights.delete(sessionId); + } + }) + .catch(() => {}); + } + + const created = await resumeFlight; + const written = service.writeBySessionId(created.sessionId, `${text}\r`); + if (!written) { + try { + service.dispose({ ptyId: created.ptyId, sessionId: created.sessionId }); + } catch { + // Best effort; preserve the send failure for the caller. + } + throw new Error(`Terminal session '${sessionId}' could not receive the message.`); + } + + if (provider === "claude") { + const confirmTimer = setTimeout(() => { + const confirmWritten = service.writeBySessionId(created.sessionId, "\r"); + if (!confirmWritten) { + logger.warn("pty.send_to_session_claude_initial_input_confirm_failed", { + sessionId: created.sessionId, + ptyId: created.ptyId, + }); + } + }, CLAUDE_INITIAL_INPUT_CONFIRM_DELAY_MS); + confirmTimer.unref?.(); + } + + return buildResult(created, { resumed: true, reusedExistingRuntime: false }); + }, + write({ ptyId, data }: { ptyId: string; data: string }): void { const entry = ptys.get(ptyId); if (!entry) return; @@ -2368,6 +2906,7 @@ export function createPtyService({ limit, }); return summaries + .filter((summary) => !isPersistedChatToolType(summary.toolType)) .filter((summary) => { if (!chatSessionId) return true; const linkedChatSessionId = terminalChatSessions.get(summary.id) @@ -2411,6 +2950,9 @@ export function createPtyService({ if (!terminalId) throw new Error("terminal.read requires terminalId or an active chat terminal."); const session = sessionService.get(terminalId); if (!session) throw new Error(`Terminal session '${terminalId}' was not found.`); + if (isPersistedChatToolType(session.toolType)) { + throw new Error(`Session '${terminalId}' is an agent chat session, not a terminal.`); + } const maxBytes = typeof args.maxBytes === "number" && Number.isFinite(args.maxBytes) ? Math.max(1, Math.min(MAX_TRANSCRIPT_BYTES, Math.floor(args.maxBytes))) : DEFAULT_TERMINAL_READ_MAX_BYTES; @@ -2426,6 +2968,60 @@ export function createPtyService({ }; }, + async previewTerminal(args: ChatTerminalPreviewArgs = {}): Promise { + const terminalId = resolveTerminalId(args); + if (!terminalId) throw new Error("terminal.preview requires terminalId or an active chat terminal."); + const session = sessionService.get(terminalId); + if (!session) throw new Error(`Terminal session '${terminalId}' was not found.`); + if (isPersistedChatToolType(session.toolType)) { + throw new Error(`Session '${terminalId}' is an agent chat session, not a terminal.`); + } + const live = liveEntryBySessionId(terminalId); + if (live) { + flushTerminalSnapshot(live[1]); + } + const maxBytes = typeof args.maxBytes === "number" && Number.isFinite(args.maxBytes) + ? Math.max(1, Math.min(MAX_TRANSCRIPT_BYTES, Math.floor(args.maxBytes))) + : TERMINAL_SNAPSHOT_TRANSCRIPT_FALLBACK_BYTES; + const readTranscriptPreview = async (): Promise => + sessionService.readTranscriptTail(session.transcriptPath, maxBytes, { + raw: true, + alignToLineBoundary: true, + }); + const snapshot = readStoredTerminalSnapshot(terminalId); + if (snapshot) { + let transcript: string | null = null; + if (session.status !== "running") { + try { + transcript = await readTranscriptPreview(); + } catch (err) { + logger.warn("pty.terminal_preview_transcript_read_failed", { + terminalId, + err: String(err), + }); + } + } + return { + terminalId, + session: terminalSessionFromSummary(session), + source: "snapshot", + snapshot, + transcript: transcript || null, + capturedAt: snapshot.capturedAt, + }; + } + + const transcript = await readTranscriptPreview(); + return { + terminalId, + session: terminalSessionFromSummary(session), + source: transcript ? "transcript" : "empty", + snapshot: null, + transcript: transcript || null, + capturedAt: new Date().toISOString(), + }; + }, + writeTerminal(args: ChatTerminalWriteArgs): { ok: true } { if (!args || typeof args.data !== "string") { throw new Error("terminal.write requires string data."); @@ -2450,6 +3046,35 @@ export function createPtyService({ return { ok: true }; }, + resizeTerminal(args: ChatTerminalResizeArgs): { ok: true; cols: number; rows: number } { + if (!args) throw new Error("terminal.resize requires terminalId, ptyId, or an active chat terminal."); + const ptyId = cleanOptionalId(args.ptyId); + let live: [string, PtyEntry] | null = null; + if (ptyId) { + const candidate = ptys.get(ptyId); + if (candidate && !candidate.disposed) live = [ptyId, candidate]; + } else { + const terminalId = resolveTerminalId(args); + if (terminalId) live = liveEntryBySessionId(terminalId); + } + if (!live) throw new Error("No running terminal matched the requested resize target."); + const [liveId, entry] = live; + const safe = clampDims(args.cols, args.rows); + if (entry.lastResizeCols === safe.cols && entry.lastResizeRows === safe.rows) { + return { ok: true, cols: safe.cols, rows: safe.rows }; + } + try { + entry.pty.resize(safe.cols, safe.rows); + entry.lastResizeCols = safe.cols; + entry.lastResizeRows = safe.rows; + resizeTerminalSnapshot(entry, safe.cols, safe.rows); + return { ok: true, cols: safe.cols, rows: safe.rows }; + } catch (err) { + logger.warn("pty.terminal_resize_failed", { ptyId: liveId, err: String(err) }); + throw err; + } + }, + signalTerminal(args: ChatTerminalSignalArgs): { ok: true } { if (!args || (args.signal !== "SIGINT" && args.signal !== "SIGTERM" && args.signal !== "SIGKILL")) { throw new Error("terminal.signal requires SIGINT, SIGTERM, or SIGKILL."); @@ -2487,6 +3112,7 @@ export function createPtyService({ entry.pty.resize(safe.cols, safe.rows); entry.lastResizeCols = safe.cols; entry.lastResizeRows = safe.rows; + resizeTerminalSnapshot(entry, safe.cols, safe.rows); } catch (err) { logger.warn("pty.resize_failed", { ptyId, err: String(err) }); } @@ -2533,6 +3159,7 @@ export function createPtyService({ entry.pty.resize(safe.cols, safe.rows); entry.lastResizeCols = safe.cols; entry.lastResizeRows = safe.rows; + resizeTerminalSnapshot(entry, safe.cols, safe.rows); return true; } catch (err) { logger.warn("pty.resize_by_session_failed", { sessionId, err: String(err) }); @@ -2651,7 +3278,7 @@ export function createPtyService({ disposeAll(): void { for (const ptyId of [...ptys.keys()]) { try { - this.dispose({ ptyId }); + service.dispose({ ptyId }); } catch { // ignore } @@ -2663,7 +3290,7 @@ export function createPtyService({ for (const [ptyId, entry] of [...ptys.entries()]) { if (entry.laneId !== laneId) continue; try { - this.dispose({ ptyId }); + service.dispose({ ptyId }); disposed += 1; } catch { // ignore individual failures; teardown should never block @@ -2695,4 +3322,5 @@ export function createPtyService({ }; } }; + return service; } diff --git a/apps/desktop/src/main/services/review/reviewRuleRegistry.ts b/apps/desktop/src/main/services/review/reviewRuleRegistry.ts index ecb380f5f..632356577 100644 --- a/apps/desktop/src/main/services/review/reviewRuleRegistry.ts +++ b/apps/desktop/src/main/services/review/reviewRuleRegistry.ts @@ -162,52 +162,6 @@ const REVIEW_RULE_OVERLAYS: ReviewRuleOverlayDefinition[] = [ evidenceMode: "cross_boundary", }, }, - { - id: "mcp-dual-path", - label: "MCP dual path", - description: "ADE MCP changes should keep the headless server path and the desktop socket-backed proxy path in sync.", - pathPatterns: [ - "apps/mcp-server/**", - "apps/desktop/src/main/adeMcpProxy.ts", - "apps/desktop/src/main/adeMcpProxyUtils.ts", - "apps/desktop/src/main/services/runtime/adeMcpLaunch.ts", - ], - rolloutExpectations: [ - "Check both headless MCP mode and the desktop socket-backed launch/proxy path.", - "Treat one-sided MCP changes as incomplete rollout unless the other path is intentionally unaffected and the diff proves it.", - ], - companionFamilies: [ - { - id: "mcp-server", - label: "headless MCP server", - pathPatterns: ["apps/mcp-server/**"], - }, - { - id: "desktop-mcp", - label: "desktop MCP proxy/runtime", - pathPatterns: [ - "apps/desktop/src/main/adeMcpProxy.ts", - "apps/desktop/src/main/adeMcpProxyUtils.ts", - "apps/desktop/src/main/services/runtime/adeMcpLaunch.ts", - ], - }, - ], - promptGuidance: { - "diff-risk": [ - "Treat MCP edits as transport or protocol changes that can break ADE-native tool execution.", - ], - "cross-file-impact": [ - "Before concluding there is no issue, explicitly consider both headless MCP behavior and the desktop socket-backed proxy path.", - ], - "checks-and-tests": [ - "Prefer evidence from existing MCP-adjacent validation instead of generic 'add tests' guidance.", - ], - }, - adjudicationPolicy: { - evidenceMode: "cross_boundary", - requireDualPathConsideration: true, - }, - }, ]; export function getReviewRuleOverlayDefinitions(): ReviewRuleOverlayDefinition[] { diff --git a/apps/desktop/src/main/services/sync/syncHostService.test.ts b/apps/desktop/src/main/services/sync/syncHostService.test.ts index fb3d2fbd1..485f449a1 100644 --- a/apps/desktop/src/main/services/sync/syncHostService.test.ts +++ b/apps/desktop/src/main/services/sync/syncHostService.test.ts @@ -254,6 +254,7 @@ function createStubChatService() { resumeSession: vi.fn().mockResolvedValue(baseSession), updateSession: vi.fn().mockResolvedValue(baseSession), dispose: vi.fn().mockResolvedValue(undefined), + deleteSession: vi.fn().mockResolvedValue(undefined), listSessions: vi.fn().mockResolvedValue([]), getSessionSummary: vi.fn().mockResolvedValue(null), getChatTranscript: vi.fn().mockResolvedValue([]), @@ -2109,12 +2110,12 @@ describe.skipIf(!isCrsqliteAvailable())("syncHostService", () => { responseText: "No thanks", }); - const resume = await sendCommand(secondClient.ws, secondClient.queue, { - commandId: "chat-resume", - action: "chat.resume", + const restart = await sendCommand(secondClient.ws, secondClient.queue, { + commandId: "chat-restart", + action: "chat.restart", args: { sessionId: "session-1" }, }); - expect((resume.result.payload as { ok: boolean; result: { sessionId: string } }).result.sessionId).toBe("session-1"); + expect((restart.result.payload as { ok: boolean; result: { sessionId: string } }).result.sessionId).toBe("session-1"); expect(chatService.service.resumeSession).toHaveBeenCalledWith({ sessionId: "session-1" }); const update = await sendCommand(secondClient.ws, secondClient.queue, { @@ -2135,13 +2136,13 @@ describe.skipIf(!isCrsqliteAvailable())("syncHostService", () => { permissionMode: "edit", })); - const dispose = await sendCommand(secondClient.ws, secondClient.queue, { - commandId: "chat-dispose", - action: "chat.dispose", + const deleteResult = await sendCommand(secondClient.ws, secondClient.queue, { + commandId: "chat-delete", + action: "chat.delete", args: { sessionId: "session-1" }, }); - expect((dispose.result.payload as { ok: boolean }).ok).toBe(true); - expect(chatService.service.dispose).toHaveBeenCalledWith({ sessionId: "session-1" }); + expect((deleteResult.result.payload as { ok: boolean }).ok).toBe(true); + expect(chatService.service.deleteSession).toHaveBeenCalledWith({ sessionId: "session-1" }); }, 15_000); it("pairs a phone peer using the desktop PIN and allows paired reconnect auth", async () => { diff --git a/apps/desktop/src/main/services/sync/syncRemoteCommandService.test.ts b/apps/desktop/src/main/services/sync/syncRemoteCommandService.test.ts index 192c0aa08..53ad7986a 100644 --- a/apps/desktop/src/main/services/sync/syncRemoteCommandService.test.ts +++ b/apps/desktop/src/main/services/sync/syncRemoteCommandService.test.ts @@ -13,7 +13,8 @@ const IOS_REMOTE_COMMAND_ACTIONS = [ "prs.getMobileSnapshot", "work.runQuickCommand", "work.startCliSession", - "work.closeSession", + "work.sendToSession", + "work.stopRuntime", "processes.listDefinitions", "processes.listRuntime", "processes.start", @@ -90,9 +91,7 @@ const IOS_REMOTE_COMMAND_ACTIONS = [ "chat.cancelDispatchedSteer", "chat.approve", "chat.respondToInput", - "chat.resume", "chat.updateSession", - "chat.dispose", "cto.getRoster", "cto.ensureSession", "cto.ensureAgentSession", @@ -327,6 +326,14 @@ function createMockQueueLandingService() { function createMockPtyService() { return { create: vi.fn().mockResolvedValue({ sessionId: "pty-1", ptyId: "pty-proc" }), + sendToSession: vi.fn().mockResolvedValue({ + sessionId: "pty-1", + ptyId: "pty-proc", + pid: 123, + session: null, + resumed: true, + reusedExistingRuntime: false, + }), writeBySessionId: vi.fn().mockReturnValue(true), dispose: vi.fn().mockResolvedValue(undefined), enrichSessions: vi.fn((sessions) => sessions), @@ -1309,14 +1316,6 @@ describe("createSyncRemoteCommandService", () => { }); }); - it("chat.dispose routes to agentChatService.dispose", async () => { - const result = await service.execute(makePayload("chat.dispose", { - sessionId: "sess-1", - })); - expect(agentChatService.dispose).toHaveBeenCalledWith({ sessionId: "sess-1" }); - expect(result).toEqual({ ok: true }); - }); - it("chat.interrupt routes to agentChatService.interrupt", async () => { const result = await service.execute(makePayload("chat.interrupt", { sessionId: "sess-1", @@ -1554,18 +1553,6 @@ describe("createSyncRemoteCommandService", () => { }); }); - it("chat.resume routes to agentChatService.resumeSession", async () => { - await service.execute(makePayload("chat.resume", { - sessionId: "sess-1", - })); - expect(agentChatService.resumeSession).toHaveBeenCalledWith({ sessionId: "sess-1" }); - }); - - it("chat.resume throws when sessionId is missing", async () => { - await expect(service.execute(makePayload("chat.resume", {}))) - .rejects.toThrow("chat.resume requires sessionId."); - }); - it("chat.models returns available models for a provider", async () => { await service.execute(makePayload("chat.models", { provider: "codex" })); expect(agentChatService.getAvailableModels).toHaveBeenCalledWith({ provider: "codex" }); @@ -1708,6 +1695,21 @@ describe("createSyncRemoteCommandService", () => { ); }); + it("work.startCliSession preserves wide terminal dimensions", async () => { + await service.execute(makePayload("work.startCliSession", { + laneId: "lane-1", + provider: "codex", + cols: 999, + rows: 999, + })); + expect(ptyService.create).toHaveBeenCalledWith( + expect.objectContaining({ + cols: 400, + rows: 200, + }), + ); + }); + it("work.startCliSession opens a shell without accepting arbitrary startup commands", async () => { await service.execute(makePayload("work.startCliSession", { laneId: "lane-1", @@ -1760,70 +1762,55 @@ describe("createSyncRemoteCommandService", () => { expect(call?.toolType).toBe("claude"); }); - it("work.startCliSession rebuilds the resume command from stored metadata when resumeSessionId is given", async () => { - sessionService.get.mockReturnValue({ - id: "pty-existing", - laneId: "lane-1", - ptyId: "pty-existing", - toolType: "codex", - title: "Codex", - status: "running", - resumeCommand: "codex resume picker", - resumeMetadata: { - provider: "codex", - targetKind: "thread", - targetId: "thread-77", - launch: { permissionMode: "edit" }, - }, - }); - await service.execute(makePayload("work.startCliSession", { - laneId: "lane-1", - provider: "codex", - resumeSessionId: "pty-existing", - })); - const call = ptyService.create.mock.calls.at(-1)?.[0]; - expect(call?.sessionId).toBe("pty-existing"); - expect(call?.allowNewSessionId).toBe(false); - expect(call?.startupCommand).toBe( - "codex --no-alt-screen --sandbox workspace-write --ask-for-approval untrusted resume thread-77", - ); - expect(call?.command).toBeUndefined(); - }); + it("work.startCliSession passes Claude model and confirms initial input", async () => { + vi.useFakeTimers(); + try { + await service.execute(makePayload("work.startCliSession", { + laneId: "lane-1", + provider: "claude", + model: "anthropic/claude-opus-4-7-1m", + initialInput: "hello?", + })); - it("work.startCliSession fails fast when resumeSessionId is missing", async () => { - sessionService.get.mockReturnValue(null); + const call = ptyService.create.mock.calls.at(-1)?.[0]; + expect(call?.args).toEqual(expect.arrayContaining(["--model", "opus[1m]"])); + expect(call?.startupCommand).toContain("opus[1m]"); + expect(ptyService.writeBySessionId).toHaveBeenCalledTimes(1); + expect(ptyService.writeBySessionId).toHaveBeenNthCalledWith(1, "pty-1", "hello?\r"); - await expect(service.execute(makePayload("work.startCliSession", { - laneId: "lane-1", - provider: "codex", - resumeSessionId: "missing-session", - }))).rejects.toThrow("missing-session"); - expect(ptyService.create).not.toHaveBeenCalled(); + await vi.advanceTimersByTimeAsync(1200); + + expect(ptyService.writeBySessionId).toHaveBeenCalledTimes(2); + expect(ptyService.writeBySessionId).toHaveBeenNthCalledWith(2, "pty-1", "\r"); + } finally { + vi.useRealTimers(); + } }); - it("work.startCliSession fails fast when resumeSessionId belongs to a different provider", async () => { - sessionService.get.mockReturnValue({ - id: "pty-existing", - laneId: "lane-1", - ptyId: "pty-existing", - toolType: "claude", - title: "Claude", - status: "running", - resumeCommand: "claude --resume old", - resumeMetadata: { - provider: "claude", - targetKind: "session", - targetId: "old", - launch: { permissionMode: "default" }, - }, + it("work.sendToSession sends through the durable session continuation path", async () => { + const result = await service.execute(makePayload("work.sendToSession", { + sessionId: "pty-existing", + text: "continue here", + cols: 999, + rows: 999, + })); + expect(ptyService.sendToSession).toHaveBeenCalledWith({ + sessionId: "pty-existing", + text: "continue here", + cols: 999, + rows: 999, }); + expect(result).toMatchObject({ + sessionId: "pty-1", + ptyId: "pty-proc", + resumed: true, + }); + }); - await expect(service.execute(makePayload("work.startCliSession", { - laneId: "lane-1", - provider: "codex", - resumeSessionId: "pty-existing", - }))).rejects.toThrow("belongs to claude, not codex"); - expect(ptyService.create).not.toHaveBeenCalled(); + it("work.sendToSession throws when text is missing", async () => { + await expect(service.execute(makePayload("work.sendToSession", { + sessionId: "pty-existing", + }))).rejects.toThrow("work.sendToSession requires text."); }); it("work.startCliSession disposes the created session when initial input cannot be written", async () => { @@ -1837,9 +1824,9 @@ describe("createSyncRemoteCommandService", () => { expect(ptyService.dispose).toHaveBeenCalledWith({ ptyId: "pty-proc", sessionId: "pty-1" }); }); - it("work.closeSession disposes pty if session has a ptyId", async () => { + it("work.stopRuntime disposes pty if session has a ptyId", async () => { sessionService.get.mockReturnValue({ ptyId: "pty-42" }); - const result = await service.execute(makePayload("work.closeSession", { + const result = await service.execute(makePayload("work.stopRuntime", { sessionId: "sess-1", })); expect(sessionService.get).toHaveBeenCalledWith("sess-1"); @@ -1847,9 +1834,9 @@ describe("createSyncRemoteCommandService", () => { expect(result).toEqual({ ok: true }); }); - it("work.closeSession skips pty disposal when the session has no ptyId", async () => { + it("work.stopRuntime skips pty disposal when the session has no ptyId", async () => { sessionService.get.mockReturnValue(null); - const result = await service.execute(makePayload("work.closeSession", { + const result = await service.execute(makePayload("work.stopRuntime", { sessionId: "sess-1", })); expect(sessionService.get).toHaveBeenCalledWith("sess-1"); diff --git a/apps/desktop/src/main/utils/ansiStrip.test.ts b/apps/desktop/src/main/utils/ansiStrip.test.ts index df2cd66e5..7ab37f708 100644 --- a/apps/desktop/src/main/utils/ansiStrip.test.ts +++ b/apps/desktop/src/main/utils/ansiStrip.test.ts @@ -14,6 +14,10 @@ describe("stripAnsi", () => { expect(stripAnsi("\u001b]0;title\u0007hello")).toBe("hello"); }); + it("removes cursor save and restore escapes", () => { + expect(stripAnsi("\u001b7claude\u001b8 ready")).toBe("claude ready"); + }); + it("removes carriage returns", () => { expect(stripAnsi("a\rb")).toBe("ab"); }); diff --git a/apps/desktop/src/main/utils/ansiStrip.ts b/apps/desktop/src/main/utils/ansiStrip.ts index af155db00..1403bff27 100644 --- a/apps/desktop/src/main/utils/ansiStrip.ts +++ b/apps/desktop/src/main/utils/ansiStrip.ts @@ -4,7 +4,7 @@ const OSC_REGEX = /\u001b\][^\u0007]*(?:\u0007|\u001b\\)/g; // ESC ] ... BEL or ST (ESC \) const CSI_REGEX = /\u001b\[[0-?]*[ -/]*[@-~]/g; // ESC [ ... cmd const CHARSET_REGEX = /\u001b[\(\)][0-9A-Za-z]/g; // ESC ( B / ESC ) 0 etc -const TWO_CHAR_ESC_REGEX = /\u001b[@-Z\\-_]/g; // ESC followed by a single byte +const TWO_CHAR_ESC_REGEX = /\u001b(?:[@-Z\\-_]|[0-9=>])/g; // ESC followed by a single byte function applyBackspaces(text: string): string { if (!text.includes("\b")) return text; diff --git a/apps/desktop/src/main/utils/sessionSummary.test.ts b/apps/desktop/src/main/utils/sessionSummary.test.ts index c7c5b9236..51fead2ef 100644 --- a/apps/desktop/src/main/utils/sessionSummary.test.ts +++ b/apps/desktop/src/main/utils/sessionSummary.test.ts @@ -54,4 +54,24 @@ describe("summarizeTerminalSession", () => { expect(summary).toContain("npm run test"); expect(summary).not.toMatch(/@acme\/pkg@1\.0\.0/i); }); + + it("filters Claude terminal chrome from stopped session summaries", () => { + const transcript = [ + "\u001b7\u001b8╭───Claude Codev2.1.141────", + "? for shortcuts · ← for agents", + "Say exactly: patched exit works", + "Resume this session with:", + "claude --resume 5647da1e", + ].join("\r"); + + const summary = summarizeTerminalSession({ + title: "Say exactly: patched exit works", + exitCode: 143, + transcript, + }); + + expect(summary).toBe("Ran Say exactly: patched exit works (STOPPED, exit code 143)"); + expect(summary).not.toContain("Claude Code"); + expect(summary).not.toContain("╭"); + }); }); diff --git a/apps/desktop/src/main/utils/sessionSummary.ts b/apps/desktop/src/main/utils/sessionSummary.ts index 679884be6..def4542eb 100644 --- a/apps/desktop/src/main/utils/sessionSummary.ts +++ b/apps/desktop/src/main/utils/sessionSummary.ts @@ -1,4 +1,4 @@ -import { stripAnsi } from "./ansiStrip"; +import { stripAnsiWithOptions } from "./ansiStrip"; type ParsedTestSummary = { status: "PASS" | "FAIL"; @@ -110,6 +110,18 @@ function findFailureHint(lines: string[]): string | null { return null; } +function isTerminalChromeLine(raw: string): boolean { + const line = raw.trim(); + if (!line) return true; + if (/^[╭╮╯╰─│┌┐└┘├┤┬┴┼▌▐▛▜▘▝▄▀█▒░\s]+$/u.test(line)) return true; + if (/\bClaude Codev?\d/i.test(line) || /^Claude Code\b/i.test(line)) return true; + if (/\b(?:for shortcuts|for agents|bypass permissions|auto mode on)\b/i.test(line)) return true; + if (/^Resume this session with:/i.test(line)) return true; + if (/^(?:claude\s+--resume|codex\s+resume|cursor-agent\s+--resume|droid\s+--resume|opencode\s+--(?:continue|session))\b/i.test(line)) return true; + if (/^\[?esc\]?\s+close\b/i.test(line)) return true; + return false; +} + export function summarizeTerminalSession(args: { title: string; goal?: string | null; @@ -121,11 +133,12 @@ export function summarizeTerminalSession(args: { const goal = (args.goal ?? "").trim(); const intent = goal || title || "terminal session"; - const transcript = stripAnsi(args.transcript ?? ""); + const transcript = stripAnsiWithOptions(args.transcript ?? "", { preserveCarriageReturns: true }); const lines = transcript - .split(/\r?\n/) + .split(/\r\n|\n|\r/) .map((line) => line.trim()) .filter(Boolean) + .filter((line) => !isTerminalChromeLine(line)) .slice(-320); const cmd = findLikelyCommand(lines) ?? intent; @@ -145,6 +158,9 @@ export function summarizeTerminalSession(args: { if (args.exitCode === 0) { return `${prefix} (OK)`; } + if (args.exitCode === 130 || args.exitCode === 143) { + return `${prefix} (STOPPED, exit code ${args.exitCode})`; + } const hint = findFailureHint(lines); return `${prefix} (FAIL, exit code ${args.exitCode}${hint ? `, ${hint}` : ""})`; diff --git a/apps/desktop/src/main/utils/terminalSessionSignals.test.ts b/apps/desktop/src/main/utils/terminalSessionSignals.test.ts index 2a12214fc..c1e034b7f 100644 --- a/apps/desktop/src/main/utils/terminalSessionSignals.test.ts +++ b/apps/desktop/src/main/utils/terminalSessionSignals.test.ts @@ -65,6 +65,10 @@ describe("terminalSessionSignals", () => { permissionMode: "default", claudePermissionMode: "default", }); + expect(parseTrackedCliLaunchConfig("claude --permission-mode auto", "claude")).toEqual({ + permissionMode: "auto", + claudePermissionMode: "auto", + }); expect(parseTrackedCliLaunchConfig("codex --no-alt-screen --sandbox workspace-write --ask-for-approval untrusted", "codex")).toEqual({ permissionMode: "edit", codexApprovalPolicy: "untrusted", @@ -119,6 +123,26 @@ describe("terminalSessionSignals", () => { })).toBe("OPENCODE_CONFIG_CONTENT='{\"permission\":\"allow\"}' opencode --session ses_1"); }); + it("applies resume-time model, reasoning, and permission overrides", () => { + expect(buildTrackedCliResumeCommand({ + provider: "claude", + targetKind: "session", + targetId: "claude-session-1", + launch: { permissionMode: "default" }, + }, { model: "anthropic/claude-haiku-4-5", reasoningEffort: "low", permissionMode: "auto" })).toBe( + "claude --permission-mode auto --model haiku --effort low --resume claude-session-1", + ); + + expect(buildTrackedCliResumeCommand({ + provider: "codex", + targetKind: "thread", + targetId: "thread-99", + launch: { permissionMode: "edit" }, + }, { model: "gpt-5.4", reasoningEffort: "high", permissionMode: "plan" })).toBe( + "codex --no-alt-screen --model gpt-5.4 -c 'model_reasoning_effort=\"high\"' --sandbox read-only --ask-for-approval on-request resume thread-99", + ); + }); + it("parses codex --full-auto as default permission mode", () => { expect(parseTrackedCliLaunchConfig("codex --no-alt-screen --full-auto", "codex")).toEqual({ permissionMode: "default", diff --git a/apps/desktop/src/main/utils/terminalSessionSignals.ts b/apps/desktop/src/main/utils/terminalSessionSignals.ts index 5881731da..173126fbe 100644 --- a/apps/desktop/src/main/utils/terminalSessionSignals.ts +++ b/apps/desktop/src/main/utils/terminalSessionSignals.ts @@ -7,6 +7,12 @@ import type { TerminalRuntimeState, TerminalToolType, } from "../../shared/types"; +import { + codexReasoningEffortFlags, + modelToCliFlag, + normalizeCliFlagValue, + resolveClaudeCliModelForLaunch, +} from "../../shared/cliLaunch"; const OSC_133_REGEX = /\u001b\]133;([ABCD])(?:;[^\u0007\u001b]*)?(?:\u0007|\u001b\\)/g; const RESUME_BACKTICK_REGEX = /`([^`\r\n]*(?:claude|codex|cursor-agent|droid|opencode)\s+[^`\r\n]*(?:--resume|-r|resume|--continue|-c|--session|-s)[^`\r\n]*)`/gi; @@ -69,6 +75,7 @@ export function providerFromTool(toolType: TerminalToolType | null | undefined): function permissionModeToClaudeFlag(permissionMode: AgentChatPermissionMode | null | undefined): string[] { if (permissionMode === "full-auto") return ["--dangerously-skip-permissions"]; if (permissionMode === "edit") return ["--permission-mode", "acceptEdits"]; + if (permissionMode === "auto") return ["--permission-mode", "auto"]; if (permissionMode === "default") return ["--permission-mode", "default"]; return ["--permission-mode", "plan"]; } @@ -137,8 +144,9 @@ function permissionModeToOpenCodeArgs(permissionMode: AgentChatPermissionMode | function buildOpenCodeResumeCommand(args: { permissionMode: AgentChatPermissionMode | null | undefined; targetId: string | null; + model?: string | null; }): string { - const commandArgs = ["opencode", ...permissionModeToOpenCodeArgs(args.permissionMode)]; + const commandArgs = ["opencode", ...permissionModeToOpenCodeArgs(args.permissionMode), ...modelToCliFlag(args.model)]; if (args.targetId) { commandArgs.push("--session", args.targetId); } else { @@ -154,6 +162,7 @@ function extractTrackedCliPermissionMode(command: string, provider: TerminalResu if (provider === "claude") { if (normalized.includes("--dangerously-skip-permissions")) return "full-auto"; if (normalized.includes("--permission-mode acceptedits")) return "edit"; + if (normalized.includes("--permission-mode auto")) return "auto"; if (normalized.includes("--permission-mode default")) return "default"; if (normalized.includes("--permission-mode plan")) return "plan"; return undefined; @@ -231,6 +240,8 @@ export function parseTrackedCliLaunchConfig( claudePermissionMode = "bypassPermissions"; } else if (effectivePermissionMode === "edit") { claudePermissionMode = "acceptEdits"; + } else if (effectivePermissionMode === "auto" || effectivePermissionMode === "plan") { + claudePermissionMode = effectivePermissionMode; } else { claudePermissionMode = "default"; } @@ -351,28 +362,41 @@ export function parseTrackedCliResumeCommand( return { provider, targetId: target }; } -export function buildTrackedCliResumeCommand(metadata: TerminalResumeMetadata | null | undefined): string | null { +export function buildTrackedCliResumeCommand( + metadata: TerminalResumeMetadata | null | undefined, + overrides: { model?: string | null; reasoningEffort?: string | null; permissionMode?: AgentChatPermissionMode | null } = {}, +): string | null { if (!metadata) return null; const provider = metadata.provider; - const permissionMode = metadata.launch.permissionMode ?? null; + const permissionMode = overrides.permissionMode ?? metadata.launch.permissionMode ?? null; const targetId = sanitizeResumeTargetId(metadata.targetId) ?? ""; if (provider === "claude") { const parts = ["claude", ...permissionModeToClaudeFlag(permissionMode)]; + const model = resolveClaudeCliModelForLaunch(overrides.model); + if (model) parts.push("--model", model); + const reasoningEffort = normalizeCliFlagValue(overrides.reasoningEffort); + if (reasoningEffort) parts.push("--effort", reasoningEffort); parts.push("--resume"); if (targetId.length) parts.push(targetId); return commandArrayToLine(parts); } if (provider === "codex") { - const parts = ["codex", "--no-alt-screen", ...permissionModeToCodexFlags(permissionMode)]; + const parts = [ + "codex", + "--no-alt-screen", + ...modelToCliFlag(overrides.model), + ...codexReasoningEffortFlags(overrides.reasoningEffort), + ...permissionModeToCodexFlags(permissionMode), + ]; parts.push("resume"); if (targetId.length) parts.push(targetId); return commandArrayToLine(parts); } if (provider === "cursor") { - const parts = ["cursor-agent", ...permissionModeToCursorFlags(permissionMode)]; + const parts = ["cursor-agent", ...permissionModeToCursorFlags(permissionMode), ...modelToCliFlag(overrides.model)]; if (targetId.length) { parts.push("--resume", targetId); } else { @@ -385,7 +409,7 @@ export function buildTrackedCliResumeCommand(metadata: TerminalResumeMetadata | return buildDroidCommandLine({ permissionMode, resumeTarget: targetId || null }); } - return buildOpenCodeResumeCommand({ permissionMode, targetId: targetId || null }); + return buildOpenCodeResumeCommand({ permissionMode, targetId: targetId || null, model: overrides.model }); } function canonicalizePreferredTool(preferredTool: TerminalToolType | null | undefined): TerminalToolType | null | undefined { diff --git a/apps/desktop/src/preload/global.d.ts b/apps/desktop/src/preload/global.d.ts index 14663c28e..8fb144004 100644 --- a/apps/desktop/src/preload/global.d.ts +++ b/apps/desktop/src/preload/global.d.ts @@ -73,7 +73,6 @@ import type { AgentChatCreateArgs, AgentChatDeleteArgs, AgentChatSuggestLaneNameArgs, - AgentChatDisposeArgs, AgentChatEventEnvelope, AgentChatGetSummaryArgs, AgentChatHandoffArgs, @@ -85,7 +84,6 @@ import type { AgentChatParallelLaunchState, AgentChatParallelLaunchStateArgs, AgentChatRespondToInputArgs, - AgentChatResumeArgs, AgentChatSendArgs, AgentChatSetParallelLaunchStateArgs, AgentChatSlashCommand, @@ -95,10 +93,6 @@ import type { AgentChatSetClaudeOutputStyleArgs, AgentChatClaudePlugin, AgentChatClaudePluginsArgs, - AgentChatClaudeMcpReconnectArgs, - AgentChatClaudeMcpServerStatus, - AgentChatClaudeMcpStatusArgs, - AgentChatClaudeMcpToggleArgs, AgentChatReloadClaudePluginsArgs, AgentChatReloadClaudePluginsResult, AgentChatClaudeSessionInfo, @@ -442,6 +436,8 @@ import type { RecentProjectSummary, PtyCreateArgs, PtyCreateResult, + PtySendToSessionArgs, + PtySendToSessionResult, PtyDataEvent, PtyExitEvent, ReadTranscriptTailArgs, @@ -598,6 +594,7 @@ import type { GetLaneEnvStatusArgs, GetLaneOverlayArgs, LaneDeleteEvent, + LaneDeleteProgress, LaneDeleteRisk, LaneEnvInitProgress, LaneEnvInitEvent, @@ -731,6 +728,8 @@ import type { RemoteRuntimeTargetInput, ChatTerminalActiveForChatArgs, ChatTerminalListArgs, + ChatTerminalPreviewArgs, + ChatTerminalPreviewResult, ChatTerminalReadArgs, ChatTerminalReadResult, ChatTerminalSession, @@ -1335,6 +1334,7 @@ declare global { cancelDelete: (args: { laneId: string; }) => Promise<{ cancelled: boolean; reason?: string }>; + listDeleteProgress: () => Promise; getDeleteRisk: (args: { laneId: string }) => Promise; onDeleteEvent: (cb: (ev: LaneDeleteEvent) => void) => () => void; getStackChain: (laneId: string) => Promise; @@ -1468,11 +1468,9 @@ declare global { args: AgentChatCancelDispatchedSteerArgs, ) => Promise; interrupt: (args: AgentChatInterruptArgs) => Promise; - resume: (args: AgentChatResumeArgs) => Promise; approve: (args: AgentChatApproveArgs) => Promise; respondToInput: (args: AgentChatRespondToInputArgs) => Promise; models: (args: AgentChatModelsArgs) => Promise; - dispose: (args: AgentChatDisposeArgs) => Promise; archive: (args: AgentChatArchiveArgs) => Promise; unarchive: (args: AgentChatArchiveArgs) => Promise; delete: (args: AgentChatDeleteArgs) => Promise; @@ -1487,15 +1485,6 @@ declare global { slashCommands: ( args: AgentChatSlashCommandsArgs, ) => Promise; - getClaudeMcpStatus: ( - args: AgentChatClaudeMcpStatusArgs, - ) => Promise; - reconnectClaudeMcpServer: ( - args: AgentChatClaudeMcpReconnectArgs, - ) => Promise; - toggleClaudeMcpServer: ( - args: AgentChatClaudeMcpToggleArgs, - ) => Promise; listClaudePlugins: ( args?: AgentChatClaudePluginsArgs, ) => Promise; @@ -1750,14 +1739,23 @@ declare global { terminal: { list: (args?: ChatTerminalListArgs) => Promise; read: (args?: ChatTerminalReadArgs) => Promise; + preview: ( + args?: ChatTerminalPreviewArgs, + ) => Promise; write: (args: ChatTerminalWriteArgs) => Promise<{ ok: true }>; signal: (args: ChatTerminalSignalArgs) => Promise<{ ok: true }>; activeForChat: ( args: ChatTerminalActiveForChatArgs, ) => Promise; }; + localhost: { + probePort: (port: number) => Promise; + }; pty: { create: (args: PtyCreateArgs) => Promise; + sendToSession: ( + args: PtySendToSessionArgs, + ) => Promise; write: (args: { ptyId: string; data: string }) => Promise; resize: (args: { ptyId: string; diff --git a/apps/desktop/src/preload/preload.test.ts b/apps/desktop/src/preload/preload.test.ts index d0aa82fab..33b4dab10 100644 --- a/apps/desktop/src/preload/preload.test.ts +++ b/apps/desktop/src/preload/preload.test.ts @@ -924,7 +924,6 @@ describe("preload OAuth bridge", () => { await bridge.lanes.create({ name: "Remote lane" }); await bridge.agentChat.create({ laneId: "lane-1", provider: "codex", model: "gpt-5.4" }); await bridge.agentChat.send({ sessionId: "chat-1", text: "hello" }); - await bridge.agentChat.resume({ sessionId: "chat-1" }); await bridge.git.commit({ laneId: "lane-1", message: "checkpoint" }); await bridge.git.push({ laneId: "lane-1" }); await bridge.prs.createFromLane({ laneId: "lane-1", title: "Remote PR", body: "Proof" }); @@ -936,7 +935,6 @@ describe("preload OAuth bridge", () => { "lane.create", "chat.createSession", "chat.sendMessage", - "chat.resumeSession", "git.commit", "git.push", "pr.createFromLane", diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts index bd12b2227..2b2970400 100644 --- a/apps/desktop/src/preload/preload.ts +++ b/apps/desktop/src/preload/preload.ts @@ -282,7 +282,6 @@ import type { AgentChatCodexOpenInCliResult, AgentChatDeleteArgs, AgentChatSuggestLaneNameArgs, - AgentChatDisposeArgs, AgentChatEventEnvelope, AgentChatGetSummaryArgs, AgentChatHandoffArgs, @@ -294,7 +293,6 @@ import type { AgentChatParallelLaunchState, AgentChatParallelLaunchStateArgs, AgentChatRespondToInputArgs, - AgentChatResumeArgs, AgentChatSendArgs, AgentChatSetParallelLaunchStateArgs, AgentChatSlashCommand, @@ -304,10 +302,6 @@ import type { AgentChatSetClaudeOutputStyleArgs, AgentChatClaudePlugin, AgentChatClaudePluginsArgs, - AgentChatClaudeMcpReconnectArgs, - AgentChatClaudeMcpServerStatus, - AgentChatClaudeMcpStatusArgs, - AgentChatClaudeMcpToggleArgs, AgentChatReloadClaudePluginsArgs, AgentChatReloadClaudePluginsResult, AgentChatClaudeSessionInfo, @@ -381,6 +375,8 @@ import type { RecentProjectSummary, PtyCreateArgs, PtyCreateResult, + PtySendToSessionArgs, + PtySendToSessionResult, PtyDataEvent, PtyExitEvent, RiskMatrixEntry, @@ -466,6 +462,7 @@ import type { GetLaneEnvStatusArgs, GetLaneOverlayArgs, LaneDeleteEvent, + LaneDeleteProgress, LaneDeleteRisk, LaneEnvInitProgress, LaneEnvInitEvent, @@ -743,6 +740,8 @@ import type { RemoteRuntimeTargetInput, ChatTerminalActiveForChatArgs, ChatTerminalListArgs, + ChatTerminalPreviewArgs, + ChatTerminalPreviewResult, ChatTerminalReadArgs, ChatTerminalReadResult, ChatTerminalSession, @@ -4320,6 +4319,10 @@ contextBridge.exposeInMainWorld("ade", { { arg: args.laneId }, () => ipcRenderer.invoke(IPC.lanesDeleteCancel, args), ), + listDeleteProgress: async (): Promise => + callProjectRuntimeActionOr("lane", "listDeleteProgress", {}, () => + ipcRenderer.invoke(IPC.lanesListDeleteProgress), + ), getDeleteRisk: async (args: { laneId: string }): Promise => callProjectRuntimeActionOr( "lane", @@ -4946,19 +4949,6 @@ contextBridge.exposeInMainWorld("ade", { await ipcRenderer.invoke(IPC.agentChatInterrupt, args); agentChatSummaryCache.clear(); }, - resume: async (args: AgentChatResumeArgs): Promise => { - agentChatSummaryCache.clear(); - const runtime = await callProjectRuntimeActionIfBound( - "chat", - "resumeSession", - { args }, - ); - const session = runtime.handled - ? runtime.result - : await ipcRenderer.invoke(IPC.agentChatResume, args); - agentChatSummaryCache.clear(); - return session as AgentChatSession; - }, approve: async (args: AgentChatApproveArgs): Promise => { agentChatSummaryCache.clear(); const runtime = await callProjectRuntimeActionIfBound( @@ -4993,17 +4983,6 @@ contextBridge.exposeInMainWorld("ade", { ? runtime.result : ipcRenderer.invoke(IPC.agentChatModels, args); }, - dispose: async (args: AgentChatDisposeArgs): Promise => { - agentChatSummaryCache.clear(); - const runtime = await callProjectRuntimeActionIfBound( - "chat", - "dispose", - { args }, - ); - if (!runtime.handled) - await ipcRenderer.invoke(IPC.agentChatDispose, args); - agentChatSummaryCache.clear(); - }, archive: async (args: AgentChatArchiveArgs): Promise => { agentChatSummaryCache.clear(); const runtime = await callProjectRuntimeActionIfBound( @@ -5071,24 +5050,6 @@ contextBridge.exposeInMainWorld("ade", { } return ipcRenderer.invoke(IPC.agentChatSlashCommands, args); }, - getClaudeMcpStatus: async ( - args: AgentChatClaudeMcpStatusArgs, - ): Promise => - callProjectRuntimeActionOr("chat", "getClaudeMcpStatus", { args }, () => - ipcRenderer.invoke(IPC.agentChatClaudeMcpStatus, args), - ), - reconnectClaudeMcpServer: async ( - args: AgentChatClaudeMcpReconnectArgs, - ): Promise => - callProjectRuntimeActionOr("chat", "reconnectClaudeMcpServer", { args }, () => - ipcRenderer.invoke(IPC.agentChatClaudeMcpReconnect, args), - ), - toggleClaudeMcpServer: async ( - args: AgentChatClaudeMcpToggleArgs, - ): Promise => - callProjectRuntimeActionOr("chat", "toggleClaudeMcpServer", { args }, () => - ipcRenderer.invoke(IPC.agentChatClaudeMcpToggle, args), - ), listClaudePlugins: async ( args: AgentChatClaudePluginsArgs = {}, ): Promise => @@ -5779,6 +5740,19 @@ contextBridge.exposeInMainWorld("ade", { ? runtime.result : ipcRenderer.invoke(IPC.terminalRead, args); }, + preview: async ( + args: ChatTerminalPreviewArgs = {}, + ): Promise => { + const runtime = + await callProjectRuntimeActionIfBound( + "terminal", + "preview", + { args }, + ); + return runtime.handled + ? runtime.result + : ipcRenderer.invoke(IPC.terminalPreview, args); + }, write: async (args: ChatTerminalWriteArgs): Promise<{ ok: true }> => { const runtime = await callProjectRuntimeActionIfBound<{ ok: true }>( "terminal", @@ -5813,6 +5787,10 @@ contextBridge.exposeInMainWorld("ade", { : ipcRenderer.invoke(IPC.terminalActiveForChat, args); }, }, + localhost: { + probePort: async (port: number): Promise => + ipcRenderer.invoke(IPC.localhostProbePort, { port }), + }, pty: { create: async (args: PtyCreateArgs): Promise => { const runtime = await callProjectRuntimeActionIfBound( @@ -5824,6 +5802,19 @@ contextBridge.exposeInMainWorld("ade", { ? runtime.result : ipcRenderer.invoke(IPC.ptyCreate, args); }, + sendToSession: async ( + args: PtySendToSessionArgs, + ): Promise => { + const runtime = + await callProjectRuntimeActionIfBound( + "pty", + "sendToSession", + { args }, + ); + return runtime.handled + ? runtime.result + : ipcRenderer.invoke(IPC.ptySendToSession, args); + }, write: async (arg: { ptyId: string; data: string }): Promise => { const runtime = await callProjectRuntimeActionIfBound( "pty", diff --git a/apps/desktop/src/renderer/browserMock.ts b/apps/desktop/src/renderer/browserMock.ts index ca9419f19..d4d8dc9ca 100644 --- a/apps/desktop/src/renderer/browserMock.ts +++ b/apps/desktop/src/renderer/browserMock.ts @@ -4471,31 +4471,35 @@ if (typeof window !== "undefined" && shouldInstallBrowserMock(window)) { }), cancelDispatchedSteer: resolvedArg({ cancelled: false }), interrupt: resolvedArg(undefined), - resume: resolvedArg({ id: "mock" }), approve: resolvedArg(undefined), respondToInput: resolvedArg(undefined), models: resolvedArg([]), - dispose: resolvedArg(undefined), archive: resolvedArg(undefined), unarchive: resolvedArg(undefined), delete: resolvedArg(undefined), updateSession: resolvedArg({ id: "mock" }), - warmupModel: resolvedArg(undefined), - onEvent: noop, - slashCommands: resolvedArg([]), - getClaudeMcpStatus: resolvedArg([]), - reconnectClaudeMcpServer: resolvedArg([]), - toggleClaudeMcpServer: resolvedArg([]), - listClaudePlugins: resolvedArg([]), - reloadClaudePlugins: resolvedArg({ plugins: [], commands: [], agents: [], mcpServers: [], errorCount: 0 }), - listClaudeOutputStyles: resolvedArg([ - { name: "Default", source: "builtin" }, - { name: "Proactive", source: "builtin" }, - { name: "Explanatory", source: "builtin" }, - { name: "Learning", source: "builtin" }, - ]), - setClaudeOutputStyle: resolvedArg({ id: "mock", provider: "claude", claudeOutputStyle: "Default" }), - listClaudeSessions: resolvedArg([]), + warmupModel: resolvedArg(undefined), + onEvent: noop, + slashCommands: resolvedArg([]), + listClaudePlugins: resolvedArg([]), + reloadClaudePlugins: resolvedArg({ + plugins: [], + commands: [], + agents: [], + errorCount: 0, + }), + listClaudeOutputStyles: resolvedArg([ + { name: "Default", source: "builtin" }, + { name: "Proactive", source: "builtin" }, + { name: "Explanatory", source: "builtin" }, + { name: "Learning", source: "builtin" }, + ]), + setClaudeOutputStyle: resolvedArg({ + id: "mock", + provider: "claude", + claudeOutputStyle: "Default", + }), + listClaudeSessions: resolvedArg([]), getClaudeSessionInfo: resolvedArg(null), getClaudeSessionMessages: resolvedArg([]), getContextUsage: resolvedArg(null), @@ -4918,6 +4922,14 @@ if (typeof window !== "undefined" && shouldInstallBrowserMock(window)) { sessionId: "mock-session", pid: 1234, }), + sendToSession: resolvedArg({ + ptyId: "mock", + sessionId: "mock-session", + pid: 1234, + session: null, + resumed: false, + reusedExistingRuntime: true, + }), write: resolvedArg(undefined), resize: resolvedArg(undefined), dispose: resolvedArg(undefined), diff --git a/apps/desktop/src/renderer/components/automations/adeActionSchemas.ts b/apps/desktop/src/renderer/components/automations/adeActionSchemas.ts index 0250fb53e..a60f66bce 100644 --- a/apps/desktop/src/renderer/components/automations/adeActionSchemas.ts +++ b/apps/desktop/src/renderer/components/automations/adeActionSchemas.ts @@ -1072,13 +1072,6 @@ export const ADE_ACTION_SCHEMAS: readonly AdeActionSchema[] = [ description: "Interrupt the running turn on a chat session.", params: [{ name: "sessionId", type: "string", required: true }], }, - { - domain: "chat", - action: "resumeSession", - label: "Resume chat session", - description: "Resume a previously ended or idled chat session.", - params: [{ name: "sessionId", type: "string", required: true }], - }, { domain: "chat", action: "deleteSession", @@ -1159,41 +1152,13 @@ export const ADE_ACTION_SCHEMAS: readonly AdeActionSchema[] = [ { name: "includeSystemMessages", type: "boolean" }, ], }, - { - domain: "chat", - action: "getContextUsage", - label: "Get context usage", - description: "Return Claude context usage for a chat session.", - params: [{ name: "sessionId", type: "string", required: true }], - }, - { - domain: "chat", - action: "getClaudeMcpStatus", - label: "Get Claude MCP status", - description: "Return MCP server connection status for a Claude chat session.", - params: [{ name: "sessionId", type: "string", required: true }], - }, - { - domain: "chat", - action: "reconnectClaudeMcpServer", - label: "Reconnect Claude MCP server", - description: "Reconnect one MCP server in a Claude chat session.", - params: [ - { name: "sessionId", type: "string", required: true }, - { name: "serverName", type: "string", required: true }, - ], - }, - { - domain: "chat", - action: "toggleClaudeMcpServer", - label: "Toggle Claude MCP server", - description: "Enable or disable one MCP server in a Claude chat session.", - params: [ - { name: "sessionId", type: "string", required: true }, - { name: "serverName", type: "string", required: true }, - { name: "enabled", type: "boolean", required: true }, - ], - }, + { + domain: "chat", + action: "getContextUsage", + label: "Get context usage", + description: "Return Claude context usage for a chat session.", + params: [{ name: "sessionId", type: "string", required: true }], + }, { domain: "chat", action: "listClaudePlugins", @@ -1204,13 +1169,13 @@ export const ADE_ACTION_SCHEMAS: readonly AdeActionSchema[] = [ { name: "laneId", type: "string", placeholder: "{{trigger.lane.id}}" }, ], }, - { - domain: "chat", - action: "reloadClaudePlugins", - label: "Reload Claude plugins", - description: "Reload Claude plugin commands, agents, hooks, and MCP servers for a chat session.", - params: [{ name: "sessionId", type: "string", required: true }], - }, + { + domain: "chat", + action: "reloadClaudePlugins", + label: "Reload Claude plugins", + description: "Reload Claude plugin commands, agents, and hooks for a chat session.", + params: [{ name: "sessionId", type: "string", required: true }], + }, { domain: "chat", action: "listClaudeOutputStyles", diff --git a/apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx b/apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx index d4449a245..abf5d3860 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx @@ -731,6 +731,7 @@ export function AgentChatComposer({ draft, attachments, contextAttachments = [], + allowAttachmentOnlySubmit = false, pinnedLinearIssue = null, pendingInput, approvalResponding, @@ -764,6 +765,9 @@ export function AgentChatComposer({ onDraftChange, onClearDraft, onSubmit, + onSubmitInBackground, + backgroundLaunchBusy = false, + backgroundLaunchLabel = "Background", onInterrupt, onApproval, onAddAttachment, @@ -847,6 +851,7 @@ export function AgentChatComposer({ draft: string; attachments: AgentChatFileRef[]; contextAttachments?: AgentChatContextAttachment[]; + allowAttachmentOnlySubmit?: boolean; pinnedLinearIssue?: LaneLinearIssue | null; pendingInput: PendingInputRequest | null; approvalResponding?: boolean; @@ -880,6 +885,9 @@ export function AgentChatComposer({ onDraftChange: (value: string) => void; onClearDraft?: () => void; onSubmit: () => void; + onSubmitInBackground?: () => void; + backgroundLaunchBusy?: boolean; + backgroundLaunchLabel?: string; onInterrupt: () => void; onApproval: (decision: AgentChatApprovalDecision, responseText?: string | null) => void; onAddAttachment: (attachment: AgentChatFileRef) => void; @@ -2521,8 +2529,22 @@ export function AgentChatComposer({ const hasAppControlContext = appControlContextItems.length > 0; const hasBuiltInBrowserContext = builtInBrowserContextItems.length > 0; const hasMacosVmContext = macosVmContextItems.length > 0; - const singleReady = !parallelChatMode && Boolean(modelId) && (draft.trim().length > 0 || hasIosElementContext || hasAppControlContext || hasBuiltInBrowserContext || hasMacosVmContext || contextAttachmentCount > 0); - const sendEnabled = !busy && !parallelLaunchBusy && !composerInputLocked && (parallelReady || singleReady); + const singleReady = !parallelChatMode && Boolean(modelId) && ( + draft.trim().length > 0 + || (allowAttachmentOnlySubmit && attachments.length > 0) + || hasIosElementContext + || hasAppControlContext + || hasBuiltInBrowserContext + || hasMacosVmContext + || contextAttachmentCount > 0 + ); + const sendEnabled = !busy && !backgroundLaunchBusy && !parallelLaunchBusy && !composerInputLocked && (parallelReady || singleReady); + const backgroundSendEnabled = Boolean(onSubmitInBackground) + && !busy + && !backgroundLaunchBusy + && !parallelLaunchBusy + && !composerInputLocked + && singleReady; function sendButtonTitle(): string { if (composerInputLocked) return composerInputLockMessage ?? "Resolve the pending request before sending."; @@ -2532,6 +2554,7 @@ export function AgentChatComposer({ return "Send to all lanes"; } if (!modelId) return "Select a model first"; + if (!draft.trim().length && allowAttachmentOnlySubmit && attachments.length > 0) return "Send attached files"; if (!draft.trim().length && contextAttachmentCount > 0) return "Send attached issue context"; if (!draft.trim().length && hasAppControlContext) return "Send selected App Control context"; if (!draft.trim().length && hasIosElementContext) return "Send selected iOS context"; @@ -3471,27 +3494,50 @@ export function AgentChatComposer({ ? "Launch a Cursor Cloud agent with this prompt and the panel's settings." : "Send this prompt to the selected model."; return ( - - - + <> + + + + {onSubmitInBackground && !parallelChatMode && !cloudMode ? ( + + + + ) : null} + ); })() )} diff --git a/apps/desktop/src/renderer/components/chat/AgentChatMessageList.test.tsx b/apps/desktop/src/renderer/components/chat/AgentChatMessageList.test.tsx index c0ba3d7b9..c076dad61 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatMessageList.test.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatMessageList.test.tsx @@ -122,6 +122,10 @@ beforeEach(() => { ...(originalAde?.terminal ?? {}), activeForChat: vi.fn().mockResolvedValue(null), }, + localhost: { + ...(originalAde?.localhost ?? {}), + probePort: vi.fn().mockResolvedValue(true), + }, } as any; }); @@ -218,7 +222,8 @@ describe("AgentChatMessageList transcript rendering", () => { }, ]); - fireEvent.click(screen.getByRole("button", { name: "Open http://localhost:5173/ in ADE browser" })); + const openButton = await screen.findByRole("button", { name: "Open http://localhost:5173/ in ADE browser" }); + fireEvent.click(openButton); await waitFor(() => { expect(globalThis.window.ade.builtInBrowser.navigate).toHaveBeenCalledWith({ @@ -273,9 +278,10 @@ describe("AgentChatMessageList transcript rendering", () => { }, ], { sessionId: "session-1", onInsertDraft }); - fireEvent.click(screen.getByRole("button", { + const logsButton = await screen.findByRole("button", { name: "Open terminal logs or ask the agent to run this server in the chat terminal", - })); + }); + fireEvent.click(logsButton); await waitFor(() => { expect(onInsertDraft).toHaveBeenCalledWith(expect.stringContaining("ade --socket terminal read")); diff --git a/apps/desktop/src/renderer/components/chat/AgentChatMessageList.tsx b/apps/desktop/src/renderer/components/chat/AgentChatMessageList.tsx index d2b9f9aa9..f16c3721b 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatMessageList.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatMessageList.tsx @@ -2499,11 +2499,9 @@ function renderEvent( ); })} - {(usage.memoryFiles?.length || usage.mcpTools?.length) ? ( + {usage.memoryFiles?.length ? (
{usage.memoryFiles?.length ? {usage.memoryFiles.length} memory file{usage.memoryFiles.length === 1 ? "" : "s"} : null} - {usage.memoryFiles?.length && usage.mcpTools?.length ? · : null} - {usage.mcpTools?.length ? {usage.mcpTools.length} MCP tool{usage.mcpTools.length === 1 ? "" : "s"} : null}
) : null} diff --git a/apps/desktop/src/renderer/components/chat/AgentChatPane.submit.test.tsx b/apps/desktop/src/renderer/components/chat/AgentChatPane.submit.test.tsx index 77d2e864b..ceedb57fd 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatPane.submit.test.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatPane.submit.test.tsx @@ -16,7 +16,7 @@ import type { import { getModelById } from "../../../shared/modelRegistry"; import { invalidateAiDiscoveryCache } from "../../lib/aiDiscoveryCache"; import { useAppStore } from "../../state/appStore"; -import { AgentChatPane } from "./AgentChatPane"; +import { AgentChatPane, isMatchingOptimisticUserMessage } from "./AgentChatPane"; vi.mock("../terminals/TerminalView", () => { const ReactMod = require("react") as typeof import("react"); @@ -792,6 +792,70 @@ describe("AgentChatPane submit recovery", () => { }); }); + it("matches a recovered committed user message to the optimistic first bubble", () => { + type UserMessageEvent = Extract; + const optimistic: AgentChatEventEnvelope = { + sessionId: "session-1", + timestamp: "2026-03-24T05:57:45.700Z", + event: { + type: "user_message", + text: "Pearl UI audit handoff", + attachments: [{ path: "docs/audit.md", type: "file" }], + deliveryState: "queued", + }, + }; + const committedUserEvent: UserMessageEvent = { + type: "user_message", + text: "Full handoff prompt with all implementation details.", + displayText: "Pearl UI audit handoff", + attachments: [{ path: "docs/audit.md", type: "file" }], + }; + const committed: AgentChatEventEnvelope = { + sessionId: "session-1", + timestamp: "2026-03-24T05:57:46.000Z", + event: committedUserEvent, + }; + + expect(isMatchingOptimisticUserMessage(committed, optimistic)).toBe(true); + expect(isMatchingOptimisticUserMessage({ + ...committed, + event: { ...committedUserEvent, steerId: "steer-1", deliveryState: "delivered" }, + }, optimistic)).toBe(false); + expect(isMatchingOptimisticUserMessage({ + ...committed, + event: { ...committedUserEvent, attachments: [{ path: "docs/other.md", type: "file" }] }, + }, optimistic)).toBe(false); + }); + + it("renders a duplicated live Codex user_message envelope only once", async () => { + const session = buildSession("session-1", { status: "idle" }); + const { emitChatEvent } = installAdeMocks({ + sessions: [session], + }); + + renderPane(session); + + await screen.findByRole("textbox"); + const envelope: AgentChatEventEnvelope = { + sessionId: session.sessionId, + timestamp: "2026-03-24T05:57:46.000Z", + sequence: 1, + event: { + type: "user_message", + text: "Render this Codex message once.", + }, + }; + + act(() => { + emitChatEvent(envelope); + emitChatEvent(envelope); + }); + + await waitFor(() => { + expect(screen.getAllByText("Render this Codex message once.")).toHaveLength(1); + }); + }); + it("keeps the draft cleared after steer succeeds even if session refresh fails", async () => { const session = buildSession("session-1"); const { steer } = installAdeMocks({ @@ -1563,6 +1627,49 @@ describe("AgentChatPane submit recovery", () => { }); }); + it("launches a tracked CLI session from the Work draft composer instead of creating an ADE chat", async () => { + const { send, create } = installAdeMocks({ sessions: [] }); + const onLaunchCliSession = vi.fn().mockResolvedValue({ sessionId: "terminal-1", ptyId: "pty-1" }); + + render( + + + , + ); + + const trigger = await screen.findByRole("button", { name: "Select model" }); + const codexLabel = getModelById("openai/gpt-5.4")?.displayName ?? "GPT-5.4"; + + fireEvent.click(trigger); + fireEvent.click(await screen.findByRole("button", { name: /^Codex$/i })); + await clickEnabledModelOption(new RegExp(escapeRegExp(codexLabel), "i")); + + const textbox = await screen.findByRole("textbox"); + fireEvent.change(textbox, { target: { value: "Run the unified CLI launch." } }); + fireEvent.click(await screen.findByRole("button", { name: "Send" })); + + await waitFor(() => { + expect(onLaunchCliSession).toHaveBeenCalledWith(expect.objectContaining({ + laneId: "lane-1", + profile: "codex", + title: "Run the unified CLI launch", + command: "codex", + tracked: true, + })); + }); + const launchArgs = onLaunchCliSession.mock.calls[0]?.[0]; + expect(launchArgs.startupCommand).toContain("ADE session guidance"); + expect(launchArgs.startupCommand).toContain("Run the unified CLI launch."); + expect(create).not.toHaveBeenCalled(); + expect(send).not.toHaveBeenCalled(); + }); + it("keeps immediate agent events for a freshly created chat before session refresh catches up", async () => { const { create, emitChatEvent } = installAdeMocks({ sessions: [] }); const send = vi.fn().mockImplementation(async ({ sessionId }: { sessionId: string }) => { diff --git a/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx b/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx index 02203f3d0..7cd03eeb8 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx @@ -1,7 +1,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useNavigate } from "react-router-dom"; import { AnimatePresence, motion } from "motion/react"; -import { Cube, Desktop, DeviceMobile, Lightning, Plus, TreeStructure } from "@phosphor-icons/react"; +import { Cube, Desktop, DeviceMobile, Lightning, Plus, Terminal, TreeStructure } from "@phosphor-icons/react"; import { inferAttachmentType, PARALLEL_CHAT_MAX_ATTACHMENTS, @@ -38,11 +38,11 @@ import { type LaneLinearIssue, type AiSettingsStatus, type MacosVmContextItem, - type MacosVmStatus, type TerminalSessionDetail, type TerminalToolType, } from "../../../shared/types"; import { + buildChatContextAttachmentPrompt, makeLinearIssueContextAttachment, mergeChatContextAttachments, removeChatContextAttachment, @@ -57,8 +57,10 @@ import { getLocalProviderDefaultEndpoint, getModelById, getModelDescriptorForPermissionMode, + getRuntimeModelRefForDescriptor, modelSupportsFastMode, parseLocalProviderFromModelId, + resolveCliProviderForModel, resolveProviderGroupForModel, resolveModelDescriptorForProvider, type LocalProviderFamily, @@ -104,11 +106,38 @@ import { useAppStore } from "../../state/appStore"; import { buildChatAppearanceRootStyle } from "./chatAppearance"; import { LaneAccentDot } from "../lanes/LaneAccentDot"; import { LaneCombobox } from "../terminals/LaneCombobox"; +import { + buildTrackedCliLaunchCommand, + LAUNCH_PROFILE_TITLE, + type CliProvider, + type LaunchProfile, +} from "../terminals/cliLaunch"; import { ClaudeCacheTtlBadge } from "../shared/ClaudeCacheTtlBadge"; import { shouldShowClaudeCacheTtl } from "../../lib/claudeCacheTtl"; import { getAgentChatModelsCached, getAiStatusCached, peekAiStatusCached } from "../../lib/aiDiscoveryCache"; import { invalidateSessionListCache } from "../../lib/sessionListCache"; import { rewriteMissionControlTextToolEvents } from "./missionControlTextTools"; +import { + buildAutomaticMacosVmContextForPrompt, + createAppControlContextInstanceId, + createBuiltInBrowserContextInstanceId, + createIosContextInstanceId, + createMacosVmContextInstanceId, + formatAppControlContextChipsForDisplay, + formatAppControlContextForPrompt, + formatBuiltInBrowserContextChipsForDisplay, + formatBuiltInBrowserContextForPrompt, + formatIosElementContextChipsForDisplay, + formatIosElementContextForPrompt, + formatMacosVmContextChipsForDisplay, + formatMacosVmContextForPrompt, + getAppControlContextAttachmentPath, + getBuiltInBrowserContextAttachmentPath, + getIosContextAttachmentPath, + iosContextSurface, + normalizeBuiltInBrowserContextItem, + stripDataUrlPrefix, +} from "../../lib/visualContextFormatting"; import { playAgentTurnCompletionSound } from "../../lib/agentTurnCompletionSound"; @@ -116,477 +145,73 @@ const LAST_MODEL_ID_KEY = "ade.chat.lastModelId"; const LAST_REASONING_KEY_PREFIX = "ade.chat.lastReasoningEffort"; export const DEFAULT_PARALLEL_ATTACHMENT_REQUEST = "Please review the attached files."; -function asRecord(value: unknown): Record | null { - return value && typeof value === "object" && !Array.isArray(value) ? value as Record : null; -} - -function asRecordArray(value: unknown): Array> { - return Array.isArray(value) ? value.map(asRecord).filter((item): item is Record => Boolean(item)) : []; -} - -function stringOrNull(value: unknown): string | null { - return typeof value === "string" && value.trim().length ? value.trim() : null; -} - -function numberOrNull(value: unknown): number | null { - return typeof value === "number" && Number.isFinite(value) ? value : null; -} - -function frameFromUnknown(value: unknown): BuiltInBrowserContextItem["frame"] | null { - const record = asRecord(value); - if (!record) return null; - const x = numberOrNull(record.x); - const y = numberOrNull(record.y); - const width = numberOrNull(record.width); - const height = numberOrNull(record.height); - if (x == null || y == null || width == null || height == null) return null; - return { x, y, width, height }; -} - -function iosContextLabel(item: IosElementContextItem): string { - const metadata = item.metadata ?? {}; - const label = typeof metadata.label === "string" && metadata.label.trim() - ? metadata.label.trim() - : null; - const role = typeof metadata.role === "string" && metadata.role.trim() - ? metadata.role.trim() - : null; - return label ?? item.componentId ?? role ?? "iOS simulator element"; -} - -function iosContextSurface(item: IosElementContextItem): "simulator" | "xcode-preview" { - const source = typeof item.metadata?.screenElementSource === "string" ? item.metadata.screenElementSource : ""; - return item.metadata?.contextSurface === "xcode-preview" || source.startsWith("xcode-preview") - ? "xcode-preview" - : "simulator"; -} - -function formatIosElementContextChipsForDisplay(items: IosElementContextItem[]): string { - if (!items.length) return ""; - return items.map((item) => `\`${iosContextLabel(item)}\``).join(" "); -} - -function formatIosElementContextForPrompt(items: IosElementContextItem[]): string { - if (!items.length) return ""; - const rows = items.map((item, index) => { - const metadata = item.metadata ?? {}; - const sourceConfidence = typeof metadata.sourceConfidence === "string" - ? metadata.sourceConfidence - : item.sourceFile ? "exact" : "none"; - let source: string; - if (item.sourceFile) { - source = `${item.sourceFile}${item.sourceLine ? `:${item.sourceLine}` : ""}`; - } else if (sourceConfidence === "candidate") { - source = "no exact source; ranked candidates below"; - } else { - source = "no source match"; - } - const frame = item.frame - ? `x=${item.frame.x}, y=${item.frame.y}, w=${item.frame.width}, h=${item.frame.height}` - : "unknown frame"; - const attachmentPath = getIosContextAttachmentPath(item); - const sourceCandidates = asRecordArray(metadata.sourceCandidates ?? metadata.sourceMatches) - .slice(0, 3) - .map((candidate) => ({ - sourceFile: candidate.sourceFile, - sourceLine: candidate.sourceLine, - confidence: candidate.confidence, - reason: candidate.reason, - snippet: typeof candidate.snippet === "string" ? candidate.snippet : undefined, - })); - const nearbyElements = asRecordArray(metadata.nearbyElements) - .slice(0, 8) - .map((element) => ({ - label: element.label, - value: element.value, - role: element.role, - elementType: element.elementType, - identifier: element.identifier, - componentId: element.componentId, - source: element.source, - relation: element.relation, - screenshotFrame: element.screenshotFrame, - })); - const packet = { - contextId: item.id, - visualAttachmentPath: attachmentPath, - selectedAt: item.selectedAt, - selectedElement: metadata.selectedElement ?? { - componentId: item.componentId, - accessibilityIdentifier: item.accessibilityIdentifier ?? null, - label: metadata.label, - value: metadata.value, - role: metadata.role, - elementType: metadata.elementType, - screenshotFrame: frame, - }, - screen: metadata.screen, - sourceConfidence, - exactSource: item.sourceFile ? { - sourceFile: item.sourceFile, - sourceLine: item.sourceLine, - snippet: typeof metadata.sourceSnippet === "string" ? metadata.sourceSnippet : null, - } : null, - sourceCandidates, - nearbyElements, - }; - const snippet = typeof metadata.sourceSnippet === "string" && metadata.sourceSnippet.trim().length - ? `\nExact source snippet:\n${metadata.sourceSnippet}` - : ""; - return `${index + 1}. ${iosContextLabel(item)} (${source}, frame=${frame})\nPacket:\n${JSON.stringify(packet, null, 2)}${snippet}`; - }); - return [ - "iOS visual inspect context attached by the user.", - "Each packet came from the user clicking a UI element in the real iOS Simulator, dragging a simulator screenshot region, or dragging a capture area in an Xcode SwiftUI preview. Image attachments/crops are visual evidence for the same packet and use the same screenshot coordinate space.", - "Use exactSource when sourceConfidence is exact. Treat sourceCandidates as ranked best guesses, not proof; prefer nearbyElements and the screenshot when the source is missing or only candidate quality.", - "When the packet surface is xcode-preview, treat it as fast fixture/mock-data feedback rather than live app state. Keep SwiftUI changes previewable with nearby #Preview definitions and deterministic mock fixtures.", - ...rows, - "", - ].join("\n"); -} - -function getIosContextAttachmentPath(item: IosElementContextItem): string | null { - const value = item.metadata?.attachmentPath; - return typeof value === "string" && value.length ? value : null; -} - -function createIosContextInstanceId(item: IosElementContextItem): string { - const suffix = typeof crypto !== "undefined" && "randomUUID" in crypto - ? crypto.randomUUID() - : `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`; - return `${item.id}::${suffix}`; -} - -function appControlContextLabel(item: AppControlContextItem): string { - const metadata = item.metadata ?? {}; - for (const value of [metadata.label, metadata.value, item.componentId, metadata.role, metadata.tagName]) { - if (typeof value === "string" && value.trim()) return value.trim(); - } - return "App Control element"; -} - -function formatAppControlContextChipsForDisplay(items: AppControlContextItem[]): string { - if (!items.length) return ""; - return items.map((item) => `\`${appControlContextLabel(item)}\``).join(" "); -} - -function getAppControlContextAttachmentPath(item: AppControlContextItem): string | null { - const value = item.metadata?.attachmentPath; - return typeof value === "string" && value.length ? value : null; -} - -function createAppControlContextInstanceId(item: AppControlContextItem): string { - const suffix = typeof crypto !== "undefined" && "randomUUID" in crypto - ? crypto.randomUUID() - : `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`; - return `${item.id}::${suffix}`; -} +const AUTO_CREATE_LANE_OPTION_ID = "__ade_auto_create_lane__"; +const AUTO_CREATE_LANE_OPTION = { + id: AUTO_CREATE_LANE_OPTION_ID, + name: "Auto-create lane", + color: null, + branchRef: null, +}; -function formatAppControlContextForPrompt(items: AppControlContextItem[]): string { - if (!items.length) return ""; - const rows = items.map((item, index) => { - const metadata = item.metadata ?? {}; - const sourceConfidence = typeof metadata.sourceConfidence === "string" - ? metadata.sourceConfidence - : item.sourceFile ? "exact" : "none"; - const frame = item.frame - ? `x=${item.frame.x}, y=${item.frame.y}, w=${item.frame.width}, h=${item.frame.height}` - : "unknown frame"; - const attachmentPath = getAppControlContextAttachmentPath(item); - const sourceCandidates = asRecordArray(metadata.sourceCandidates) - .slice(0, 5) - .map((candidate) => ({ - sourceFile: candidate.sourceFile, - sourceLine: candidate.sourceLine, - confidence: candidate.confidence, - reason: candidate.reason, - snippet: typeof candidate.snippet === "string" ? candidate.snippet : undefined, - })); - const nearbyElements = asRecordArray(metadata.nearbyElements).slice(0, 8); - const packet = { - contextId: item.id, - appKind: item.appKind, - provider: item.provider, - visualAttachmentPath: attachmentPath, - selectedAt: item.selectedAt, - selectedElement: metadata.selectedElement ?? { - componentId: item.componentId, - label: metadata.label, - value: metadata.value, - role: metadata.role, - tagName: metadata.tagName, - selector: metadata.selector, - testId: metadata.testId, - screenshotFrame: frame, - }, - screen: metadata.screen, - url: metadata.url, - title: metadata.title, - sourceConfidence, - exactSource: item.sourceFile ? { - sourceFile: item.sourceFile, - sourceLine: item.sourceLine, - snippet: typeof metadata.sourceSnippet === "string" ? metadata.sourceSnippet : null, - } : null, - sourceCandidates, - nearbyElements, - }; - const source = item.sourceFile - ? `${item.sourceFile}${item.sourceLine ? `:${item.sourceLine}` : ""}` - : sourceConfidence === "candidate" ? "no exact source; ranked candidates below" : "no source match"; - const snippet = typeof metadata.sourceSnippet === "string" && metadata.sourceSnippet.trim().length - ? `\nBest source snippet:\n${metadata.sourceSnippet}` - : ""; - return `${index + 1}. ${appControlContextLabel(item)} (${source}, frame=${frame})\nPacket:\n${JSON.stringify(packet, null, 2)}${snippet}`; - }); - return [ - "App Control visual inspect context attached by the user.", - "Each packet came from a developer-owned app session, usually Electron launched or connected through ADE CLI with a local CDP port. Image attachments/crops are visual evidence for the same packet and use screenshot pixel coordinates.", - "Use exactSource when sourceConfidence is exact. Treat sourceCandidates as ranked guesses from DOM text/test ids/selectors and source search, not proof. Prefer the screenshot, DOM selector, nearbyElements, console/browser context, and exact source when available.", - ...rows, - "", - ].join("\n"); -} +const LEGACY_PROVIDER_KEY = "ade.chat.lastProvider"; +const LEGACY_MODEL_KEY_PREFIX = "ade.chat.lastModel"; -function macosVmContextLabel(item: MacosVmContextItem): string { - return item.vmName || "macOS VM"; -} +const COMPUTER_USE_SNAPSHOT_COOLDOWN_MS = 750; +const CHAT_HISTORY_READ_MAX_BYTES = 2_000_000; +const MAX_RETAINED_CHAT_SESSION_HISTORIES = 6; +const MAX_SELECTED_CHAT_SESSION_EVENTS = 20_000; +const MAX_BACKGROUND_CHAT_SESSION_EVENTS = 1_000; -function formatMacosVmContextChipsForDisplay(items: MacosVmContextItem[]): string { - if (!items.length) return ""; - return items.map((item) => `\`${macosVmContextLabel(item)}\``).join(" "); -} +type DraftLaunchSnapshot = { + text: string; + draft: string; + attachments: AgentChatFileRef[]; + contextAttachments: AgentChatContextAttachment[]; + iosContextItems: IosElementContextItem[]; + appControlContextItems: AppControlContextItem[]; + builtInBrowserContextItems: BuiltInBrowserContextItem[]; + macosVmContextItems: MacosVmContextItem[]; + visualContextPrefix: string; + visualContextDisplayChips: string; + isLiteralSlashCommand: boolean; +}; -function createMacosVmContextInstanceId(item: MacosVmContextItem): string { - const suffix = typeof crypto !== "undefined" && "randomUUID" in crypto - ? crypto.randomUUID() - : `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`; - return `${item.id}::${suffix}`; -} +type PreparedDraftLaunch = DraftLaunchSnapshot & { + finalText: string; + finalDisplayText: string; + selectedAttachments: AgentChatFileRef[]; + selectedContextAttachments: AgentChatContextAttachment[]; +}; -function getMacosVmContextAttachmentPath(item: MacosVmContextItem): string | null { - const value = item.metadata?.screenshotPath ?? item.metadata?.attachmentPath; - return typeof value === "string" && value.length ? value : null; -} +type BackgroundLaunchNotice = { + laneId: string; + laneName: string; + sessionId: string; +}; -function formatMacosVmContextForPrompt(items: MacosVmContextItem[]): string { - if (!items.length) return ""; - const rows = items.map((item, index) => { - const metadata = item.metadata ?? {}; - const attachmentPath = getMacosVmContextAttachmentPath(item); - const packet = { - contextId: item.id, - provider: item.provider, - state: item.state, - visualAttachmentPath: attachmentPath, - laneId: item.laneId, - laneName: item.laneName, - vmName: item.vmName, - hostLanePath: item.hostLanePath, - guestLanePath: item.guestLanePath, - runCommand: item.runCommand, - sshCommand: item.sshCommand, - vncUrl: item.vncUrl, - windowTitleQuery: item.windowTitleQuery, - selectedAt: item.selectedAt, - selectedPoint: metadata.selectedPoint, - screenshotPath: metadata.screenshotPath, - metadata, - }; - return `${index + 1}. ${macosVmContextLabel(item)} (${item.state}, lane=${item.laneName})\nPacket:\n${JSON.stringify(packet, null, 2)}`; - }); +function createTemporaryAutoLaneName(date = new Date()): string { + const pad = (value: number) => String(value).padStart(2, "0"); return [ - "macOS VM target context attached by the user.", - "Use this VM for isolated GUI validation tied to the active lane. The host lane path is shared into the guest with VirtioFS, so edits in the mounted folder stay synced between host and guest. When visualAttachmentPath or selectedPoint is present, treat it as screenshot-backed VM context.", - "For GUI computer use, prefer ADE macos-vm screenshot/click/type tools; they use headless VNC when available and only fall back to a visible Lume/VNC/macOS VM window. Do not target the host ADE window unless the user asks to switch back.", - "For shell commands inside the guest, use sshCommand when present or configure SSH/in-guest agent access, then work from guestLanePath.", - "Keep secrets out of the VM; ADE blocks lane roots that contain .ade/secrets from being mounted.", - ...rows, - "", - ].join("\n"); -} - -function formatAutomaticMacosVmContextForPrompt(status: MacosVmStatus, laneId: string): string { - if (!status.supported || !status.activeProvider.available) return ""; - const vm = status.laneVm; - const state = vm?.state ?? "not_created"; - const guestPath = vm?.guestSharedPath ?? "/Volumes/My Shared Files"; - const hostPath = vm?.laneRoot ?? "current ADE lane worktree"; - const lines = [ - "ADE macOS VM capability for this lane (automatic context).", - "Use this capability only when the user asks to use the ADE VM, needs isolated macOS GUI validation, or asks for VM-backed computer use. Query fresh state before acting; do not assume this snapshot is current.", - `- Lane id: ${laneId}`, - `- Provider: ${status.activeProvider.kind}${status.activeProvider.version ? ` ${status.activeProvider.version}` : ""}`, - vm - ? `- Current lane VM: ${vm.name} (${state})` - : "- Current lane VM: not provisioned yet", - `- Host lane path: ${hostPath}`, - `- Guest lane path: ${guestPath}`, - vm?.sharedDirectory ? `- Shared directory mounted into the VM: ${vm.sharedDirectory}` : null, - vm?.sshCommand ? `- Guest SSH command: ${vm.sshCommand}` : "- Guest SSH command: not configured yet", - vm?.vncUrl ? `- Sanitized VNC URL: ${vm.vncUrl}` : "- Sanitized VNC URL: available after the VM is running", - "- ADE RPC tools: macos_vm_status, macos_vm_start, macos_vm_focus, macos_vm_screenshot, macos_vm_select, macos_vm_click, macos_vm_type.", - `- ADE CLI examples: ade macos-vm status --lane ${laneId} --text; ade macos-vm start --lane ${laneId} --create --no-display; ade macos-vm screenshot --lane ${laneId} --text.`, - "- GUI control goes through ADE's direct headless VNC bridge when available, then falls back to a visible VM viewer. Do not target the host ADE window when the task is to control the lane VM.", - state === "running" - ? "- The VM is currently running; prefer macos_vm_screenshot first, then click/type/select against the VM." - : "- The VM is not running; start it only if the user asked for VM use or the task clearly needs isolated macOS GUI validation.", - vm?.metadata?.shareMode === "sanitized-mirror" - ? "- This lane uses a sanitized mirror for the VM share; ADE syncs code while excluding secrets, runtime databases, caches, transcripts, generated local history, and .git." - : "- Keep VM-side edits inside the mounted guest lane path so the host lane and guest stay aligned.", - "", - ]; - return lines.filter((line): line is string => Boolean(line)).join("\n"); -} - -async function buildAutomaticMacosVmContextForPrompt(laneId: string): Promise { - const api = window.ade?.macosVm; - if (!api?.getStatus) return ""; - try { - const status = await api.getStatus({ laneId }); - return formatAutomaticMacosVmContextForPrompt(status, laneId); - } catch { - return ""; - } -} - -function normalizeBuiltInBrowserContextItem(value: unknown): BuiltInBrowserContextItem | null { - const record = asRecord(value); - if (!record) return null; - const metadata = asRecord(record.metadata) ?? {}; - const pixelFrameCandidate = frameFromUnknown(record.pixelFrame); - const frame = frameFromUnknown(record.frame) ?? pixelFrameCandidate ?? frameFromUnknown(record.bounds) ?? { - x: 0, - y: 0, - width: 0, - height: 0, - }; - const pixelFrame = pixelFrameCandidate ?? frame; - const componentId = stringOrNull(record.componentId) - ?? stringOrNull(metadata.selector) - ?? stringOrNull(metadata.testId) - ?? stringOrNull(metadata.tagName) - ?? "browser-element"; - const kind = stringOrNull(record.kind) === "built_in_browser_capture" ? "built_in_browser_capture" : "built_in_browser_element"; - return { - kind, - id: stringOrNull(record.id) ?? `built-in-browser:${Date.now().toString(36)}`, - provider: "cdp", - componentId, - url: stringOrNull(record.url), - title: stringOrNull(record.title), - sourceFile: stringOrNull(record.sourceFile), - sourceLine: numberOrNull(record.sourceLine), - frame, - pixelFrame, - metadata, - screenshotDataUrl: stringOrNull(record.screenshotDataUrl) ?? stringOrNull(record.dataUrl), - selectedAt: stringOrNull(record.selectedAt) ?? new Date().toISOString(), - }; -} - -function builtInBrowserContextLabel(item: BuiltInBrowserContextItem): string { - const metadata = item.metadata ?? {}; - if (item.kind === "built_in_browser_capture") { - const selectedElement = asRecord(metadata.selectedElement); - const selectedLabel = stringOrNull(selectedElement?.label); - return selectedLabel ? `Browser capture: ${selectedLabel}` : "Browser screenshot capture"; - } - for (const value of [ - metadata.label, - metadata.text, - metadata.value, - item.componentId, - metadata.selector, - metadata.role, - metadata.tagName, - ]) { - if (typeof value === "string" && value.trim()) return value.trim(); - } - return "Browser element"; -} - -function formatBuiltInBrowserContextChipsForDisplay(items: BuiltInBrowserContextItem[]): string { - if (!items.length) return ""; - return items.map((item) => `\`${builtInBrowserContextLabel(item)}\``).join(" "); -} - -function getBuiltInBrowserContextAttachmentPath(item: BuiltInBrowserContextItem): string | null { - const value = item.metadata?.attachmentPath; - return typeof value === "string" && value.length ? value : null; + "chat", + `${date.getFullYear()}${pad(date.getMonth() + 1)}${pad(date.getDate())}`, + `${pad(date.getHours())}${pad(date.getMinutes())}${pad(date.getSeconds())}`, + ].join("-"); } -function createBuiltInBrowserContextInstanceId(item: BuiltInBrowserContextItem): string { - const suffix = typeof crypto !== "undefined" && "randomUUID" in crypto - ? crypto.randomUUID() - : `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`; - return `${item.id}::${suffix}`; +function buildDraftLaunchNamingSeed(snapshot: DraftLaunchSnapshot): string { + if (snapshot.text.length) return snapshot.text; + const imageCount = snapshot.attachments.filter((attachment) => attachment.type === "image").length; + const fileCount = snapshot.attachments.filter((attachment) => attachment.type === "file").length; + const issueCount = snapshot.contextAttachments.filter((attachment) => attachment.type === "linear_issue").length; + const parts = [ + "New chat task", + imageCount ? `${imageCount} image${imageCount === 1 ? "" : "s"}` : null, + fileCount ? `${fileCount} file${fileCount === 1 ? "" : "s"}` : null, + issueCount ? `${issueCount} issue${issueCount === 1 ? "" : "s"}` : null, + ].filter(Boolean); + return parts.join(" - "); } -function stripDataUrlPrefix(dataUrl: string): string { - const comma = dataUrl.indexOf(","); - return comma >= 0 ? dataUrl.slice(comma + 1) : dataUrl; -} - -function formatBuiltInBrowserContextForPrompt(items: BuiltInBrowserContextItem[]): string { - if (!items.length) return ""; - const rows = items.map((item, index) => { - const metadata = item.metadata ?? {}; - const frame = item.frame - ? `x=${item.frame.x}, y=${item.frame.y}, w=${item.frame.width}, h=${item.frame.height}` - : "unknown frame"; - const attachmentPath = getBuiltInBrowserContextAttachmentPath(item); - const packet = { - contextId: item.id, - provider: item.provider, - visualAttachmentPath: attachmentPath, - selectedAt: item.selectedAt, - url: item.url ?? metadata.url ?? null, - title: item.title ?? metadata.title ?? null, - selectedElement: metadata.selectedElement ?? { - componentId: item.componentId, - label: metadata.label, - value: metadata.value, - role: metadata.role, - tagName: metadata.tagName, - selector: metadata.selector, - testId: metadata.testId, - screenshotFrame: frame, - }, - attributes: metadata.attributes, - href: metadata.href, - inputType: metadata.inputType, - disabled: metadata.disabled, - checked: metadata.checked, - viewport: metadata.viewport, - scroll: metadata.scroll, - captureFrame: metadata.captureFrame, - crop: metadata.crop, - centerPoint: metadata.centerPoint, - source: metadata.source, - sourceConfidence: metadata.sourceConfidence, - selectionExplanation: metadata.selectionExplanation, - }; - return `${index + 1}. ${builtInBrowserContextLabel(item)} (global browser, frame=${frame})\nPacket:\n${JSON.stringify(packet, null, 2)}`; - }); - return [ - "Built-in browser visual and DOM context attached by the user.", - "Each packet came from ADE's global built-in browser, which is not lane-scoped. Image attachments/crops are visual evidence for the same page area and use browser viewport coordinates.", - "Use selectors, ARIA labels, attributes, text, URL/title, and the screenshot together. Do not assume the selected page belongs to the current lane unless the URL or user message says so.", - ...rows, - "", - ].join("\n"); -} - -const LEGACY_PROVIDER_KEY = "ade.chat.lastProvider"; -const LEGACY_MODEL_KEY_PREFIX = "ade.chat.lastModel"; - -const COMPUTER_USE_SNAPSHOT_COOLDOWN_MS = 750; -const CHAT_HISTORY_READ_MAX_BYTES = 2_000_000; -const MAX_RETAINED_CHAT_SESSION_HISTORIES = 6; -const MAX_SELECTED_CHAT_SESSION_EVENTS = 20_000; -const MAX_BACKGROUND_CHAT_SESSION_EVENTS = 1_000; - type AiStatusSnapshot = AiSettingsStatus & { runtimeConnections?: Record; }; @@ -936,6 +561,66 @@ function legacyPermissionModeToDroidPermissionMode( return undefined; } +function cliPermissionModeFromNativeControls(provider: CliProvider, controls: NativeControlState): AgentChatPermissionMode { + if (provider === "cursor") { + const modeId = controls.cursorModeId?.trim().toLowerCase() ?? ""; + if (modeId.includes("full") || modeId.includes("auto")) return "full-auto"; + if (modeId.includes("plan")) return "plan"; + if (modeId.includes("ask")) return "edit"; + return "default"; + } + return summarizeNativeControls(provider, controls).permissionMode ?? "default"; +} + +function formatWorkCliAttachmentManifest(attachments: AgentChatFileRef[]): string { + if (!attachments.length) return ""; + return [ + "Attached files and images:", + ...attachments.map((attachment, index) => { + if (attachment.type === "image-url") { + return `${index + 1}. Image URL: ${attachment.url}`; + } + return `${index + 1}. ${attachment.type === "image" ? "Image file" : "File"}: ${attachment.path}`; + }), + ].join("\n"); +} + +function buildWorkCliInitialPrompt(args: { + text: string; + attachments: AgentChatFileRef[]; + contextAttachments: AgentChatContextAttachment[]; +}): string { + return [ + formatWorkCliAttachmentManifest(args.attachments), + buildChatContextAttachmentPrompt(args.contextAttachments), + args.text.trim(), + ].filter((part) => part.trim().length > 0).join("\n\n"); +} + +function workCliTitleFromPrompt(seed: string, fallback: string): string { + const cleaned = seed + .replace(/^["'`]+|["'`]+$/g, "") + .replace(/\s+/g, " ") + .trim(); + if (!cleaned) return fallback; + const withoutSlash = cleaned.startsWith("/") && !isProviderSlashCommandInput(cleaned) + ? cleaned.slice(1).trim() + : cleaned; + const clipped = withoutSlash.length > 56 + ? withoutSlash.slice(0, 56).replace(/\s+\S*$/u, "").trim() + : withoutSlash; + const title = (clipped || withoutSlash).replace(/[.?!,:;]+$/u, "").trim(); + return title ? title.charAt(0).toUpperCase() + title.slice(1) : fallback; +} + +function createClaudeSessionIdForCliLaunch(): string | undefined { + try { + return globalThis.crypto?.randomUUID?.(); + } catch { + return undefined; + } +} + /** * Build a fallback CursorModeSnapshot when the Cursor SDK runtime hasn't * reported its own snapshot yet. @@ -1186,6 +871,58 @@ function chatEventDedupKey(entry: AgentChatEventEnvelope): string { return `${entry.timestamp}#${entry.event.type}#${JSON.stringify(entry.event)}`; } +function userMessageVisibleText(event: Extract): string { + const displayText = event.displayText?.trim(); + return displayText?.length ? displayText : event.text.trim(); +} + +function attachmentMatchKey(attachment: AgentChatFileRef): string { + return attachment.type === "image-url" + ? `${attachment.type}:${attachment.path}:${attachment.url}` + : `${attachment.type}:${attachment.path}`; +} + +function contextAttachmentMatchKey(attachment: AgentChatContextAttachment): string { + return `${attachment.type}:${attachment.issue.id}`; +} + +function sortedMatchKeys(items: T[] | undefined, readKey: (item: T) => string): string[] { + return (items ?? []).map(readKey).sort((left, right) => left.localeCompare(right)); +} + +function matchKeyLists(left: string[], right: string[]): boolean { + if (left.length !== right.length) return false; + return left.every((value, index) => value === right[index]); +} + +export function isMatchingOptimisticUserMessage( + committed: AgentChatEventEnvelope, + optimistic: AgentChatEventEnvelope, +): boolean { + if (committed.sessionId !== optimistic.sessionId) return false; + if (committed.event.type !== "user_message" || optimistic.event.type !== "user_message") return false; + if (committed.event.steerId || optimistic.event.steerId) return false; + if (userMessageVisibleText(committed.event) !== userMessageVisibleText(optimistic.event)) return false; + + return ( + matchKeyLists( + sortedMatchKeys(committed.event.attachments, attachmentMatchKey), + sortedMatchKeys(optimistic.event.attachments, attachmentMatchKey), + ) + && matchKeyLists( + sortedMatchKeys(committed.event.contextAttachments, contextAttachmentMatchKey), + sortedMatchKeys(optimistic.event.contextAttachments, contextAttachmentMatchKey), + ) + ); +} + +function hasMatchingCommittedUserMessage( + events: AgentChatEventEnvelope[], + optimistic: AgentChatEventEnvelope, +): boolean { + return events.some((event) => isMatchingOptimisticUserMessage(event, optimistic)); +} + export function mergeChatHistorySnapshot( parsed: AgentChatEventEnvelope[], existing: AgentChatEventEnvelope[], @@ -1629,6 +1366,9 @@ export function AgentChatPane({ initialLinearIssueContext = null, onInitialLinearIssueContextConsumed, onSessionCreated, + workDraftKind = "chat", + onLaunchCliSession, + onOpenShellSession, availableLanes, onLaneChange, }: { @@ -1658,8 +1398,20 @@ export function AgentChatPane({ initialLinearIssueContext?: LaneLinearIssue | null; onInitialLinearIssueContextConsumed?: () => void; onSessionCreated?: (session: AgentChatSession) => void | Promise; + workDraftKind?: "chat" | "cli"; + onLaunchCliSession?: (args: { + laneId: string; + profile: LaunchProfile; + title?: string; + startupCommand?: string; + command?: string; + args?: string[]; + env?: Record; + tracked?: boolean; + }) => Promise; + onOpenShellSession?: (laneId: string) => void | Promise; /** Available lanes for the lane selector in empty state (full `LaneSummary` includes `branchRef` for branch sublines in the menu). */ - availableLanes?: Array<{ id: string; name: string; color?: string | null; branchRef?: string | null }>; + availableLanes?: Array<{ id: string; name: string; color?: string | null; branchRef?: string | null; laneType?: string | null }>; /** Callback when lane selection changes in empty state */ onLaneChange?: (laneId: string) => void; }) { @@ -1706,6 +1458,17 @@ export function AgentChatPane({ const [sessions, setSessions] = useState([]); const [archivedSessions, setArchivedSessions] = useState([]); const [selectedSessionId, setSelectedSessionId] = useState(lockSessionId ?? initialSessionId ?? null); + const [draftLaunchTargetId, setDraftLaunchTargetId] = useState(null); + const [backgroundLaunchBusy, setBackgroundLaunchBusy] = useState(false); + const [backgroundLaunchNotice, setBackgroundLaunchNotice] = useState(null); + const isWorkCliLaunchDraft = + !lockSessionId + && !initialSessionId + && forceDraft + && embeddedWorkLayout + && workDraftKind === "cli" + && selectedSessionId == null + && Boolean(onLaunchCliSession); const [eventsBySession, setEventsBySession] = useState>({}); const [turnActiveBySession, setTurnActiveBySession] = useState>({}); const [pendingInputsBySession, setPendingInputsBySession] = useState>({}); @@ -1774,10 +1537,10 @@ export function AgentChatPane({ const [draft, setDraft] = useState(""); const draftsPerSessionRef = useRef>(new Map()); const [busy, setBusy] = useState(false); + const [shellLaunchBusy, setShellLaunchBusy] = useState(false); const [loading, setLoading] = useState(false); const [preferencesReady, setPreferencesReady] = useState(false); const [error, setError] = useState(null); - const [closingChatSessionId, setClosingChatSessionId] = useState(null); const [deletingChatSessionId, setDeletingChatSessionId] = useState(null); const [computerUseSnapshot, setComputerUseSnapshot] = useState(null); const [proofDrawerOpen, setProofDrawerOpen] = useState( @@ -1897,6 +1660,14 @@ export function AgentChatPane({ const [parallelChatMode, setParallelChatMode] = useState(false); const [parallelModelSlots, setParallelModelSlots] = useState([]); const [parallelConfiguringIndex, setParallelConfiguringIndex] = useState(null); + + useEffect(() => { + if (workDraftKind === "cli" && parallelChatMode) { + setParallelChatMode(false); + setParallelModelSlots([]); + setParallelConfiguringIndex(null); + } + }, [parallelChatMode, workDraftKind]); const [parallelLaunchBusy, setParallelLaunchBusy] = useState(false); const [parallelLaunchStatus, setParallelLaunchStatus] = useState(null); const shellRef = useRef(null); @@ -2071,7 +1842,11 @@ export function AgentChatPane({ const selectedEvents = selectedSessionId ? eventsBySession[selectedSessionId] ?? [] : []; const optimisticOutgoingMessageRef = useRef(null); const selectedEventsForDisplay = useMemo(() => { - const baseEvents = optimisticOutgoingMessage && optimisticOutgoingMessage.sessionId === selectedSessionId + const shouldRenderOptimistic = + optimisticOutgoingMessage + && optimisticOutgoingMessage.sessionId === selectedSessionId + && !hasMatchingCommittedUserMessage(selectedEvents, optimisticOutgoingMessage.envelope); + const baseEvents = shouldRenderOptimistic ? [...selectedEvents, optimisticOutgoingMessage.envelope] : selectedEvents; const renderableEvents = baseEvents.filter((envelope) => !envelope.event.type.startsWith("subagent.")); @@ -2136,7 +1911,10 @@ export function AgentChatPane({ return sawUsageEvent ? usageFromEvents : (selectedSession?.codexTokenUsage ?? null); }, [selectedEventsForDisplay, selectedSession?.codexTokenUsage]); const selectedSubagentSnapshots = useMemo(() => deriveChatSubagentSnapshots(selectedEvents), [selectedEvents]); - const selectedSubagentPaneAvailable = selectedSession?.provider === "claude" && selectedSubagentSnapshots.length > 0; + // The pane is runtime-agnostic — Codex emits subagent_started/progress/result + // events for delegation and collabToolCall items (spawn_agent, etc.) just + // like Claude. Gate on whether we actually have snapshots to display. + const selectedSubagentPaneAvailable = selectedSubagentSnapshots.length > 0; const selectedTurnDiffSummaries = useMemo(() => deriveTurnDiffSummaries(selectedEvents), [selectedEvents]); const selectedTodoItems = useMemo(() => deriveTodoItems(selectedEvents), [selectedEvents]); const pendingInput = selectedSessionId ? (pendingInputsBySession[selectedSessionId]?.[0] ?? null) : null; @@ -2165,30 +1943,32 @@ export function AgentChatPane({ setError(controlError instanceof Error ? controlError.message : String(controlError)); } }, [turnActiveBySession]); - const subagentAutoOpenRef = useRef<{ sessionId: string | null; count: number }>({ sessionId: null, count: 0 }); + // Per-session memo of which sessions have already triggered the auto-open + // affordance, so the panel doesn't keep re-opening every time a new subagent + // appears. We only slide it in on the *first* spawn within a session. + const subagentAutoOpenedSessionsRef = useRef>(new Set()); useEffect(() => { - if (!selectedSessionId || selectedSession?.provider !== "claude") { - subagentAutoOpenRef.current = { sessionId: selectedSessionId ?? null, count: 0 }; + if (!selectedSessionId) { if (subagentPaneOpen) setSubagentPaneOpen(false); return; } - const previous = subagentAutoOpenRef.current.sessionId === selectedSessionId - ? subagentAutoOpenRef.current.count - : selectedSubagentSnapshots.length; - if ( - subagentAutoOpenRef.current.sessionId === selectedSessionId - && selectedSubagentSnapshots.length > previous - && !subagentPaneOpen - ) { + if (selectedSubagentSnapshots.length === 0) { + if (subagentPaneOpen) setSubagentPaneOpen(false); + return; + } + if (subagentAutoOpenedSessionsRef.current.has(selectedSessionId)) { + return; + } + subagentAutoOpenedSessionsRef.current.add(selectedSessionId); + if (!subagentPaneOpen) { setProofDrawerOpen(false); setIosSimulatorOpen(false); setAppControlOpen(false); setCursorCloudPaneOpen(false); setSubagentPaneOpen(true); } - subagentAutoOpenRef.current = { sessionId: selectedSessionId, count: selectedSubagentSnapshots.length }; - }, [selectedSession?.provider, selectedSessionId, selectedSubagentSnapshots.length, subagentPaneOpen]); + }, [selectedSessionId, selectedSubagentSnapshots.length, subagentPaneOpen]); const persistParallelLaunchState = useCallback(async (state: AgentChatParallelLaunchState | null) => { if (!projectRoot || !laneId) return; @@ -3585,6 +3365,7 @@ export function AgentChatPane({ setPromptSuggestion(null); setHandoffOpen(false); setHandoffBusy(false); + optimisticOutgoingMessageRef.current = null; setOptimisticOutgoingMessage(null); // Also clear when the active lane changes — otherwise issue context staged // in draft mode would persist after switching lanes. @@ -3594,6 +3375,25 @@ export function AgentChatPane({ optimisticOutgoingMessageRef.current = optimisticOutgoingMessage; }, [optimisticOutgoingMessage]); + // Update the ref synchronously alongside the state setter so the chat event + // handler at the top of this component can immediately observe the optimistic + // envelope. If we rely on the useEffect above, the dedup branch may run with + // a stale (null) ref when the backend's real `user_message` envelope arrives + // in the same microtask as the send IPC resolving — producing a duplicate + // bubble that only clears on tab unmount. + const setOptimisticOutgoingMessageSynced = useCallback((next: { sessionId: string; envelope: AgentChatEventEnvelope } | null) => { + optimisticOutgoingMessageRef.current = next; + setOptimisticOutgoingMessage(next); + }, []); + + useEffect(() => { + const optimistic = optimisticOutgoingMessageRef.current; + if (!optimistic) return; + const committedEvents = eventsBySession[optimistic.sessionId] ?? []; + if (!hasMatchingCommittedUserMessage(committedEvents, optimistic.envelope)) return; + setOptimisticOutgoingMessageSynced(null); + }, [eventsBySession, setOptimisticOutgoingMessageSynced]); + // Fetch provider slash commands when session, lane, or draft provider changes. useEffect(() => { if (!isTileActive) { setSdkSlashCommands([]); return; } @@ -3647,6 +3447,10 @@ export function AgentChatPane({ const sessionEvents = next === eventsBySessionRef.current ? (eventsBySessionRef.current[sessionId] ?? []) : (next[sessionId] ?? []); + const envelopeKey = chatEventDedupKey(envelope); + if (sessionEvents.some((event) => chatEventDedupKey(event) === envelopeKey)) { + continue; + } const updated = trimChatEventHistory( [...sessionEvents, envelope], sessionId === selectedSessionIdRef.current || sessionId === lockSessionId @@ -3710,10 +3514,13 @@ export function AgentChatPane({ useEffect(() => { const unsubscribe = window.ade.agentChat.onEvent((envelope) => { + const optimistic = optimisticOutgoingMessageRef.current; if ( - optimisticOutgoingMessageRef.current?.sessionId === envelope.sessionId + optimistic?.sessionId === envelope.sessionId && envelope.event.type === "user_message" + && isMatchingOptimisticUserMessage(envelope, optimistic.envelope) ) { + optimisticOutgoingMessageRef.current = null; setOptimisticOutgoingMessage(null); } const acceptsEvent = @@ -3721,6 +3528,17 @@ export function AgentChatPane({ || optimisticSessionIdsRef.current.has(envelope.sessionId) || pendingSelectedSessionIdRef.current === envelope.sessionId; if (!acceptsEvent) return; + + // session_meta_updated is a UI-state-only patch — don't queue it as a + // chat event since it doesn't represent transcript content. + if (envelope.event.type === "session_meta_updated") { + const meta = envelope.event; + if (typeof meta.title === "string" && meta.title.length > 0) { + patchSessionSummary(envelope.sessionId, { title: meta.title }); + } + return; + } + pendingEventQueueRef.current.push(envelope); const touchTimestamp = getChatSessionLocalTouchTimestampForEvent(envelope); if (touchTimestamp) { @@ -4201,13 +4019,24 @@ export function AgentChatPane({ if (!onSessionCreated) return; void Promise.resolve(onSessionCreated(session)).catch((err) => { console.error("notifySessionCreated failed:", err); }); }, [onSessionCreated]); - - const createSession = useCallback(async (): Promise => { - if (createSessionPromiseRef.current) { - return createSessionPromiseRef.current; + const draftLaunchTargetIsAutoCreate = draftLaunchTargetId === AUTO_CREATE_LANE_OPTION_ID; + const launchShellForDraftLane = useCallback(async () => { + if (!laneId || draftLaunchTargetIsAutoCreate || !onOpenShellSession || shellLaunchBusy) return; + setShellLaunchBusy(true); + setError(null); + try { + await onOpenShellSession(laneId); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } finally { + setShellLaunchBusy(false); } - if (!laneId) return null; - const createPromise = (async () => { + }, [draftLaunchTargetIsAutoCreate, laneId, onOpenShellSession, shellLaunchBusy]); + + const createSessionForLane = useCallback(async ( + targetLaneId: string, + options: { select?: boolean; notify?: boolean } = {}, + ): Promise => { const desc = getModelById(modelId); const permissionDesc = getModelDescriptorForPermissionMode(modelId); const provider = resolveChatRuntimeProvider(desc); @@ -4226,7 +4055,7 @@ export function AgentChatPane({ } : buildNativeControlPayload(provider); const created = await window.ade.agentChat.create({ - laneId, + laneId: targetLaneId, provider, model, modelId, @@ -4238,16 +4067,18 @@ export function AgentChatPane({ loadedHistoryRef.current.delete(created.id); optimisticSessionIdsRef.current.add(created.id); knownSessionIdsRef.current.add(created.id); - pendingSelectedSessionIdRef.current = created.id; - draftSelectionLockedRef.current = false; touchSession(created.id); - setSelectedSessionId(created.id); + if (options.select) { + pendingSelectedSessionIdRef.current = created.id; + draftSelectionLockedRef.current = false; + setSelectedSessionId(created.id); + } // Only rebind the iOS simulator to a freshly created chat when the user // has opened the simulator drawer for THIS chat. The eager-create path // would otherwise steal ownership from a chat that is currently using // the simulator (e.g. switching to a new lane creates a new session // before any user gesture occurs). - if (iosSimulatorOpen) { + if (options.select && iosSimulatorOpen && targetLaneId === laneId) { try { void window.ade.iosSimulator ?.attachToChatSession?.({ chatSessionId: created.id }) @@ -4258,12 +4089,22 @@ export function AgentChatPane({ window.ade.agentChat.warmupModel({ sessionId: created.id, modelId, - }).then(() => refreshSessions()).catch(() => { /* warmup is best-effort */ }); + }).then(() => { + if (targetLaneId === laneId) void refreshSessions(); + }).catch(() => { /* warmup is best-effort */ }); } - notifySessionCreated(created); - void refreshSessions().catch(() => {}); - return created.id; - })(); + if (options.notify) notifySessionCreated(created); + if (targetLaneId === laneId) void refreshSessions().catch(() => {}); + return created; + }, [buildNativeControlPayload, codexFastMode, currentNativeControls, iosSimulatorOpen, laneId, modelId, notifySessionCreated, reasoningEffort, refreshSessions, touchSession]); + + const createSession = useCallback(async (): Promise => { + if (createSessionPromiseRef.current) { + return createSessionPromiseRef.current; + } + if (!laneId) return null; + const createPromise = createSessionForLane(laneId, { select: true, notify: true }) + .then((created) => created.id); createSessionPromiseRef.current = createPromise; try { return await createPromise; @@ -4272,7 +4113,225 @@ export function AgentChatPane({ createSessionPromiseRef.current = null; } } - }, [buildNativeControlPayload, codexFastMode, currentNativeControls, iosSimulatorOpen, laneId, modelId, notifySessionCreated, reasoningEffort, refreshSessions, touchSession]); + }, [createSessionForLane, laneId]); + + const buildDraftLaunchSnapshotForCurrentState = useCallback((): DraftLaunchSnapshot | null => { + const text = draft.trim(); + const iosContextSnapshot = [...iosElementContextItems]; + const appControlContextSnapshot = [...appControlContextItems]; + const builtInBrowserContextSnapshot = [...builtInBrowserContextItems]; + const macosVmContextSnapshot = [...macosVmContextItems]; + const contextAttachmentsSnapshot = [...contextAttachments]; + const visualContextPrefix = [ + formatIosElementContextForPrompt(iosContextSnapshot), + formatAppControlContextForPrompt(appControlContextSnapshot), + formatBuiltInBrowserContextForPrompt(builtInBrowserContextSnapshot), + formatMacosVmContextForPrompt(macosVmContextSnapshot), + ].filter(Boolean).join("\n"); + const visualContextDisplayChips = [ + formatIosElementContextChipsForDisplay(iosContextSnapshot), + formatAppControlContextChipsForDisplay(appControlContextSnapshot), + formatBuiltInBrowserContextChipsForDisplay(builtInBrowserContextSnapshot), + formatMacosVmContextChipsForDisplay(macosVmContextSnapshot), + ].filter(Boolean).join(" "); + if ( + !text.length + && !visualContextPrefix.length + && !contextAttachmentsSnapshot.length + && !(isWorkCliLaunchDraft && attachments.length) + ) { + return null; + } + return { + text, + draft, + attachments: [...attachments], + contextAttachments: contextAttachmentsSnapshot, + iosContextItems: iosContextSnapshot, + appControlContextItems: appControlContextSnapshot, + builtInBrowserContextItems: builtInBrowserContextSnapshot, + macosVmContextItems: macosVmContextSnapshot, + visualContextPrefix, + visualContextDisplayChips, + isLiteralSlashCommand: isProviderSlashCommandInput(text), + }; + }, [ + appControlContextItems, + attachments, + builtInBrowserContextItems, + contextAttachments, + draft, + iosElementContextItems, + isWorkCliLaunchDraft, + macosVmContextItems, + ]); + + const prepareDraftLaunchForSend = useCallback(async ( + snapshot: DraftLaunchSnapshot, + targetLaneId: string, + ): Promise => { + const automaticMacosVmContextPrefix = await buildAutomaticMacosVmContextForPrompt(targetLaneId); + const finalTextPrefix = [automaticMacosVmContextPrefix, snapshot.visualContextPrefix].filter(Boolean).join("\n"); + let finalText = finalTextPrefix ? `${finalTextPrefix}${snapshot.text}` : snapshot.text; + if (!finalText.trim().length && snapshot.contextAttachments.length) { + finalText = "Use the attached issue context."; + } + const finalDisplayText = snapshot.visualContextDisplayChips + ? snapshot.text.length + ? `${snapshot.visualContextDisplayChips} ${snapshot.text}` + : snapshot.visualContextDisplayChips + : snapshot.text.length + ? snapshot.text + : "Attached issue context"; + return { + ...snapshot, + finalText, + finalDisplayText, + selectedAttachments: snapshot.isLiteralSlashCommand ? [] : snapshot.attachments, + selectedContextAttachments: snapshot.isLiteralSlashCommand ? [] : snapshot.contextAttachments, + }; + }, []); + + const restoreDraftLaunchSnapshot = useCallback((snapshot: DraftLaunchSnapshot) => { + setDraft((current) => (current.trim().length ? current : snapshot.draft)); + setAttachments((current) => (current.length ? current : snapshot.attachments)); + setContextAttachments((current) => (current.length ? current : snapshot.contextAttachments)); + setIosElementContextItems((current) => (current.length ? current : snapshot.iosContextItems)); + setAppControlContextItems((current) => (current.length ? current : snapshot.appControlContextItems)); + setBuiltInBrowserContextItems((current) => (current.length ? current : snapshot.builtInBrowserContextItems)); + setMacosVmContextItems((current) => (current.length ? current : snapshot.macosVmContextItems)); + }, []); + + const openLaunchedDraftChat = useCallback((launch: BackgroundLaunchNotice) => { + setBackgroundLaunchNotice(null); + if (projectRoot) { + setWorkViewState(projectRoot, (prev) => ({ + ...prev, + openItemIds: prev.openItemIds.includes(launch.sessionId) + ? prev.openItemIds + : [...prev.openItemIds, launch.sessionId], + })); + setLaneWorkViewState(projectRoot, launch.laneId, (prev) => ({ + ...prev, + openItemIds: prev.openItemIds.includes(launch.sessionId) + ? prev.openItemIds + : [...prev.openItemIds, launch.sessionId], + activeItemId: launch.sessionId, + selectedItemId: launch.sessionId, + draftKind: "chat", + viewMode: "tabs", + })); + } + navigate(`/lanes?laneId=${encodeURIComponent(launch.laneId)}&sessionId=${encodeURIComponent(launch.sessionId)}&focus=single`); + }, [navigate, projectRoot, setLaneWorkViewState, setWorkViewState]); + + const resolveDraftLaunchLane = useCallback(async (snapshot: DraftLaunchSnapshot): Promise<{ laneId: string; laneName: string }> => { + if (draftLaunchTargetIsAutoCreate) { + if (!laneId) throw new Error("Select a lane before auto-creating a new lane."); + const primaryLane = availableLanes?.find((candidate) => candidate.laneType === "primary") + ?? availableLanes?.find((candidate) => candidate.name.trim().toLowerCase() === "primary") + ?? null; + if (!primaryLane) throw new Error("Auto-create requires a primary lane."); + const fallbackName = createTemporaryAutoLaneName(); + const laneName = await window.ade.agentChat.suggestLaneName({ + laneId: primaryLane.id, + prompt: buildDraftLaunchNamingSeed(snapshot), + modelId, + fallbackName, + }); + const createdLane = await window.ade.lanes.create({ name: laneName, parentLaneId: primaryLane.id }); + await refreshLanesStore(); + return { laneId: createdLane.id, laneName: createdLane.name }; + } + if (!laneId) throw new Error("Select a lane before launching chat."); + const laneName = availableLanes?.find((lane) => lane.id === laneId)?.name ?? laneDisplayLabel ?? laneId; + return { laneId, laneName }; + }, [availableLanes, draftLaunchTargetIsAutoCreate, laneDisplayLabel, laneId, modelId, refreshLanesStore]); + + const launchDraftChat = useCallback(async (mode: "foreground" | "background") => { + if (submitInFlightRef.current || busy || backgroundLaunchBusy || parallelLaunchBusy) return; + if (selectedSessionId || workDraftKind !== "chat" || !modelId) return; + const snapshot = buildDraftLaunchSnapshotForCurrentState(); + if (!snapshot) return; + + submitInFlightRef.current = true; + if (mode === "background") setBackgroundLaunchBusy(true); + else setBusy(true); + setPromptSuggestion(null); + setError(null); + setBackgroundLaunchNotice(null); + setDraft(""); + setAttachments([]); + setContextAttachments([]); + setIosElementContextItems([]); + setAppControlContextItems([]); + setBuiltInBrowserContextItems([]); + setMacosVmContextItems([]); + draftSelectionLockedRef.current = mode === "background"; + + try { + const targetLane = await resolveDraftLaunchLane(snapshot); + const prepared = await prepareDraftLaunchForSend(snapshot, targetLane.laneId); + const created = await createSessionForLane(targetLane.laneId, { + select: false, + notify: mode === "foreground", + }); + touchSession(created.id); + await window.ade.agentChat.send({ + sessionId: created.id, + text: prepared.finalText, + displayText: prepared.finalDisplayText || "Selected visual app context", + attachments: prepared.selectedAttachments, + contextAttachments: prepared.selectedContextAttachments, + reasoningEffort, + executionMode, + interactionMode: created.provider === "claude" ? interactionMode : null, + ...(created.provider === "cursor" ? { runtime: "local" as const } : {}), + }); + invalidateSessionListCache(); + if (targetLane.laneId === laneId) { + void refreshSessions().catch(() => {}); + } + const launch = { + laneId: targetLane.laneId, + laneName: targetLane.laneName, + sessionId: created.id, + }; + if (mode === "foreground") { + openLaunchedDraftChat(launch); + } else { + setSelectedSessionId(null); + setBackgroundLaunchNotice(launch); + } + } catch (launchError) { + restoreDraftLaunchSnapshot(snapshot); + const message = launchError instanceof Error ? launchError.message : String(launchError); + setError(message); + } finally { + submitInFlightRef.current = false; + setBusy(false); + setBackgroundLaunchBusy(false); + } + }, [ + backgroundLaunchBusy, + buildDraftLaunchSnapshotForCurrentState, + busy, + createSessionForLane, + executionMode, + interactionMode, + laneId, + modelId, + openLaunchedDraftChat, + parallelLaunchBusy, + prepareDraftLaunchForSend, + reasoningEffort, + refreshSessions, + resolveDraftLaunchLane, + restoreDraftLaunchSnapshot, + selectedSessionId, + touchSession, + workDraftKind, + ]); const handoffSession = useCallback(async (mode: "brief" | "fork" = "brief") => { if (!canShowHandoff || !selectedSessionId || !handoffModelId || handoffBlocked) return; @@ -4326,26 +4385,8 @@ export function AgentChatPane({ selectedSessionId, ]); - const handleEndSelectedChat = useCallback(() => { - if (!selectedSessionId || !selectedSession || selectedSession.status === "ended") return; - setError(null); - setClosingChatSessionId(selectedSessionId); - void window.ade.agentChat.dispose({ sessionId: selectedSessionId }) - .then(async () => { - invalidateSessionListCache(); - await refreshSessions().catch(() => {}); - }) - .catch((err: unknown) => { - const message = err instanceof Error ? err.message : String(err); - setError(`End chat failed: ${message}`); - }) - .finally(() => { - setClosingChatSessionId((current) => (current === selectedSessionId ? null : current)); - }); - }, [refreshSessions, selectedSession, selectedSessionId]); - const handleDeleteSelectedChat = useCallback(() => { - if (!selectedSessionId || !selectedSession || selectedSession.status !== "ended") return; + if (!selectedSessionId || !selectedSession) return; const label = chatSessionTitle(selectedSession).trim() || "this chat"; const confirmed = window.confirm( `Delete "${label}"?\n\nThis permanently removes the saved chat history from ADE.`, @@ -4650,6 +4691,11 @@ export function AgentChatPane({ return; } + if (draftLaunchTargetIsAutoCreate && selectedSessionId == null && workDraftKind === "chat") { + await launchDraftChat("foreground"); + return; + } + if (!modelId) return; const text = draft.trim(); const iosContextSnapshot = [...iosElementContextItems]; @@ -4667,7 +4713,10 @@ export function AgentChatPane({ const macosVmContextDisplayChips = formatMacosVmContextChipsForDisplay(macosVmContextSnapshot); const visualContextPrefix = [iosContextPrefix, appControlContextPrefix, builtInBrowserContextPrefix, macosVmContextPrefix].filter(Boolean).join("\n"); const visualContextDisplayChips = [iosContextDisplayChips, appControlContextDisplayChips, builtInBrowserContextDisplayChips, macosVmContextDisplayChips].filter(Boolean).join(" "); - if ((!text.length && !visualContextPrefix.length && !contextAttachmentsSnapshot.length) || !laneId) return; + if ( + (!text.length && !visualContextPrefix.length && !contextAttachmentsSnapshot.length && !(isWorkCliLaunchDraft && attachments.length)) + || !laneId + ) return; const pendingNativeControlUpdate = pendingNativeControlUpdateRef.current; if (selectedSessionId && pendingNativeControlUpdate?.sessionId === selectedSessionId) { try { @@ -4684,11 +4733,11 @@ export function AgentChatPane({ return; } } - if ( - text === "/context" - && selectedSessionId - && sessionProvider === "claude" - && !attachments.length + if ( + text === "/context" + && selectedSessionId + && sessionProvider === "claude" + && !attachments.length && !contextAttachmentsSnapshot.length && !visualContextPrefix.length ) { @@ -4704,38 +4753,38 @@ export function AgentChatPane({ setError(contextError instanceof Error ? contextError.message : String(contextError)); } finally { setBusy(false); - } - return; - } - if ( - /^\/output-style(?:\s+[\s\S]+)?$/i.test(text) - && selectedSessionId - && sessionProvider === "claude" - && !attachments.length - && !contextAttachmentsSnapshot.length - && !visualContextPrefix.length - ) { - setBusy(true); - setError(null); - setDraft(""); - draftsPerSessionRef.current.delete(selectedSessionId); - try { - touchSession(selectedSessionId); - await window.ade.agentChat.send({ - sessionId: selectedSessionId, - text, - displayText: text, - }); - void refreshSessions().catch(() => {}); - } catch (outputStyleError) { - setDraft((current) => (current.trim().length ? current : draft)); - setError(outputStyleError instanceof Error ? outputStyleError.message : String(outputStyleError)); - } finally { - setBusy(false); - } - return; - } - const draftSnapshot = draft; + } + return; + } + if ( + /^\/output-style(?:\s+[\s\S]+)?$/i.test(text) + && selectedSessionId + && sessionProvider === "claude" + && !attachments.length + && !contextAttachmentsSnapshot.length + && !visualContextPrefix.length + ) { + setBusy(true); + setError(null); + setDraft(""); + draftsPerSessionRef.current.delete(selectedSessionId); + try { + touchSession(selectedSessionId); + await window.ade.agentChat.send({ + sessionId: selectedSessionId, + text, + displayText: text, + }); + void refreshSessions().catch(() => {}); + } catch (outputStyleError) { + setDraft((current) => (current.trim().length ? current : draft)); + setError(outputStyleError instanceof Error ? outputStyleError.message : String(outputStyleError)); + } finally { + setBusy(false); + } + return; + } + const draftSnapshot = draft; const attachmentsSnapshot = attachments; const isLiteralSlashCommand = isProviderSlashCommandInput(text); @@ -4746,6 +4795,38 @@ export function AgentChatPane({ draftsPerSessionRef.current.delete(selectedSessionId); setAttachments([]); setContextAttachments([]); + + // Show the optimistic bubble immediately when we already have a session. + // Awaiting the macOS VM IPC and any session-create roundtrip before this + // setter delays the bubble by hundreds of ms on a typical send. + const selectedAttachmentsForOptimistic = isLiteralSlashCommand ? [] : attachmentsSnapshot; + const selectedContextAttachmentsForOptimistic = isLiteralSlashCommand ? [] : contextAttachmentsSnapshot; + const optimisticDisplayText = visualContextDisplayChips + ? text.length + ? `${visualContextDisplayChips} ${text}` + : visualContextDisplayChips + : text.length + ? text + : contextAttachmentsSnapshot.length + ? "Attached issue context" + : text; + if (selectedSessionId && !turnActiveBySession[selectedSessionId]) { + setOptimisticOutgoingMessageSynced({ + sessionId: selectedSessionId, + envelope: { + sessionId: selectedSessionId, + timestamp: new Date().toISOString(), + event: { + type: "user_message", + text: optimisticDisplayText, + ...(selectedAttachmentsForOptimistic.length ? { attachments: selectedAttachmentsForOptimistic } : {}), + ...(selectedContextAttachmentsForOptimistic.length ? { contextAttachments: selectedContextAttachmentsForOptimistic } : {}), + deliveryState: "queued", + }, + }, + }); + } + try { const automaticMacosVmContextPrefix = await buildAutomaticMacosVmContextForPrompt(laneId); let justCreatedSession = false; @@ -4786,13 +4867,51 @@ export function AgentChatPane({ }, }); + if (isWorkCliLaunchDraft && onLaunchCliSession) { + const desc = getModelById(modelId); + if (!desc) throw new Error("Select a model before launching a CLI session."); + const provider = resolveCliProviderForModel(desc) ?? "opencode"; + const runtimeModel = getRuntimeModelRefForDescriptor(desc, provider); + const permissionMode = cliPermissionModeFromNativeControls(provider, currentNativeControls); + const cliPrompt = buildWorkCliInitialPrompt({ + text: finalText, + attachments: selectedAttachments, + contextAttachments: selectedContextAttachments, + }); + if (!cliPrompt.trim().length) throw new Error("Enter a prompt or attach context before launching a CLI session."); + const sessionId = provider === "claude" ? createClaudeSessionIdForCliLaunch() : undefined; + const launch = buildTrackedCliLaunchCommand({ + provider, + permissionMode, + ...(sessionId ? { sessionId } : {}), + model: runtimeModel, + reasoningEffort, + initialPrompt: cliPrompt, + }); + await onLaunchCliSession({ + laneId, + profile: provider, + title: workCliTitleFromPrompt(text || finalDisplayText || finalText, LAUNCH_PROFILE_TITLE[provider]), + startupCommand: launch.startupCommand, + ...(launch.command !== undefined ? { command: launch.command } : {}), + args: launch.args, + ...(launch.env ? { env: launch.env } : {}), + tracked: true, + }); + setIosElementContextItems([]); + setAppControlContextItems([]); + setBuiltInBrowserContextItems([]); + setMacosVmContextItems([]); + return; + } + if (sessionId && !turnActive && ( selectedModelChanged || selectedCodexFastModeChanged || hasComputerUseSelectionChanged || shouldPromoteLightSession )) { - setOptimisticOutgoingMessage({ sessionId, envelope: optimisticEnvelope(sessionId) }); + setOptimisticOutgoingMessageSynced({ sessionId, envelope: optimisticEnvelope(sessionId) }); const desc = getModelById(modelId); const provider = resolveChatRuntimeProvider(desc); await window.ade.agentChat.updateSession({ @@ -4810,7 +4929,7 @@ export function AgentChatPane({ throw new Error("Unable to create chat session."); } justCreatedSession = true; - setOptimisticOutgoingMessage({ sessionId, envelope: optimisticEnvelope(sessionId) }); + setOptimisticOutgoingMessageSynced({ sessionId, envelope: optimisticEnvelope(sessionId) }); } if (!sessionId) { throw new Error("Unable to create chat session."); @@ -4825,7 +4944,7 @@ export function AgentChatPane({ }); if (turnActiveBySession[sessionId]) { - setOptimisticOutgoingMessage(null); + setOptimisticOutgoingMessageSynced(null); await window.ade.agentChat.steer({ sessionId, text: finalText, @@ -4834,7 +4953,7 @@ export function AgentChatPane({ }); } else { try { - setOptimisticOutgoingMessage({ sessionId, envelope: optimisticEnvelope(sessionId) }); + setOptimisticOutgoingMessageSynced({ sessionId, envelope: optimisticEnvelope(sessionId) }); await window.ade.agentChat.send({ sessionId, text: finalText, @@ -4882,7 +5001,7 @@ export function AgentChatPane({ setAppControlContextItems((current) => (current.length ? current : appControlContextSnapshot)); setBuiltInBrowserContextItems((current) => (current.length ? current : builtInBrowserContextSnapshot)); setMacosVmContextItems((current) => (current.length ? current : macosVmContextSnapshot)); - setOptimisticOutgoingMessage(null); + setOptimisticOutgoingMessageSynced(null); setError(message); if ( /ade chat could not authenticate/i.test(message) @@ -4901,12 +5020,15 @@ export function AgentChatPane({ busy, codexFastMode, createSession, + currentNativeControls, contextAttachments, draft, + draftLaunchTargetIsAutoCreate, executionMode, hasComputerUseSelectionChanged, interactionMode, laneId, + launchDraftChat, launchModeEditable, modelId, patchSessionSummary, @@ -4931,6 +5053,7 @@ export function AgentChatPane({ embeddedWorkLayout, projectRoot, navigate, + onLaunchCliSession, buildNativeControlPayloadForSlot, refreshLanesStore, persistParallelLaunchState, @@ -4940,6 +5063,7 @@ export function AgentChatPane({ appControlContextItems, builtInBrowserContextItems, macosVmContextItems, + workDraftKind, ]); const openRewindConfirmDialog = useCallback((state: RewindFilesConfirmDialogState): Promise => { @@ -5246,6 +5370,37 @@ export function AgentChatPane({ // Computer-use policy gating has been removed; this handler is a no-op retained for UI compat. }, []); + const embedDraft = embeddedWorkLayout && forceDraft; + const compactShell = embedDraft || layoutVariant === "grid-tile"; + const isEmptyState = !selectedSessionId; + const showDraftLaunchControls = + showWorkspaceChrome + && selectedSessionId == null + && !lockSessionId + && !initialSessionId + && workDraftKind === "chat"; + const draftLaneSelectorLanes = useMemo( + () => showDraftLaunchControls && availableLanes + ? [AUTO_CREATE_LANE_OPTION, ...availableLanes] + : (availableLanes ?? []), + [availableLanes, showDraftLaunchControls], + ); + const draftLaneSelectorValue = draftLaunchTargetIsAutoCreate ? AUTO_CREATE_LANE_OPTION_ID : (laneId ?? ""); + const handleDraftLaneSelectionChange = useCallback((nextLaneId: string) => { + if (nextLaneId === AUTO_CREATE_LANE_OPTION_ID) { + setDraftLaunchTargetId(AUTO_CREATE_LANE_OPTION_ID); + return; + } + setDraftLaunchTargetId(null); + onLaneChange?.(nextLaneId); + }, [onLaneChange]); + + useEffect(() => { + if (!showDraftLaunchControls && draftLaunchTargetId) { + setDraftLaunchTargetId(null); + } + }, [draftLaunchTargetId, showDraftLaunchControls]); + if (!laneId) { return ( ) : null} - {!lockedSingleSessionMode && selectedSessionId && selectedSession?.status !== "ended" ? ( - - ) : null} - {!lockedSingleSessionMode && selectedSessionId && selectedSession?.status === "ended" ? ( + {!lockedSingleSessionMode && selectedSessionId ? ( + + ) : null} {composerElement} ); @@ -6513,7 +6674,7 @@ export function AgentChatPane({ {selectedTodoItems.length ? ( ) : null} - {selectedSession?.provider === "claude" && selectedSubagentSnapshots.length && !effectiveSubagentPaneOpen ? ( + {selectedSubagentSnapshots.length && !effectiveSubagentPaneOpen ? ( {/* Lane selector pill */} - {showWorkspaceChrome && availableLanes && availableLanes.length > 0 && onLaneChange ? ( + {showWorkspaceChrome && draftLaneSelectorLanes.length > 0 && onLaneChange ? ( - +
+ + {onOpenShellSession ? ( + + + + ) : null} +
) : showWorkspaceChrome && laneDisplayLabel ? ( { }); fireEvent.click(screen.getByText("Inspect")); - expect(screen.getByText("Inspect mode attaches clicks to chat")).toBeTruthy(); + expect(screen.getByText("Inspect mode inserts clicked element context")).toBeTruthy(); fireEvent.click(screen.getByText("Control")); expect(screen.getByText("Click the screenshot to drive the app, or type into the focused element below.")).toBeTruthy(); @@ -284,7 +284,7 @@ describe("ChatAppControlPanel", () => { id: "context-1", sourceFile: "src/App.tsx", })); - expect(await screen.findByText("Attached cdp to chat")).toBeTruthy(); + expect(await screen.findByText("Inserted cdp context")).toBeTruthy(); const selectCallsBeforeReattach = api.appControl.selectPoint.mock.calls.length; fireEvent.click(screen.getByText("Re-attach")); diff --git a/apps/desktop/src/renderer/components/chat/ChatAppControlPanel.tsx b/apps/desktop/src/renderer/components/chat/ChatAppControlPanel.tsx index 48565198c..cd1cbbc93 100644 --- a/apps/desktop/src/renderer/components/chat/ChatAppControlPanel.tsx +++ b/apps/desktop/src/renderer/components/chat/ChatAppControlPanel.tsx @@ -692,7 +692,7 @@ export function ChatAppControlPanel({ throw new Error("The renderer is attached but the screenshot is blank. Open the app window or menu bar item, then refresh the snapshot before attaching context."); } if (!onAddContext) { - throw new Error("Chat attachments are not available in this panel."); + throw new Error("Context insertion is not available in this panel."); } const result = await window.ade.appControl.selectPoint({ projectRoot, @@ -733,7 +733,7 @@ export function ChatAppControlPanel({ throw new Error(`Could not attach screenshot crop: ${errorMessage(error)}`); } } else if (screenshotDataUrl && !onAddAttachment) { - throw new Error("Chat attachments are not available in this panel."); + throw new Error("Context insertion is not available in this panel."); } const contextItem = { ...result.item, @@ -746,7 +746,7 @@ export function ChatAppControlPanel({ try { onAddContext(contextItem); } catch (error) { - throw new Error(`Could not attach App Control context to chat: ${errorMessage(error)}`); + throw new Error(`Could not insert App Control context: ${errorMessage(error)}`); } setSelectedContextItem(contextItem); setSelectedPoint({ x, y }); @@ -762,7 +762,7 @@ export function ChatAppControlPanel({ setAttachmentAck(attachedLabel); setMessage({ tone: "info", - text: `Attached ${attachedLabel} to chat.`, + text: `Inserted ${attachedLabel} context.`, }); }, [onAddAttachment, onAddContext, projectRoot, screenshotBlank, snapshot], @@ -1030,7 +1030,7 @@ export function ChatAppControlPanel({ type="button" onClick={requestDebugHelp} className="ml-auto inline-flex h-7 shrink-0 items-center gap-1 rounded-md border border-white/[0.07] bg-white/[0.03] px-2 text-[10px] font-medium text-muted-fg/65 transition-colors hover:text-fg/85" - title="Insert a chat draft asking the agent to wire CDP debug flags into this app" + title="Insert a draft asking the agent to wire CDP debug flags into this app" > Help wire CDP @@ -1395,7 +1395,7 @@ export function ChatAppControlPanel({ ) : (
{sessionConnected - ? "Click an element to attach its source context to chat." + ? "Click an element to insert its source context." : "Launch or connect to inspect elements."}
)} @@ -1435,7 +1435,7 @@ export function ChatAppControlPanel({ : "border-white/[0.07] bg-white/[0.03] text-muted-fg/60", )} > - {attachmentAck ? `Attached ${attachmentAck} to chat` : "Inspect mode attaches clicks to chat"} + {attachmentAck ? `Inserted ${attachmentAck} context` : "Inspect mode inserts clicked element context"} @@ -3726,7 +3726,7 @@ export function ChatIosSimulatorPanel({ }} onPointerDown={(event) => event.stopPropagation()} disabled={!snapshot?.screenshot.dataUrl || ownedByOtherChat} - title="Drag a screenshot region to attach it to chat with simulator context" + title="Drag a screenshot region to insert it with simulator context" > Screenshot diff --git a/apps/desktop/src/renderer/components/chat/ChatSubagentsPanel.test.tsx b/apps/desktop/src/renderer/components/chat/ChatSubagentsPanel.test.tsx index abaf9da07..7c5f1623e 100644 --- a/apps/desktop/src/renderer/components/chat/ChatSubagentsPanel.test.tsx +++ b/apps/desktop/src/renderer/components/chat/ChatSubagentsPanel.test.tsx @@ -73,7 +73,9 @@ describe("ChatSubagentsPanel", () => { fireEvent.click(trigger); expect(trigger.getAttribute("aria-expanded")).toBe("true"); - fireEvent.click(screen.getByRole("button", { name: /Background/i })); + // With only background snapshots, the single non-empty tab auto-resolves + // to "background" — the tab strip collapses into a section header so the + // background list renders directly without clicking a tab. expect(screen.getByTitle("Audit chat renderer")).toBeTruthy(); fireEvent.click(screen.getByTitle("Audit chat renderer")); @@ -98,7 +100,6 @@ describe("ChatSubagentsPanel", () => { ); fireEvent.click(screen.getByRole("button", { name: /Subagents/i })); - fireEvent.click(screen.getByRole("button", { name: /Background/i })); fireEvent.click(screen.getByTitle("Audit chat renderer")); expect(screen.queryByText("Progress 0")).toBeNull(); @@ -108,4 +109,24 @@ describe("ChatSubagentsPanel", () => { fireEvent.click(screen.getByRole("button", { name: /Copy id/i })); expect(writeText).toHaveBeenCalledWith("task-1"); }); + + it("renders the tab strip when more than one category is non-empty", () => { + const foregroundSnapshot: ChatSubagentSnapshot = { + ...snapshot, + taskId: "task-2", + description: "Inspect codex flow", + background: false, + }; + render( + , + ); + + fireEvent.click(screen.getByRole("button", { name: /Subagents/i })); + // With both background and non-background snapshots present, both tabs + // should be clickable. + expect(screen.getAllByRole("button", { name: /Background/i }).length).toBeGreaterThan(0); + }); }); diff --git a/apps/desktop/src/renderer/components/chat/ChatSubagentsPanel.tsx b/apps/desktop/src/renderer/components/chat/ChatSubagentsPanel.tsx index 324ac16b0..e2a540d6f 100644 --- a/apps/desktop/src/renderer/components/chat/ChatSubagentsPanel.tsx +++ b/apps/desktop/src/renderer/components/chat/ChatSubagentsPanel.tsx @@ -338,7 +338,14 @@ export function ChatSubagentsPanel({ if (!snapshots.length) return null; - const activeTab = selectedTab; + // When only one category has content (e.g. Codex never emits Teammates and + // often only emits non-background subagents), drop the tab strip so the + // panel reads as a single labeled section instead of one lit-up tab next + // to two empty ones. + const nonEmptyTabs = (["subagents", "teammates", "background"] as SubagentPanelTab[]) + .filter((tab) => tabCounts[tab] > 0); + const singleNonEmptyTab = nonEmptyTabs.length === 1 ? nonEmptyTabs[0] : null; + const activeTab = singleNonEmptyTab ?? selectedTab; const visibleSnapshots = snapshots.filter((snapshot) => { if (activeTab === "subagents") return !snapshot.background; if (activeTab === "teammates") return false; @@ -347,7 +354,14 @@ export function ChatSubagentsPanel({ }); const tabs: SubagentPanelTab[] = ["subagents", "teammates", "background"]; - const tabControls = ( + const tabControls = singleNonEmptyTab ? ( +
+ {tabLabel(singleNonEmptyTab)} + + {tabCounts[singleNonEmptyTab]} + +
+ ) : (
{tabs.map((tab) => (
) : null} + {screenshotLaneMismatchMessage ? ( +
+ + {screenshotLaneMismatchMessage} +
+ ) : null} +
@@ -495,9 +522,9 @@ export function MacosVmPanel({ alt="macOS VM screenshot" className={cn( "block max-h-[220px] w-full object-contain", - running ? "cursor-crosshair" : "cursor-default", + running && !screenshotLaneMismatch ? "cursor-crosshair" : "cursor-default", )} - onClick={running ? selectPreviewPoint : undefined} + onClick={running && !screenshotLaneMismatch ? selectPreviewPoint : undefined} onLoad={(event) => { setPreviewSize({ width: event.currentTarget.naturalWidth, @@ -529,7 +556,7 @@ export function MacosVmPanel({ className="ade-shell-control inline-flex items-center gap-2 rounded-md px-3 py-2 text-[12px]" data-variant="ghost" onClick={clickSelectedPoint} - disabled={!canOperate || !running || !selectedPoint || busyAction === "click"} + disabled={!canOperate || !running || !selectedPoint || screenshotLaneMismatch || busyAction === "click"} > {busyAction === "click" ? Spinner : } Click point diff --git a/apps/desktop/src/renderer/components/terminals/SessionCard.tsx b/apps/desktop/src/renderer/components/terminals/SessionCard.tsx index 246e7041f..b3f54f2ba 100644 --- a/apps/desktop/src/renderer/components/terminals/SessionCard.tsx +++ b/apps/desktop/src/renderer/components/terminals/SessionCard.tsx @@ -1,5 +1,5 @@ import React from "react"; -import { GitBranch, Info, Play, WarningCircle } from "@phosphor-icons/react"; +import { GitBranch, Info, WarningCircle } from "@phosphor-icons/react"; import type { LaneSummary, TerminalSessionSummary } from "../../../shared/types"; import { sessionStatusDot, sanitizeTerminalInlineText } from "../../lib/terminalAttention"; import { @@ -15,7 +15,6 @@ import { MONO_FONT } from "../lanes/laneDesignTokens"; import { ToolLogo } from "./ToolLogos"; import { iconGlyph } from "../graph/graphHelpers"; import { SmartTooltip } from "../ui/SmartTooltip"; -import { resolveTrackedCliResumeCommand } from "./cliLaunch"; import { ClaudeCacheTtlBadge } from "../shared/ClaudeCacheTtlBadge"; import { shouldShowClaudeCacheTtl } from "../../lib/claudeCacheTtl"; @@ -43,10 +42,8 @@ export const SessionCard = React.memo(function SessionCard({ isSelected, isMultiSelected, onSelect, - onResume, onInfoClick, onContextMenu, - resumingSessionId, compact = false, }: { session: TerminalSessionSummary; @@ -54,14 +51,11 @@ export const SessionCard = React.memo(function SessionCard({ isSelected: boolean; isMultiSelected?: boolean; onSelect: (id: string, event: React.MouseEvent) => void; - onResume: () => void; onInfoClick: (e: React.MouseEvent) => void; onContextMenu: (e: React.MouseEvent) => void; - resumingSessionId: string | null; compact?: boolean; }) { const dot = sessionStatusDot(session); - const canResume = session.status !== "running" && Boolean(resolveTrackedCliResumeCommand(session)); const delta = useSessionDelta(session.id, true); const primaryText = primarySessionLabel(session); const previewLine = getPreviewLine(session, primaryText); @@ -79,6 +73,7 @@ export const SessionCard = React.memo(function SessionCard({ const laneRing = useLaneGlow ? `color-mix(in srgb, ${laneAccent} 32%, transparent)` : null; + const stoppedBySignal = session.exitCode === 130 || session.exitCode === 143; const showClaudeCacheTimer = shouldShowClaudeCacheTtl({ provider: session.toolType === "claude-chat" ? "claude" : null, status: session.runtimeState === "idle" ? "idle" : "active", @@ -144,7 +139,7 @@ export const SessionCard = React.memo(function SessionCard({ @@ -209,10 +204,15 @@ export const SessionCard = React.memo(function SessionCard({ {session.exitCode != null && session.exitCode !== 0 ? ( - EXIT {session.exitCode} + {stoppedBySignal ? "STOPPED" : `EXIT ${session.exitCode}`} ) : null}
@@ -222,7 +222,7 @@ export const SessionCard = React.memo(function SessionCard({ {/* Hover actions — bottom-right so they don’t compete with title row */}
- + - - {canResume ? ( - - - - ) : null}
); diff --git a/apps/desktop/src/renderer/components/terminals/SessionContextMenu.tsx b/apps/desktop/src/renderer/components/terminals/SessionContextMenu.tsx index a6ed76c1d..cf61d80f9 100644 --- a/apps/desktop/src/renderer/components/terminals/SessionContextMenu.tsx +++ b/apps/desktop/src/renderer/components/terminals/SessionContextMenu.tsx @@ -1,7 +1,6 @@ import { useState, useRef, useEffect, useLayoutEffect } from "react"; import type { TerminalSessionSummary } from "../../../shared/types"; import { isChatToolType } from "../../lib/sessions"; -import { resolveTrackedCliResumeCommand } from "./cliLaunch"; export type SessionContextMenuState = { session: TerminalSessionSummary; @@ -12,13 +11,10 @@ export type SessionContextMenuState = { type SessionContextMenuProps = { menu: SessionContextMenuState; onClose: () => void; - onCloseSession: (args: { ptyId: string; sessionId: string }) => void; - onEndChat: (sessionId: string) => void; + onStopRuntime: (args: { ptyId: string; sessionId: string }) => void; onDeleteChat: (session: TerminalSessionSummary) => void; onDeleteSession: (session: TerminalSessionSummary) => void; deletingSessionId: string | null; - onResume: (session: TerminalSessionSummary) => void; - onCopyResumeCommand: (command: string) => void; onGoToLane: (session: TerminalSessionSummary) => void; onCopySessionId: (id: string) => void; onRename: (session: TerminalSessionSummary, newTitle: string) => void; @@ -30,13 +26,10 @@ type SessionContextMenuProps = { export function SessionContextMenu({ menu, onClose, - onCloseSession, - onEndChat, + onStopRuntime, onDeleteChat, onDeleteSession, deletingSessionId, - onResume, - onCopyResumeCommand, onGoToLane, onCopySessionId, onRename, @@ -103,8 +96,6 @@ export function SessionContextMenu({ : { left: x, top: y }; const isRunning = session.status === "running"; const isChat = isChatToolType(session.toolType); - const resumeCommand = resolveTrackedCliResumeCommand(session); - const canResume = !isRunning && Boolean(resumeCommand); const commitRename = () => { if (finalizedRef.current) return; @@ -159,22 +150,13 @@ export function SessionContextMenu({ {isRunning && session.ptyId && !isChat ? ( ) : null} - {isRunning && isChat ? ( - - ) : null} - - {!isRunning && isChat ? ( + {isChat ? ( - ) : null} - - {resumeCommand ? ( - - ) : null} -
- - - - -
-
- ), - }) - : null} - {session.status !== "running" ? : null} {health && !isChat @@ -333,34 +275,21 @@ export function SessionInfoPopover({
{session.status === "running" && session.ptyId && !isChat ? ( - + - - ) : null} - {session.status === "running" && isChat ? ( - - ) : null} - {session.status !== "running" && isChat ? ( + {isChat ? ( - - ) : null} - {archivableCount > 0 ? ( - - - - ) : null} - {restorableCount > 0 ? ( - - - - ) : null} - {selectedCount > 0 ? ( - - ) : null} diff --git a/apps/desktop/src/renderer/components/terminals/TerminalsPage.tsx b/apps/desktop/src/renderer/components/terminals/TerminalsPage.tsx index 39f4aaff0..1b0fbd8d0 100644 --- a/apps/desktop/src/renderer/components/terminals/TerminalsPage.tsx +++ b/apps/desktop/src/renderer/components/terminals/TerminalsPage.tsx @@ -4,14 +4,13 @@ import { PaneTilingLayout, type PaneConfig, type PaneSplit } from "../ui/PaneTil import { useWorkSessions } from "./useWorkSessions"; import { SessionListPane } from "./SessionListPane"; import { WorkViewArea } from "./WorkViewArea"; -import { WorkSidebar } from "./WorkSidebar"; +import { WorkSidebar, type WorkSidebarContextTarget } from "./WorkSidebar"; import { SessionContextMenu, type SessionContextMenuState } from "./SessionContextMenu"; import { SessionInfoPopover, type InfoPopoverState } from "./SessionInfoPopover"; -import type { AgentChatSession, TerminalSessionSummary } from "../../../shared/types"; +import type { AgentChatPermissionMode, AgentChatSession, TerminalSessionSummary } from "../../../shared/types"; import { formatToolTypeLabel, isChatToolType } from "../../lib/sessions"; import { sortLanesForTabs } from "../lanes/laneUtils"; import { invalidateSessionListCache } from "../../lib/sessionListCache"; -import { formatSessionBundleMarkdown, triggerBrowserDownload } from "../../lib/transcriptExport"; import { useAppStore } from "../../state/appStore"; import { ADE_OPEN_BUILT_IN_BROWSER_EVENT } from "../../lib/openExternal"; import { @@ -44,6 +43,10 @@ function dispatchWorkSidebarBrowserResizeEvent(type: "start" | "end"): void { )); } +function isPtyContextInsertableToolType(toolType: TerminalSessionSummary["toolType"]): boolean { + return toolType === "claude"; +} + async function allSettledWithConcurrency( items: readonly T[], concurrency: number, @@ -261,21 +264,18 @@ export function TerminalsPage({ active = true }: { active?: boolean }) { ); const handleBulkCloseSelected = useCallback(() => { - const running = selectedSessions.filter((session) => session.status === "running"); + const running = selectedSessions.filter((session) => session.status === "running" && !isChatToolType(session.toolType)); if (!running.length) return; const confirmed = window.confirm( - `Close ${running.length} running session${running.length === 1 ? "" : "s"}?\n\nThis terminates the underlying CLI, shell, or chat process for each selected running session.`, + `Stop ${running.length} running runtime${running.length === 1 ? "" : "s"}?\n\nThis terminates the underlying CLI or shell process for each selected running session. Saved transcripts stay in ADE.`, ); if (!confirmed) return; setSessionActionError(null); void Promise.allSettled( running.map((session) => { - if (isChatToolType(session.toolType)) { - return work.closeChatSession(session.id); - } if (session.ptyId) { - return work.closeSession(session.ptyId, session.id); + return work.stopRuntime(session.ptyId, session.id); } return Promise.resolve(); }), @@ -283,7 +283,7 @@ export function TerminalsPage({ active = true }: { active?: boolean }) { .then((results) => { const failed = results.filter((result) => result.status === "rejected").length; if (failed > 0) { - setSessionActionError(`Close failed for ${failed} selected session${failed === 1 ? "" : "s"}.`); + setSessionActionError(`Stop failed for ${failed} selected runtime${failed === 1 ? "" : "s"}.`); window.setTimeout(() => setSessionActionError(null), 6000); } setSelectedSessionIds(new Set()); @@ -293,7 +293,7 @@ export function TerminalsPage({ active = true }: { active?: boolean }) { }) .catch((err: unknown) => { const message = err instanceof Error ? err.message : String(err); - setSessionActionError(`Close failed: ${message}`); + setSessionActionError(`Stop failed: ${message}`); window.setTimeout(() => setSessionActionError(null), 6000); }); }, [selectedSessions, work]); @@ -351,90 +351,34 @@ export function TerminalsPage({ active = true }: { active?: boolean }) { }); }, [selectedSessions, work]); - const selectedArchivableChats = useMemo( - () => selectedSessions.filter((session) => isChatToolType(session.toolType) && !session.archivedAt), - [selectedSessions], - ); - const selectedRestorableChats = useMemo( - () => selectedSessions.filter((session) => isChatToolType(session.toolType) && Boolean(session.archivedAt)), - [selectedSessions], - ); - - const handleBulkArchiveSelected = useCallback(() => { - const targets = selectedArchivableChats; - if (!targets.length) return; - const confirmed = window.confirm( - `Archive ${targets.length} chat${targets.length === 1 ? "" : "s"}?\n\nArchived chats are hidden from the default view but can be restored later. Terminal sessions in your selection are skipped.`, - ); - if (!confirmed) return; - setSessionActionError(null); - void Promise.allSettled( - targets.map((session) => window.ade.agentChat.archive({ sessionId: session.id })), - ) - .then(async (results) => { - const failed = results.filter((result) => result.status === "rejected").length; - setSelectedSessionIds(new Set()); - setSelectionAnchorId(null); - invalidateSessionListCache(); - await work.refresh({ showLoading: false, force: true }).catch((err: unknown) => { - console.error("[TerminalsPage] refresh after bulk archive failed", { err }); + const handleContinueCliSession = useCallback( + async (session: TerminalSessionSummary, text: string, options?: { model?: string | null; reasoningEffort?: string | null; permissionMode?: AgentChatPermissionMode | null }) => { + setSessionActionError(null); + try { + const result = await window.ade.pty.sendToSession({ + sessionId: session.id, + text, + cols: 100, + rows: 30, + ...(options?.model ? { model: options.model } : {}), + ...(options?.reasoningEffort ? { reasoningEffort: options.reasoningEffort } : {}), + ...(options?.permissionMode ? { permissionMode: options.permissionMode } : {}), }); - if (failed > 0) { - setSessionActionError(`Archive failed for ${failed} chat${failed === 1 ? "" : "s"}.`); - window.setTimeout(() => setSessionActionError(null), 6000); - } - }) - .catch((err: unknown) => { - const message = err instanceof Error ? err.message : String(err); - setSessionActionError(`Archive failed: ${message}`); - window.setTimeout(() => setSessionActionError(null), 6000); - }); - }, [selectedArchivableChats, work]); - - const handleBulkRestoreSelected = useCallback(() => { - const targets = selectedRestorableChats; - if (!targets.length) return; - setSessionActionError(null); - void Promise.allSettled( - targets.map((session) => window.ade.agentChat.unarchive({ sessionId: session.id })), - ) - .then(async (results) => { - const failed = results.filter((result) => result.status === "rejected").length; - setSelectedSessionIds(new Set()); - setSelectionAnchorId(null); invalidateSessionListCache(); - await work.refresh({ showLoading: false, force: true }).catch((err: unknown) => { - console.error("[TerminalsPage] refresh after bulk restore failed", { err }); - }); - if (failed > 0) { - setSessionActionError(`Restore failed for ${failed} chat${failed === 1 ? "" : "s"}.`); - window.setTimeout(() => setSessionActionError(null), 6000); + try { + await work.refresh({ showLoading: false, force: true }); + } catch { + // Best-effort after reattach; the PTY events will also refresh state. } - }) - .catch((err: unknown) => { + work.selectLane(session.laneId); + work.focusSession(result.sessionId); + work.setActiveItemId(result.sessionId); + } catch (err) { const message = err instanceof Error ? err.message : String(err); - setSessionActionError(`Restore failed: ${message}`); + setSessionActionError(`Send failed: ${message}`); window.setTimeout(() => setSessionActionError(null), 6000); - }); - }, [selectedRestorableChats, work]); - - const handleBulkExportSelected = useCallback(() => { - if (!selectedSessions.length) return; - setSessionActionError(null); - try { - const markdown = formatSessionBundleMarkdown(selectedSessions); - const stamp = new Date().toISOString().replace(/[:.]/g, "-"); - triggerBrowserDownload(`ade-sessions-${stamp}.md`, markdown); - } catch (err: unknown) { - const message = err instanceof Error ? err.message : String(err); - setSessionActionError(`Export failed: ${message}`); - window.setTimeout(() => setSessionActionError(null), 6000); - } - }, [selectedSessions]); - - const handleResumeSession = useCallback( - (session: TerminalSessionSummary) => { - void work.resumeSession(session).catch(() => {}); + throw err; + } }, [work], ); @@ -450,15 +394,39 @@ export function TerminalsPage({ active = true }: { active?: boolean }) { return sortedLanes.find((lane) => lane.laneType === "primary")?.id ?? sortedLanes[0]?.id ?? null; }, [activeWorkSession?.laneId, selectedLaneId, sortedLanes]); - const activeIsChat = Boolean(activeWorkSession && isChatToolType(activeWorkSession.toolType)); - const attachChatSessionId = activeIsChat ? activeWorkSession!.id : null; - let attachDisabledReason: string | null; + const contextTarget = useMemo(() => { + if (!activeWorkSession || activeWorkSession.laneId !== activeLaneId) return null; + if (isChatToolType(activeWorkSession.toolType)) { + return { kind: "chat", sessionId: activeWorkSession.id }; + } + if ( + activeWorkSession.status === "running" + && activeWorkSession.ptyId + && isPtyContextInsertableToolType(activeWorkSession.toolType) + ) { + return { + kind: "pty", + sessionId: activeWorkSession.id, + ptyId: activeWorkSession.ptyId, + toolType: activeWorkSession.toolType, + }; + } + return null; + }, [activeLaneId, activeWorkSession]); + + let contextDisabledReason: string | null; if (!activeWorkSession) { - attachDisabledReason = "Start an ADE chat before attaching tool context."; - } else if (!activeIsChat) { - attachDisabledReason = `Context attachment only works in ADE chat sessions. This ${formatToolTypeLabel(activeWorkSession.toolType)} session can still use the lane tools, but selections are not attached to chat.`; + contextDisabledReason = "Open a chat or agent CLI session in this lane to insert tool context."; + } else if (activeWorkSession.laneId !== activeLaneId) { + contextDisabledReason = "Open a Work session in the active lane to insert tool context."; + } else if (activeWorkSession.toolType === "shell" || activeWorkSession.toolType === "run-shell") { + contextDisabledReason = "Shell sessions can use the lane tools, but context insertion targets chats or agent CLI sessions."; + } else if (!contextTarget && activeWorkSession.ptyId && activeWorkSession.status !== "running") { + contextDisabledReason = `Continue this ${formatToolTypeLabel(activeWorkSession.toolType)} session before inserting tool context.`; + } else if (!contextTarget) { + contextDisabledReason = `This ${formatToolTypeLabel(activeWorkSession.toolType)} session can use the lane tools, but it cannot receive inserted context.`; } else { - attachDisabledReason = null; + contextDisabledReason = null; } const workSidebarVisible = active && work.workSidebarOpen && work.viewMode !== "grid"; @@ -488,6 +456,16 @@ export function TerminalsPage({ active = true }: { active?: boolean }) { const closeWorkSidebar = useCallback(() => { work.setWorkSidebarOpen(false); }, [work]); + const handleStopRunningSession = useCallback((session: TerminalSessionSummary) => { + if (!session.ptyId) return; + work.stopRuntime(session.ptyId, session.id).catch((err: unknown) => { + console.error("[TerminalsPage] Failed to stop CLI session from header", { + sessionId: session.id, + ptyId: session.ptyId, + err, + }); + }); + }, [work]); const handleWorkSidebarResizeMouseDown = useCallback((event: React.MouseEvent) => { event.preventDefault(); const container = event.currentTarget.parentElement; @@ -552,16 +530,18 @@ export function TerminalsPage({ active = true }: { active?: boolean }) { activeItemId={work.activeItemId} viewMode={work.viewMode} draftKind={work.draftKind} + draftLaneId={work.draftLaneId} setViewMode={work.setViewMode} onSelectItem={work.setActiveItemId} onCloseItem={work.closeTab} onOpenChatSession={handleOpenChatSession} onLaunchPtySession={work.launchPtySession} + onDraftLaneChange={work.setDraftLaneId} onShowDraftKind={work.showDraftKind} onToggleTabGroupCollapsed={work.toggleWorkTabGroupCollapsed} closingPtyIds={work.closingPtyIds} onContextMenu={handleContextMenu} - onResumeSession={handleResumeSession} + onContinueCliSession={handleContinueCliSession} sessionsPaneCollapsed={work.workFocusSessionsHidden} onExpandSessionsPane={expandSessionsPane} sessionsPaneListCount={work.filtered.length} @@ -569,6 +549,8 @@ export function TerminalsPage({ active = true }: { active?: boolean }) { sessionsListLoading={work.loading} workSidebarOpen={work.workSidebarOpen} onToggleWorkSidebar={toggleWorkSidebar} + onInfoClick={handleInfoClick} + onStopRunningSession={handleStopRunningSession} onReorderLaneSessions={work.reorderLaneSessions} onOpenSessionInTabsView={(sessionId) => { work.setViewMode("tabs"); @@ -589,6 +571,8 @@ export function TerminalsPage({ active = true }: { active?: boolean }) { work.activeItemId, work.viewMode, work.draftKind, + work.draftLaneId, + work.setDraftLaneId, work.showDraftKind, work.setViewMode, work.setActiveItemId, @@ -604,8 +588,10 @@ export function TerminalsPage({ active = true }: { active?: boolean }) { expandSessionsPane, toggleWorkSidebar, handleOpenChatSession, - handleResumeSession, + handleContinueCliSession, handleContextMenu, + handleInfoClick, + handleStopRunningSession, work.reorderLaneSessions, work.openSessionTab, work.selectLane, @@ -643,8 +629,8 @@ export function TerminalsPage({ active = true }: { active?: boolean }) { tab={work.workSidebarTab} onTabChange={work.setWorkSidebarTab} onClose={closeWorkSidebar} - attachChatSessionId={attachChatSessionId} - attachDisabledReason={attachDisabledReason} + contextTarget={contextTarget} + contextDisabledReason={contextDisabledReason} />
@@ -655,8 +641,8 @@ export function TerminalsPage({ active = true }: { active?: boolean }) { activeLaneId, activeWorkSession, active, - attachChatSessionId, - attachDisabledReason, + contextTarget, + contextDisabledReason, handleWorkSidebarResizeMouseDown, closeWorkSidebar, sortedLanes, @@ -731,13 +717,6 @@ export function TerminalsPage({ active = true }: { active?: boolean }) { }} onBulkClose={handleBulkCloseSelected} onBulkDelete={handleBulkDeleteSelected} - onBulkArchive={handleBulkArchiveSelected} - onBulkRestore={handleBulkRestoreSelected} - onBulkExport={handleBulkExportSelected} - archivableCount={selectedArchivableChats.length} - restorableCount={selectedRestorableChats.length} - onResume={(s) => work.resumeSession(s).catch(() => {})} - resumingSessionId={work.resumingSessionId} onInfoClick={handleInfoClick} onContextMenu={handleContextMenu} sessionListOrganization={work.sessionListOrganization} @@ -770,11 +749,6 @@ export function TerminalsPage({ active = true }: { active?: boolean }) { selectedSessionIds, handleBulkCloseSelected, handleBulkDeleteSelected, - handleBulkArchiveSelected, - handleBulkRestoreSelected, - handleBulkExportSelected, - selectedArchivableChats, - selectedRestorableChats, handleInfoClick, handleContextMenu, sessionsHeaderActions, @@ -809,13 +783,10 @@ export function TerminalsPage({ active = true }: { active?: boolean }) { setContextMenu(null)} - onCloseSession={({ ptyId, sessionId }) => work.closeSession(ptyId, sessionId).catch(() => {})} - onEndChat={(id) => work.closeChatSession(id).catch(() => {})} + onStopRuntime={({ ptyId, sessionId }) => work.stopRuntime(ptyId, sessionId).catch(() => {})} onDeleteChat={handleDeleteChat} onDeleteSession={handleDeleteSession} deletingSessionId={deletingSessionId} - onResume={handleResumeSession} - onCopyResumeCommand={(cmd) => navigator.clipboard.writeText(cmd).catch(() => {})} onGoToLane={handleGoToLane} onCopySessionId={(id) => navigator.clipboard.writeText(id).catch(() => {})} onCopySessionDeepLink={(session) => { @@ -848,16 +819,12 @@ export function TerminalsPage({ active = true }: { active?: boolean }) { setInfoPopover(null)} - onCloseSession={({ ptyId, sessionId }) => work.closeSession(ptyId, sessionId).catch(() => {})} - onEndChat={(id) => work.closeChatSession(id).catch(() => {})} + onStopRuntime={({ ptyId, sessionId }) => work.stopRuntime(ptyId, sessionId).catch(() => {})} onDeleteChat={handleDeleteChat} onDeleteSession={handleDeleteSession} - onResume={handleResumeSession} onGoToLane={handleGoToLane} closingPtyIds={work.closingPtyIds} - closingChatSessionId={work.closingChatSessionId} deletingSessionId={deletingSessionId} - resumingSessionId={work.resumingSessionId} /> ); diff --git a/apps/desktop/src/renderer/components/terminals/WorkCliSessionHeader.test.tsx b/apps/desktop/src/renderer/components/terminals/WorkCliSessionHeader.test.tsx new file mode 100644 index 000000000..566f449b4 --- /dev/null +++ b/apps/desktop/src/renderer/components/terminals/WorkCliSessionHeader.test.tsx @@ -0,0 +1,91 @@ +/* @vitest-environment jsdom */ + +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { TerminalSessionSummary } from "../../../shared/types"; +import { WorkCliSessionHeader } from "./WorkCliSessionHeader"; + +vi.mock("../chat/ChatGitToolbar", () => ({ + ChatGitToolbar: ({ laneId }: { laneId: string }) =>
, +})); + +vi.mock("./ToolLogos", () => ({ + ToolLogo: () => , +})); + +vi.mock("../ui/SmartTooltip", async () => { + const React = await import("react"); + return { SmartTooltip: ({ children }: { children: unknown }) => React.createElement(React.Fragment, null, children as never) }; +}); + +vi.mock("../../lib/terminalAttention", () => ({ + sessionStatusDot: vi.fn(() => ({ cls: "ade-status-dot", label: "Running", spinning: false })), + sessionStatusBucket: vi.fn(() => "running"), +})); + +function makeSession(overrides: Partial = {}): TerminalSessionSummary { + return { + id: "session-1", + laneId: "lane-1", + laneName: "Lane 1", + ptyId: "pty-1", + tracked: true, + pinned: false, + goal: null, + toolType: "claude", + title: "Claude Code: sidebar parity", + status: "running", + startedAt: "2026-05-13T00:00:00.000Z", + endedAt: null, + exitCode: null, + transcriptPath: "/tmp/transcript", + headShaStart: null, + headShaEnd: null, + lastOutputPreview: null, + summary: null, + runtimeState: "running", + resumeCommand: null, + ...overrides, + }; +} + +describe("WorkCliSessionHeader", () => { + afterEach(() => { + cleanup(); + vi.restoreAllMocks(); + }); + + it("keeps Work drawer tools out of the CLI header", () => { + render(); + + expect(screen.getByText("Claude Code: sidebar parity")).toBeTruthy(); + expect(screen.queryByText("Insert into Claude Code")).toBeNull(); + expect(screen.queryByLabelText("Open iOS Simulator in Work sidebar")).toBeNull(); + expect(screen.queryByLabelText("Open App Control in Work sidebar")).toBeNull(); + expect(screen.queryByLabelText("Open Browser in Work sidebar")).toBeNull(); + expect(screen.queryByLabelText("Open macOS VM in Work sidebar")).toBeNull(); + }); + + it("wires info, stop, and action affordances to existing session handlers", () => { + const onInfoClick = vi.fn(); + const onContextMenu = vi.fn(); + const onStopRunningSession = vi.fn(); + const session = makeSession(); + render( + , + ); + + fireEvent.click(screen.getByLabelText("Session info")); + fireEvent.click(screen.getByLabelText("Stop Claude Code")); + fireEvent.click(screen.getByLabelText("Session actions")); + + expect(onInfoClick).toHaveBeenCalledWith(session, expect.any(Object)); + expect(onStopRunningSession).toHaveBeenCalledWith(session); + expect(onContextMenu).toHaveBeenCalledWith(session, expect.any(Object)); + }); +}); diff --git a/apps/desktop/src/renderer/components/terminals/WorkCliSessionHeader.tsx b/apps/desktop/src/renderer/components/terminals/WorkCliSessionHeader.tsx new file mode 100644 index 000000000..729b907cb --- /dev/null +++ b/apps/desktop/src/renderer/components/terminals/WorkCliSessionHeader.tsx @@ -0,0 +1,185 @@ +import type { MouseEvent as ReactMouseEvent, ReactNode } from "react"; +import { + DotsThreeVertical, + Info, + StopCircle, +} from "@phosphor-icons/react"; +import type { TerminalSessionSummary } from "../../../shared/types"; +import { formatToolTypeLabel, primarySessionLabel, truncateSessionLabel } from "../../lib/sessions"; +import { sessionStatusBucket, sessionStatusDot } from "../../lib/terminalAttention"; +import { ChatGitToolbar } from "../chat/ChatGitToolbar"; +import { SmartTooltip } from "../ui/SmartTooltip"; +import { cn } from "../ui/cn"; +import { ToolLogo } from "./ToolLogos"; + +export type WorkCliSessionHeaderProps = { + session: TerminalSessionSummary; + compact?: boolean; + stopping?: boolean; + onInfoClick?: (session: TerminalSessionSummary, event: ReactMouseEvent) => void; + onContextMenu?: (session: TerminalSessionSummary, event: ReactMouseEvent) => void; + onStopRunningSession?: (session: TerminalSessionSummary) => void; +}; + +function insertionTargetLabel(toolType: TerminalSessionSummary["toolType"]): string { + switch (toolType) { + case "claude": + return "Claude Code"; + case "codex": + return "Codex CLI"; + case "cursor-cli": + return "Cursor CLI"; + case "opencode": + return "OpenCode CLI"; + case "droid": + return "Droid CLI"; + default: + return formatToolTypeLabel(toolType); + } +} + +function runtimeLabel(session: TerminalSessionSummary): string { + const bucket = sessionStatusBucket({ + status: session.status, + lastOutputPreview: session.lastOutputPreview, + runtimeState: session.runtimeState, + toolType: session.toolType, + }); + if (bucket === "awaiting-input") return "Awaiting input"; + if (session.runtimeState === "waiting-input") return "Awaiting input"; + if (session.runtimeState === "idle") return "Idle"; + if (session.runtimeState === "killed") return "Stopped"; + if (session.exitCode === 130 || session.exitCode === 143) return "Stopped"; + if (session.status === "running") return "Running"; + if (session.status === "failed") return "Failed"; + if (session.status === "completed") return "Completed"; + if (session.status === "disposed") return "Disposed"; + return formatToolTypeLabel(session.toolType); +} + +function HeaderIconButton({ + label, + description, + disabled = false, + children, + onClick, +}: { + label: string; + description: string; + disabled?: boolean; + children: ReactNode; + onClick?: (event: ReactMouseEvent) => void; +}) { + return ( + + + + ); +} + +export function WorkCliSessionHeader({ + session, + compact = false, + stopping = false, + onInfoClick, + onContextMenu, + onStopRunningSession, +}: WorkCliSessionHeaderProps) { + const dot = sessionStatusDot(session); + const title = truncateSessionLabel(primarySessionLabel(session), compact ? 24 : 44); + const toolLabel = formatToolTypeLabel(session.toolType); + const targetLabel = insertionTargetLabel(session.toolType); + const statusLabel = runtimeLabel(session); + const canStop = session.status === "running" && Boolean(session.ptyId) && Boolean(onStopRunningSession); + + const handleContextMenu = (event: ReactMouseEvent) => { + if (!onContextMenu) return; + event.preventDefault(); + event.stopPropagation(); + onContextMenu(session, event); + }; + + return ( +
+
+
+ +
+
+
+ + {title} + + +
+
+ {toolLabel} + · + {session.laneName} + · + {statusLabel} +
+
+
+ + {!compact ? ( +
+ +
+ ) : null} + +
+ onInfoClick?.(session, event)} + > + + + + {canStop ? ( + onStopRunningSession?.(session)} + > + + + ) : null} + + onContextMenu?.(session, event)} + > + + +
+
+ ); +} diff --git a/apps/desktop/src/renderer/components/terminals/WorkSidebar.test.tsx b/apps/desktop/src/renderer/components/terminals/WorkSidebar.test.tsx new file mode 100644 index 000000000..4d290f830 --- /dev/null +++ b/apps/desktop/src/renderer/components/terminals/WorkSidebar.test.tsx @@ -0,0 +1,473 @@ +/* @vitest-environment jsdom */ + +import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { MemoryRouter } from "react-router-dom"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { + AppControlContextItem, + AppControlSession, + IosElementContextItem, + IosSimulatorSession, + LaneSummary, + TerminalSessionSummary, +} from "../../../shared/types"; +import { ADE_WORK_PTY_CONTEXT_INSERTED_EVENT } from "../../lib/workPtyContextEvents"; +import { WorkSidebar, type WorkSidebarContextTarget } from "./WorkSidebar"; + +vi.mock("../chat/ChatIosSimulatorPanel", async () => { + const React = await import("react"); + return { + ChatIosSimulatorPanel: (props: { + sessionId: string | null; + onAddAttachment?: (attachment: { path: string; type: "image" }) => void; + onAddContext?: (item: IosElementContextItem) => void; + onInsertDraft?: (text: string) => void; + }) => React.createElement("div", { "data-testid": "ios-panel", "data-session-id": props.sessionId ?? "" }, [ + React.createElement("button", { + key: "context", + type: "button", + disabled: !props.onAddContext, + onClick: () => props.onAddContext?.(iosContextItem), + }, "Add iOS context"), + React.createElement("button", { + key: "attachment", + type: "button", + disabled: !props.onAddAttachment, + onClick: () => props.onAddAttachment?.({ path: ".ade/artifacts/ios.png", type: "image" }), + }, "Add iOS attachment"), + React.createElement("button", { + key: "draft", + type: "button", + disabled: !props.onInsertDraft, + onClick: () => props.onInsertDraft?.("inspect this iOS screen"), + }, "Insert iOS draft"), + ]), + }; +}); + +vi.mock("../chat/ChatAppControlPanel", async () => { + const React = await import("react"); + return { + ChatAppControlPanel: (props: { + sessionId: string | null; + onAddContext?: (item: AppControlContextItem) => void; + }) => React.createElement("div", { "data-testid": "app-control-panel", "data-session-id": props.sessionId ?? "" }, + React.createElement("button", { + type: "button", + disabled: !props.onAddContext, + onClick: () => props.onAddContext?.(appControlContextItem), + }, "Add App Control context"), + ), + }; +}); + +vi.mock("../chat/ChatBuiltInBrowserPanel", async () => { + const React = await import("react"); + return { + ChatBuiltInBrowserPanel: (props: { + sessionId: string | null; + onAddAttachment?: (attachment: { path: string; type: "image" }) => void; + onAddContext?: (item: unknown) => void; + }) => React.createElement("div", { "data-testid": "browser-panel", "data-session-id": props.sessionId ?? "" }, [ + React.createElement("button", { + key: "context", + type: "button", + disabled: !props.onAddContext, + onClick: () => props.onAddContext?.({ + kind: "built_in_browser_element", + id: "browser-context-1", + componentId: "button.submit", + label: "Submit", + selector: "button.submit", + frame: { x: 1, y: 2, width: 30, height: 12 }, + metadata: { label: "Submit", selector: "button.submit" }, + selectedAt: "2026-05-13T00:00:00.000Z", + }), + }, "Add Browser context"), + React.createElement("button", { + key: "attachment", + type: "button", + disabled: !props.onAddAttachment, + onClick: () => props.onAddAttachment?.({ path: ".ade/artifacts/browser.png", type: "image" }), + }, "Add Browser attachment"), + ]), + }; +}); + +vi.mock("./MacosVmPanel", async () => { + const React = await import("react"); + return { + MacosVmPanel: (props: { onAddContext?: (item: unknown) => void }) => React.createElement("button", { + type: "button", + disabled: !props.onAddContext, + onClick: () => props.onAddContext?.({ + kind: "macos_vm_target", + id: "vm-context-1", + provider: "lume", + state: "running", + laneId: "lane-1", + laneName: "Lane 1", + vmName: "ADE VM", + hostLanePath: "/repo", + guestLanePath: "/Volumes/ADE", + runCommand: "lume run ADE", + sshCommand: "ssh ade-vm", + vncUrl: null, + windowTitleQuery: null, + metadata: {}, + selectedAt: "2026-05-13T00:00:00.000Z", + }), + }, "Add VM context"), + }; +}); + +vi.mock("../files/FilesPage", async () => { + const React = await import("react"); + return { FilesPage: () => React.createElement("div", null, "Files") }; +}); + +vi.mock("../lanes/LaneDiffPane", async () => { + const React = await import("react"); + return { LaneDiffPane: () => React.createElement("div", null, "Diff") }; +}); + +vi.mock("../lanes/LaneGitActionsPane", async () => { + const React = await import("react"); + return { LaneGitActionsPane: () => React.createElement("div", null, "Git") }; +}); + +vi.mock("../ui/SmartTooltip", async () => { + const React = await import("react"); + return { SmartTooltip: ({ children }: { children: unknown }) => React.createElement(React.Fragment, null, children as never) }; +}); + +const lane: LaneSummary = { + id: "lane-1", + name: "Lane 1", + laneType: "worktree", + baseRef: "main", + branchRef: "feature/test", + worktreePath: "/repo", + parentLaneId: null, + childCount: 0, + stackDepth: 0, + parentStatus: null, + isEditProtected: false, + status: { + dirty: false, + ahead: 0, + behind: 0, + remoteBehind: 0, + rebaseInProgress: false, + }, + createdAt: "2026-05-13T00:00:00.000Z", + color: null, + icon: null, + tags: [], + missionId: null, +}; + +const laneTwo: LaneSummary = { + ...lane, + id: "lane-2", + name: "Lane 2", + branchRef: "feature/other", + worktreePath: "/repo-two", +}; + +const activeSession: TerminalSessionSummary = { + id: "session-1", + laneId: "lane-1", + laneName: "Lane 1", + ptyId: "pty-1", + tracked: true, + pinned: false, + goal: null, + toolType: "claude", + title: "Claude Code", + status: "running", + startedAt: "2026-05-13T00:00:00.000Z", + endedAt: null, + exitCode: null, + transcriptPath: "/tmp/transcript", + headShaStart: null, + headShaEnd: null, + lastOutputPreview: null, + summary: null, + runtimeState: "running", + resumeCommand: null, +}; + +const otherLaneAppControlSession: AppControlSession = { + id: "app-control-session-2", + appKind: "electron", + label: "Other lane app", + projectRoot: "/repo-two", + laneId: "lane-2", + cwd: "/repo-two", + command: "npm run dev", + pid: 123, + terminalSessionId: "term-2", + terminalPtyId: "pty-2", + cdpPort: 9222, + cdpEndpoint: "http://127.0.0.1:9222", + cdpTargetId: "target-2", + provider: "cdp", + chatSessionId: "chat-2", + startedAt: "2026-05-13T00:00:00.000Z", + connectedAt: "2026-05-13T00:00:01.000Z", + status: "connected", + lastError: null, +}; + +const otherLaneIosSession: IosSimulatorSession = { + id: "ios-session-2", + deviceUdid: "device-2", + deviceName: "iPhone 16", + bundleId: "com.example.app", + appName: "Example", + appBundlePath: null, + targetId: null, + projectRoot: "/repo-two", + laneId: "lane-2", + chatSessionId: "chat-2", + mode: "live", + bridgeUrl: null, + startedAt: "2026-05-13T00:00:00.000Z", +}; + +const iosContextItem: IosElementContextItem = { + kind: "ios_element", + id: "ios-context-1", + componentId: "ContentView/Continue", + sourceFile: "ContentView.swift", + sourceLine: 12, + frame: { x: 1, y: 2, width: 3, height: 4 }, + metadata: { label: "Continue", role: "Button" }, + screenshotDataUrl: null, + selectedAt: "2026-05-13T00:00:00.000Z", +}; + +const appControlContextItem: AppControlContextItem = { + kind: "app_control_element", + id: "app-context-1", + appKind: "electron", + sessionId: "app-control-session-1", + provider: "cdp", + componentId: "Run button", + sourceFile: "src/App.tsx", + sourceLine: 42, + frame: { x: 10, y: 20, width: 30, height: 40 }, + metadata: { label: "Run", selector: "button.run" }, + screenshotDataUrl: null, + selectedAt: "2026-05-13T00:00:00.000Z", +}; + +function installAdeMock(options: { + appControlSession?: AppControlSession | null; + iosSession?: IosSimulatorSession | null; +} = {}) { + const terminalWrite = vi.fn().mockResolvedValue({ ok: true }); + Object.defineProperty(window, "ade", { + configurable: true, + value: { + appControl: { + getStatus: vi.fn().mockResolvedValue({ activeSession: options.appControlSession ?? null }), + onEvent: vi.fn(() => () => {}), + }, + builtInBrowser: { + stopInspect: vi.fn().mockResolvedValue(undefined), + setBounds: vi.fn().mockResolvedValue(undefined), + }, + iosSimulator: { + getStatus: vi.fn().mockResolvedValue({ activeSession: options.iosSession ?? null }), + onEvent: vi.fn(() => () => {}), + }, + terminal: { + write: terminalWrite, + }, + }, + }); + return { terminalWrite }; +} + +function renderSidebar(args: { + tab: "ios" | "app-control" | "browser" | "macos-vm"; + contextTarget: WorkSidebarContextTarget | null; + contextDisabledReason?: string | null; + laneId?: string; + lanes?: LaneSummary[]; + activeSession?: TerminalSessionSummary | null; +}) { + return render( + + + , + ); +} + +describe("WorkSidebar context targets", () => { + beforeEach(() => { + installAdeMock(); + }); + + afterEach(() => { + cleanup(); + delete (window as unknown as { ade?: unknown }).ade; + vi.restoreAllMocks(); + }); + + it("passes chat session ids into iOS and App Control panels and dispatches chat events", async () => { + const received: unknown[] = []; + window.addEventListener("ade:agent-chat:add-ios-context", (event) => { + received.push((event as CustomEvent).detail); + }); + renderSidebar({ tab: "ios", contextTarget: { kind: "chat", sessionId: "chat-1" } }); + + expect(screen.getByTestId("ios-panel").getAttribute("data-session-id")).toBe("chat-1"); + fireEvent.click(screen.getByText("Add iOS context")); + + expect(received).toEqual([expect.objectContaining({ + sessionId: "chat-1", + item: expect.objectContaining({ id: "ios-context-1" }), + })]); + + cleanup(); + renderSidebar({ tab: "app-control", contextTarget: { kind: "chat", sessionId: "chat-1" } }); + + expect(screen.getByTestId("app-control-panel").getAttribute("data-session-id")).toBe("chat-1"); + }); + + it("writes formatted context to active PTY targets instead of dispatching chat events", async () => { + const { terminalWrite } = installAdeMock(); + const dispatchSpy = vi.spyOn(window, "dispatchEvent"); + const inserted: unknown[] = []; + window.addEventListener(ADE_WORK_PTY_CONTEXT_INSERTED_EVENT, (event) => { + inserted.push((event as CustomEvent).detail); + }); + + renderSidebar({ + tab: "ios", + contextTarget: { kind: "pty", sessionId: "term-1", ptyId: "pty-1", toolType: "claude" }, + }); + + expect(screen.getByTestId("ios-panel").getAttribute("data-session-id")).toBe(""); + fireEvent.click(screen.getByText("Add iOS context")); + + await waitFor(() => expect(terminalWrite).toHaveBeenCalledTimes(1)); + expect(terminalWrite).toHaveBeenCalledWith({ + terminalId: "term-1", + ptyId: "pty-1", + data: expect.stringContaining("\x1b[200~"), + }); + expect(terminalWrite.mock.calls[0]?.[0].data).toContain("iOS visual inspect context attached by the user."); + expect(terminalWrite.mock.calls[0]?.[0].data).toContain("Continue"); + expect(dispatchSpy).not.toHaveBeenCalledWith(expect.objectContaining({ type: "ade:agent-chat:add-ios-context" })); + await waitFor(() => expect(inserted).toEqual([expect.objectContaining({ + sessionId: "term-1", + ptyId: "pty-1", + toolType: "claude", + kind: "ios", + })])); + }); + + it("inserts screenshot attachment paths immediately for PTY targets", async () => { + const { terminalWrite } = installAdeMock(); + renderSidebar({ + tab: "browser", + contextTarget: { kind: "pty", sessionId: "term-1", ptyId: "pty-1", toolType: "codex" }, + }); + + fireEvent.click(screen.getByText("Add Browser attachment")); + + await waitFor(() => expect(terminalWrite).toHaveBeenCalledTimes(1)); + expect(terminalWrite.mock.calls[0]?.[0].data).toContain(".ade/artifacts/browser.png"); + expect(terminalWrite.mock.calls[0]?.[0].data).not.toContain("base64"); + }); + + it("keeps tools mounted but disables context insertion when there is no target", () => { + renderSidebar({ + tab: "ios", + contextTarget: null, + contextDisabledReason: "Shell sessions can use the lane tools, but context insertion targets chats or agent CLI sessions.", + }); + + expect(screen.getByTestId("ios-panel")).toBeTruthy(); + expect(screen.getByText(/Shell sessions can use the lane tools/)).toBeTruthy(); + expect((screen.getByText("Add iOS context") as HTMLButtonElement).disabled).toBe(true); + expect((screen.getByText("Add iOS attachment") as HTMLButtonElement).disabled).toBe(true); + }); + + it("warns when App Control is attached to another lane and disables context insertion", async () => { + installAdeMock({ appControlSession: otherLaneAppControlSession }); + + renderSidebar({ + tab: "app-control", + contextTarget: { kind: "pty", sessionId: "term-1", ptyId: "pty-1", toolType: "claude" }, + lanes: [lane, laneTwo], + }); + + expect(await screen.findByText(/This App Control view is attached to Lane 2, not Lane 1/)).toBeTruthy(); + expect((screen.getByText("Add App Control context") as HTMLButtonElement).disabled).toBe(true); + }); + + it("warns when the iOS Simulator is attached to another lane and disables context insertion", async () => { + installAdeMock({ iosSession: otherLaneIosSession }); + + renderSidebar({ + tab: "ios", + contextTarget: { kind: "pty", sessionId: "term-1", ptyId: "pty-1", toolType: "claude" }, + lanes: [lane, laneTwo], + }); + + expect(await screen.findByText(/This iOS Simulator view is attached to Lane 2, not Lane 1/)).toBeTruthy(); + expect((screen.getByText("Add iOS context") as HTMLButtonElement).disabled).toBe(true); + }); + + it("warns when the Browser view survives a lane switch", async () => { + const { rerender } = render( + + + , + ); + + rerender( + + + , + ); + + expect(await screen.findByText(/This Browser view is attached to Lane 1, not Lane 2/)).toBeTruthy(); + expect((screen.getByText("Add Browser context") as HTMLButtonElement).disabled).toBe(true); + }); +}); diff --git a/apps/desktop/src/renderer/components/terminals/WorkSidebar.tsx b/apps/desktop/src/renderer/components/terminals/WorkSidebar.tsx index 731e3f48c..f3ae14b7b 100644 --- a/apps/desktop/src/renderer/components/terminals/WorkSidebar.tsx +++ b/apps/desktop/src/renderer/components/terminals/WorkSidebar.tsx @@ -20,9 +20,21 @@ import type { LaneSummary, MacosVmContextItem, TerminalSessionSummary, + TerminalToolType, } from "../../../shared/types"; import type { WorkSidebarTab } from "../../state/appStore"; import { formatToolTypeLabel } from "../../lib/sessions"; +import { + formatAppControlContextForPrompt, + formatBuiltInBrowserContextForPrompt, + formatIosElementContextForPrompt, + formatMacosVmContextForPrompt, + normalizeBuiltInBrowserContextItem, +} from "../../lib/visualContextFormatting"; +import { + dispatchWorkPtyContextInserted, + type WorkPtyContextInsertKind, +} from "../../lib/workPtyContextEvents"; import { ChatAppControlPanel } from "../chat/ChatAppControlPanel"; import { ChatBuiltInBrowserPanel } from "../chat/ChatBuiltInBrowserPanel"; import { ChatIosSimulatorPanel } from "../chat/ChatIosSimulatorPanel"; @@ -46,7 +58,33 @@ const WORK_SIDEBAR_TABS: Array<{ { id: "macos-vm", label: "Mac VM", Icon: Cube }, ]; -const NOT_CHAT_ERROR = "Context attachment only works in ADE chat sessions."; +export type WorkSidebarContextTarget = + | { kind: "chat"; sessionId: string } + | { kind: "pty"; sessionId: string; ptyId: string; toolType: TerminalToolType | null }; + +const NO_CONTEXT_TARGET_ERROR = "Open a chat or agent CLI session in this lane before inserting tool context."; +const BRACKETED_PASTE_START = "\x1b[200~"; +const BRACKETED_PASTE_END = "\x1b[201~"; + +function shortLaneId(laneId: string): string { + return laneId.length <= 8 ? laneId : `${laneId.slice(0, 4)}...${laneId.slice(-3)}`; +} + +function laneDisplayName(lanes: LaneSummary[], laneId: string | null): string { + if (!laneId) return "another lane"; + return lanes.find((lane) => lane.id === laneId)?.name ?? shortLaneId(laneId); +} + +function laneMismatchMessage( + toolName: string, + ownerLaneId: string | null, + activeLaneId: string | null, + lanes: LaneSummary[], +): string { + const ownerLane = laneDisplayName(lanes, ownerLaneId); + const activeLane = laneDisplayName(lanes, activeLaneId); + return `This ${toolName} view is attached to ${ownerLane}, not ${activeLane}. Quit this view, then restart it from a chat or Claude Code session in ${activeLane} before inserting context.`; +} function dispatchAgentChatEvent(eventName: string, sessionId: string, key: string, value: T): void { window.dispatchEvent(new CustomEvent(eventName, { @@ -57,6 +95,19 @@ function dispatchAgentChatEvent(eventName: string, sessionId: string, key: st })); } +function bracketedPaste(text: string): string { + return `${BRACKETED_PASTE_START}${text.trimEnd()}\n${BRACKETED_PASTE_END}`; +} + +function formatAttachmentForPty(attachment: AgentChatFileRef): string { + return [ + "ADE visual attachment saved by the Work sidebar.", + `Path: ${attachment.path}`, + `Type: ${attachment.type}`, + "", + ].join("\n"); +} + function hideBuiltInBrowserView(): void { const browser = window.ade?.builtInBrowser; if (!browser) return; @@ -87,8 +138,8 @@ export function WorkSidebar({ tab, onTabChange, onClose, - attachChatSessionId, - attachDisabledReason, + contextTarget, + contextDisabledReason: targetDisabledReason, }: { active?: boolean; laneId: string | null; @@ -97,8 +148,8 @@ export function WorkSidebar({ tab: WorkSidebarTab; onTabChange: (tab: WorkSidebarTab) => void; onClose: () => void; - attachChatSessionId: string | null; - attachDisabledReason: string | null; + contextTarget: WorkSidebarContextTarget | null; + contextDisabledReason: string | null; }) { const navigate = useNavigate(); const [selectedPath, setSelectedPath] = useState(null); @@ -106,6 +157,7 @@ export function WorkSidebar({ const [selectedCommit, setSelectedCommit] = useState(null); const [appControlSession, setAppControlSession] = useState(null); const [iosSession, setIosSession] = useState(null); + const [browserViewLaneId, setBrowserViewLaneId] = useState(tab === "browser" ? laneId : null); const sidebarRef = useRef(null); const [compactTabs, setCompactTabs] = useState(false); @@ -145,6 +197,16 @@ export function WorkSidebar({ }; }, [active, tab]); + const previousTabForBrowserOwnerRef = useRef(tab); + useEffect(() => { + if (!active) return; + const previousTab = previousTabForBrowserOwnerRef.current; + if (tab === "browser" && (previousTab !== "browser" || !browserViewLaneId)) { + setBrowserViewLaneId(laneId); + } + previousTabForBrowserOwnerRef.current = tab; + }, [active, browserViewLaneId, laneId, tab]); + useEffect(() => { if (!active) return undefined; if (tab !== "app-control") return undefined; @@ -195,46 +257,138 @@ export function WorkSidebar({ function resolveLaneMismatchReason(): string | null { if (!laneId) return null; + if (tab === "browser" && browserViewLaneId && browserViewLaneId !== laneId) { + return laneMismatchMessage("Browser", browserViewLaneId, laneId, lanes); + } if (tab === "app-control" && appControlSession?.laneId && appControlSession.laneId !== laneId) { - return "App Control is attached to a different lane. You can still stop or relaunch it here, but ADE will not attach selections to this chat until the tool session matches the active lane."; + return laneMismatchMessage("App Control", appControlSession.laneId, laneId, lanes); } if (tab === "ios" && iosSession?.laneId && iosSession.laneId !== laneId) { - return "The iOS Simulator session was launched from a different lane. You can still inspect it, but ADE will not attach simulator context to this chat until the tool session matches the active lane."; + return laneMismatchMessage("iOS Simulator", iosSession.laneId, laneId, lanes); } return null; } - const contextDisabledReason = resolveLaneMismatchReason() ?? attachDisabledReason; - const canAttachToChat = Boolean(attachChatSessionId && !contextDisabledReason); + const contextDisabledReason = resolveLaneMismatchReason() ?? targetDisabledReason; + const canInsertContext = Boolean(contextTarget && !contextDisabledReason); + const panelSessionId = contextTarget?.kind === "chat" ? contextTarget.sessionId : null; + + const dispatchTargetRef = useRef({ contextTarget, contextDisabledReason }); + dispatchTargetRef.current = { contextTarget, contextDisabledReason }; - const dispatchTargetRef = useRef({ attachChatSessionId, contextDisabledReason }); - dispatchTargetRef.current = { attachChatSessionId, contextDisabledReason }; + const insertIntoPty = useCallback(( + target: Extract, + text: string, + kind: WorkPtyContextInsertKind, + ) => { + const payload = text.trimEnd(); + if (!payload) return; + void window.ade.terminal.write({ + terminalId: target.sessionId, + ptyId: target.ptyId, + data: bracketedPaste(payload), + }) + .then(() => { + dispatchWorkPtyContextInserted({ + sessionId: target.sessionId, + ptyId: target.ptyId, + toolType: target.toolType, + kind, + }); + }) + .catch((error: unknown) => { + console.error("[WorkSidebar] Failed to insert context into PTY", { + sessionId: target.sessionId, + toolType: target.toolType, + error, + }); + }); + }, []); - const dispatchToChat = useCallback((eventName: string, key: string, value: T, fallbackError: string) => { - const { attachChatSessionId: targetSessionId, contextDisabledReason: targetReason } = dispatchTargetRef.current; - if (!targetSessionId || targetReason) { + const withContextTarget = useCallback(( + fallbackError: string, + action: (target: WorkSidebarContextTarget) => void, + ) => { + const { contextTarget: target, contextDisabledReason: targetReason } = dispatchTargetRef.current; + if (!target || targetReason) { throw new Error(targetReason ?? fallbackError); } - dispatchAgentChatEvent(eventName, targetSessionId, key, value); + action(target); }, []); + const insertContext = useCallback(( + eventName: string, + key: string, + value: T, + kind: WorkPtyContextInsertKind, + formatForPty: (value: T) => string | null, + ) => { + withContextTarget(NO_CONTEXT_TARGET_ERROR, (target) => { + if (target.kind === "chat") { + dispatchAgentChatEvent(eventName, target.sessionId, key, value); + return; + } + const text = formatForPty(value); + if (text) insertIntoPty(target, text, kind); + }); + }, [insertIntoPty, withContextTarget]); + const addAttachment = useCallback((attachment: AgentChatFileRef) => { - dispatchToChat("ade:agent-chat:add-attachment", "attachment", attachment, NOT_CHAT_ERROR); - }, [dispatchToChat]); + insertContext( + "ade:agent-chat:add-attachment", + "attachment", + attachment, + "attachment", + formatAttachmentForPty, + ); + }, [insertContext]); const addIosContext = useCallback((item: IosElementContextItem) => { - dispatchToChat("ade:agent-chat:add-ios-context", "item", item, NOT_CHAT_ERROR); - }, [dispatchToChat]); + insertContext( + "ade:agent-chat:add-ios-context", + "item", + item, + "ios", + (value) => formatIosElementContextForPrompt([value]), + ); + }, [insertContext]); const addAppControlContext = useCallback((item: AppControlContextItem) => { - dispatchToChat("ade:agent-chat:add-app-control-context", "item", item, NOT_CHAT_ERROR); - }, [dispatchToChat]); + insertContext( + "ade:agent-chat:add-app-control-context", + "item", + item, + "app-control", + (value) => formatAppControlContextForPrompt([value]), + ); + }, [insertContext]); const addMacosVmContext = useCallback((item: MacosVmContextItem) => { - dispatchToChat("ade:agent-chat:add-macos-vm-context", "item", item, NOT_CHAT_ERROR); - }, [dispatchToChat]); + insertContext( + "ade:agent-chat:add-macos-vm-context", + "item", + item, + "macos-vm", + (value) => formatMacosVmContextForPrompt([value]), + ); + }, [insertContext]); const addBuiltInBrowserContext = useCallback((item: unknown) => { - dispatchToChat("ade:agent-chat:add-builtin-browser-context", "item", item, NOT_CHAT_ERROR); - }, [dispatchToChat]); + insertContext( + "ade:agent-chat:add-builtin-browser-context", + "item", + item, + "browser", + (value) => { + const browserItem = normalizeBuiltInBrowserContextItem(value); + return browserItem ? formatBuiltInBrowserContextForPrompt([browserItem]) : null; + }, + ); + }, [insertContext]); const insertDraft = useCallback((text: string) => { - dispatchToChat("ade:agent-chat:insert-draft", "text", text, "Draft insertion only works in ADE chat sessions."); - }, [dispatchToChat]); + withContextTarget("Open a chat or agent CLI session in this lane before inserting draft text.", (target) => { + if (target.kind === "chat") { + dispatchAgentChatEvent("ade:agent-chat:insert-draft", target.sessionId, "text", text); + return; + } + insertIntoPty(target, text, "draft"); + }); + }, [insertIntoPty, withContextTarget]); const content = useMemo(() => { if (!active) return null; @@ -244,10 +398,10 @@ export function WorkSidebar({ {contextDisabledReason ? : null}
@@ -321,7 +475,7 @@ export function WorkSidebar({ @@ -330,21 +484,21 @@ export function WorkSidebar({ const panel = tab === "ios" ? ( ) : ( ); return ( @@ -359,8 +513,8 @@ export function WorkSidebar({ addBuiltInBrowserContext, addIosContext, addMacosVmContext, - attachChatSessionId, - canAttachToChat, + panelSessionId, + canInsertContext, contextDisabledReason, insertDraft, laneId, diff --git a/apps/desktop/src/renderer/components/terminals/WorkStartSurface.test.tsx b/apps/desktop/src/renderer/components/terminals/WorkStartSurface.test.tsx index 1e8c84fc6..ed9f68ac6 100644 --- a/apps/desktop/src/renderer/components/terminals/WorkStartSurface.test.tsx +++ b/apps/desktop/src/renderer/components/terminals/WorkStartSurface.test.tsx @@ -1,11 +1,19 @@ /* @vitest-environment jsdom */ -import { cleanup, render, screen } from "@testing-library/react"; +import { cleanup, render, screen, waitFor, fireEvent } from "@testing-library/react"; import { afterEach, describe, expect, it, vi } from "vitest"; import type { AppState } from "../../state/appStore"; import { WorkStartSurface } from "./WorkStartSurface"; const selectLane = vi.fn(); +const agentChatPaneProps = vi.hoisted(() => ({ + latest: null as null | { + laneId: string | null; + workDraftKind?: "chat" | "cli"; + onOpenShellSession?: (laneId: string) => void | Promise; + onLaunchCliSession?: unknown; + }, +})); vi.mock("../../state/appStore", () => ({ useAppStore: (selector: (state: AppState) => T): T => @@ -13,13 +21,28 @@ vi.mock("../../state/appStore", () => ({ })); vi.mock("../chat/AgentChatPane", () => ({ - AgentChatPane: () =>
, + AgentChatPane: (props: { + laneId: string | null; + workDraftKind?: "chat" | "cli"; + onOpenShellSession?: (laneId: string) => void | Promise; + onLaunchCliSession?: unknown; + }) => { + agentChatPaneProps.latest = props; + return ( +
+ +
+ ); + }, })); describe("WorkStartSurface", () => { afterEach(() => { cleanup(); vi.clearAllMocks(); + agentChatPaneProps.latest = null; }); it("renders the no-lanes state", () => { @@ -36,4 +59,34 @@ describe("WorkStartSurface", () => { expect(screen.getByText("Create or reopen a lane before starting work.")).toBeTruthy(); expect(screen.queryByTestId("agent-chat-pane")).toBeNull(); }); + + it("uses one AgentChatPane surface for CLI mode and opens shell in the selected lane", async () => { + const onLaunchPtySession = vi.fn().mockResolvedValue({}); + render( + , + ); + + expect(await screen.findByTestId("agent-chat-pane")).toBeTruthy(); + expect(agentChatPaneProps.latest?.workDraftKind).toBe("cli"); + expect(agentChatPaneProps.latest?.onLaunchCliSession).toBe(onLaunchPtySession); + + fireEvent.click(screen.getByText("mock shell")); + await waitFor(() => { + expect(onLaunchPtySession).toHaveBeenCalledWith({ + laneId: "lane-2", + profile: "shell", + title: "Shell", + }); + }); + }); }); diff --git a/apps/desktop/src/renderer/components/terminals/WorkStartSurface.tsx b/apps/desktop/src/renderer/components/terminals/WorkStartSurface.tsx index fa6275b40..44a64f4d5 100644 --- a/apps/desktop/src/renderer/components/terminals/WorkStartSurface.tsx +++ b/apps/desktop/src/renderer/components/terminals/WorkStartSurface.tsx @@ -1,23 +1,16 @@ -import React, { useCallback, useEffect, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; import { - Terminal, - ArrowRight, CircleNotch, } from "@phosphor-icons/react"; -import type { AgentChatPermissionMode, AgentChatSession, LaneLinearIssue, LaneSummary } from "../../../shared/types"; +import type { AgentChatSession, LaneLinearIssue, LaneSummary } from "../../../shared/types"; import type { WorkDraftKind } from "../../state/appStore"; import { useAppStore } from "../../state/appStore"; import { AgentChatPane } from "../chat/AgentChatPane"; -import { getPermissionOptions, safetyColors } from "../shared/permissionOptions"; -import { LaneCombobox } from "./LaneCombobox"; -import { buildTrackedCliLaunchCommand, type CliProvider, type LaunchProfile } from "./cliLaunch"; -import { ClaudeLogo, CodexLogo, CursorAgentLogo, OpenCodeLogo } from "./ToolLogos"; -import { SmartTooltip } from "../ui/SmartTooltip"; -import { cn } from "../ui/cn"; -import { DroidLogo } from "../shared/ProviderLogos"; +import type { LaunchProfile } from "./cliLaunch"; type WorkStartSurfaceProps = { draftKind: WorkDraftKind; + draftLaneId?: string | null; lanes: LaneSummary[]; onOpenChatSession: (session: AgentChatSession) => void | Promise; onLaunchPtySession: (args: { @@ -30,40 +23,18 @@ type WorkStartSurfaceProps = { env?: Record; tracked?: boolean; }) => Promise; + onDraftLaneChange?: (laneId: string) => void; initialLinearIssueContext?: LaneLinearIssue | null; onInitialLinearIssueContextConsumed?: () => void; }; -type CliProviderOption = { - id: CliProvider; - label: string; - shortLabel: string; - family: string; - defaultPermission: AgentChatPermissionMode; - Logo: React.FC<{ size?: number; className?: string }>; -}; - -const CLI_PROVIDER_OPTIONS: Record = { - claude: { id: "claude", label: "Claude Code", shortLabel: "Claude", family: "anthropic", defaultPermission: "default", Logo: ClaudeLogo }, - codex: { id: "codex", label: "Codex CLI", shortLabel: "Codex", family: "openai", defaultPermission: "default", Logo: CodexLogo }, - cursor: { id: "cursor", label: "Cursor Agent CLI", shortLabel: "Cursor", family: "cursor", defaultPermission: "default", Logo: CursorAgentLogo }, - droid: { id: "droid", label: "Factory Droid CLI", shortLabel: "Droid", family: "factory", defaultPermission: "edit", Logo: DroidLogo }, - opencode: { id: "opencode", label: "OpenCode CLI", shortLabel: "OpenCode", family: "opencode", defaultPermission: "edit", Logo: OpenCodeLogo }, -}; - -const CLI_PROVIDER_LIST: CliProviderOption[] = [ - CLI_PROVIDER_OPTIONS.claude, - CLI_PROVIDER_OPTIONS.codex, - CLI_PROVIDER_OPTIONS.cursor, - CLI_PROVIDER_OPTIONS.droid, - CLI_PROVIDER_OPTIONS.opencode, -]; - export function WorkStartSurface({ draftKind, + draftLaneId = null, lanes, onOpenChatSession, onLaunchPtySession, + onDraftLaneChange, initialLinearIssueContext = null, onInitialLinearIssueContextConsumed, }: WorkStartSurfaceProps) { @@ -71,15 +42,16 @@ export function WorkStartSurface({ const lanesLoading = useAppStore((s) => s.lanesLoading); const selectLaneGlobal = useAppStore((s) => s.selectLane); const [selectedLaneId, setSelectedLaneId] = useState(() => { + if (draftLaneId && lanes.some((lane) => lane.id === draftLaneId)) { + return draftLaneId; + } if (globallySelectedLaneId && lanes.some((lane) => lane.id === globallySelectedLaneId)) { return globallySelectedLaneId; } return lanes[0]?.id ?? ""; }); - const [cliProvider, setCliProvider] = useState("claude"); - const [cliPermissionMode, setCliPermissionMode] = useState("default"); const [launchBusy, setLaunchBusy] = useState(false); - const [chatDraftReady, setChatDraftReady] = useState(false); + const [draftReady, setDraftReady] = useState(false); const selectedLane = useMemo( () => lanes.find((lane) => lane.id === selectedLaneId) ?? lanes[0] ?? null, [lanes, selectedLaneId], @@ -87,89 +59,46 @@ export function WorkStartSurface({ const setLaneAndSync = useCallback((laneId: string) => { setSelectedLaneId(laneId); + onDraftLaneChange?.(laneId); selectLaneGlobal(laneId); - }, [selectLaneGlobal]); + }, [onDraftLaneChange, selectLaneGlobal]); useEffect(() => { if (!lanes.length) { setSelectedLaneId(""); return; } + if (draftLaneId && draftLaneId !== selectedLaneId && lanes.some((lane) => lane.id === draftLaneId)) { + setSelectedLaneId(draftLaneId); + selectLaneGlobal(draftLaneId); + return; + } if (!selectedLaneId || !lanes.some((lane) => lane.id === selectedLaneId)) { const fallbackLaneId = - globallySelectedLaneId && lanes.some((lane) => lane.id === globallySelectedLaneId) - ? globallySelectedLaneId - : lanes[0]!.id; + draftLaneId && lanes.some((lane) => lane.id === draftLaneId) + ? draftLaneId + : globallySelectedLaneId && lanes.some((lane) => lane.id === globallySelectedLaneId) + ? globallySelectedLaneId + : lanes[0]!.id; setSelectedLaneId(fallbackLaneId); + onDraftLaneChange?.(fallbackLaneId); selectLaneGlobal(fallbackLaneId); } - }, [globallySelectedLaneId, lanes, selectedLaneId, selectLaneGlobal]); - - const cliPermissionOptions = useMemo( - () => getPermissionOptions({ - family: CLI_PROVIDER_OPTIONS[cliProvider].family, - isCliWrapped: true, - }), - [cliProvider], - ); - - useEffect(() => { - if (!cliPermissionOptions.some((option) => option.value === cliPermissionMode)) { - const providerDefault = CLI_PROVIDER_OPTIONS[cliProvider].defaultPermission; - const fallback = cliPermissionOptions.some((option) => option.value === providerDefault) - ? providerDefault - : cliPermissionOptions[0]?.value ?? "default"; - setCliPermissionMode(fallback); - } - }, [cliProvider, cliPermissionMode, cliPermissionOptions]); - - const selectCliProvider = useCallback((provider: CliProvider) => { - setCliProvider(provider); - setCliPermissionMode(CLI_PROVIDER_OPTIONS[provider].defaultPermission); - }, []); + }, [draftLaneId, globallySelectedLaneId, lanes, onDraftLaneChange, selectedLaneId, selectLaneGlobal]); useEffect(() => { - if (draftKind !== "chat") { - setChatDraftReady(false); - return; - } const timer = window.setTimeout(() => { - setChatDraftReady(true); + setDraftReady(true); }, 140); return () => window.clearTimeout(timer); - }, [draftKind]); - - const launchCli = async () => { - if (!selectedLaneId || launchBusy) return; - setLaunchBusy(true); - try { - // Generate a session ID upfront for Claude so resume always works - const sessionId = cliProvider === "claude" ? crypto.randomUUID() : undefined; - const launch = buildTrackedCliLaunchCommand({ - provider: cliProvider, - permissionMode: cliPermissionMode, - sessionId, - }); - await onLaunchPtySession({ - laneId: selectedLaneId, - profile: cliProvider, - title: CLI_PROVIDER_OPTIONS[cliProvider].label, - startupCommand: launch.startupCommand, - ...(launch.command !== undefined ? { command: launch.command } : {}), - args: launch.args, - ...(launch.env ? { env: launch.env } : {}), - }); - } finally { - setLaunchBusy(false); - } - }; + }, []); - const launchShell = async () => { - if (!selectedLaneId || launchBusy) return; + const launchShell = async (laneId: string) => { + if (!laneId || launchBusy) return; setLaunchBusy(true); try { await onLaunchPtySession({ - laneId: selectedLaneId, + laneId, profile: "shell", title: "Shell", }); @@ -198,214 +127,37 @@ export function WorkStartSurface({ ); } - /* ---- Chat draft ---- */ - if (draftKind === "chat") { - return ( -
-
- {chatDraftReady ? ( - - ) : ( -
-
-
Preparing chat draft
-
- ADE waits briefly before mounting the full chat surface so fast tab switches stay cheap. -
-
-
- )} -
-
- ); - } - - /* ---- CLI draft ---- */ - if (draftKind === "cli") { - return ( -
- -
- - {/* Lane */} -
- Lane - -
- - {/* Provider toggle */} -
- {CLI_PROVIDER_LIST.map((opt) => { - const active = cliProvider === opt.id; - return ( - - - - ); - })} -
- - {/* Permission pills */} -
- {cliPermissionOptions.map((option) => { - const active = cliPermissionMode === option.value; - const colors = safetyColors(option.safety); - return ( - - - - ); - })} -
- - {/* Launch */} - - - -
-
-
- ); - } - - /* ---- Shell draft ---- */ return ( -
- -
- - {/* Lane */} -
- Lane - -
- - {/* Description */} -
-
- -
-
Shell session
-
- Terminal with lane context for commands and debugging. +
+
+ {draftReady ? ( + + ) : ( +
+
+
Preparing draft
+
+ ADE waits briefly before mounting the full chat surface so fast tab switches stay cheap.
-
- - {/* Launch */} - - - - -
-
- ); -} - -const LOGO_SIZES = { - lg: { maxImg: 560, glowMultiplier: 1.21, blur: "blur(160px)" }, - sm: { maxImg: 320, glowMultiplier: 0.875, blur: "blur(90px)" }, -} as const; - -function LogoGlow({ size }: { size: "lg" | "sm" }) { - const s = LOGO_SIZES[size]; - return ( -
-
-
- ADE + )}
); } - -function GlassCard({ children }: { children: React.ReactNode }) { - return ( -
- {children} -
- ); -} diff --git a/apps/desktop/src/renderer/components/terminals/WorkViewArea.test.tsx b/apps/desktop/src/renderer/components/terminals/WorkViewArea.test.tsx index 8365e462b..0ce3c4868 100644 --- a/apps/desktop/src/renderer/components/terminals/WorkViewArea.test.tsx +++ b/apps/desktop/src/renderer/components/terminals/WorkViewArea.test.tsx @@ -2,8 +2,8 @@ import { useState, type ReactNode } from "react"; import type * as ReactNamespace from "react"; -import { fireEvent, render, screen, within } from "@testing-library/react"; -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { TerminalSessionSummary } from "../../../shared/types"; import { collectLeafIds } from "../ui/paneTreeOps"; import { FloatingPane } from "../ui/FloatingPane"; @@ -21,6 +21,16 @@ vi.mock("./TerminalView", () => ({ ), })); +vi.mock("./WorkCliSessionHeader", () => ({ + WorkCliSessionHeader: ({ + session, + }: { + session: TerminalSessionSummary; + }) => ( +
+ ), +})); + vi.mock("../chat/AgentChatPane", async () => { const React = await vi.importActual("react") as typeof ReactNamespace; return { @@ -88,14 +98,62 @@ let latestPaneTilingLayoutProps: { tree: unknown; panes: Record void }>; } | null = null; +const terminalPreviewMock = vi.fn(); +const slashCommandsMock = vi.fn(); +const modelsMock = vi.fn(); +const sendToSessionMock = vi.fn(); beforeEach(() => { latestPaneTilingLayoutProps = null; chatPaneLifecycle.mounts.clear(); chatPaneLifecycle.unmounts.clear(); + terminalPreviewMock.mockReset(); + terminalPreviewMock.mockResolvedValue({ + terminalId: "session-1", + source: "empty", + snapshot: null, + transcript: null, + capturedAt: "2026-04-06T12:10:00.000Z", + }); + slashCommandsMock.mockReset(); + slashCommandsMock.mockResolvedValue([]); + modelsMock.mockReset(); + modelsMock.mockImplementation(({ provider }: { provider: string }) => { + if (provider === "codex") { + return Promise.resolve([ + { id: "gpt-5.4", displayName: "GPT-5.4", isDefault: true }, + { id: "gpt-5.3-codex", displayName: "GPT-5.3 Codex", isDefault: false }, + ]); + } + return Promise.resolve([ + { id: "sonnet", displayName: "Claude Sonnet 4.6", isDefault: true }, + { id: "haiku", displayName: "Claude Haiku 4.5", isDefault: false }, + ]); + }); + sendToSessionMock.mockReset(); + sendToSessionMock.mockResolvedValue({ sessionId: "session-1", ptyId: "pty-1", pid: 123, session: null, resumed: true, reusedExistingRuntime: false }); + Object.defineProperty(window, "ade", { + configurable: true, + value: { + agentChat: { + models: modelsMock, + slashCommands: slashCommandsMock, + }, + pty: { + sendToSession: sendToSessionMock, + }, + terminal: { + preview: terminalPreviewMock, + }, + }, + }); vi.mocked(isChatToolType).mockReturnValue(false); }); +afterEach(() => { + cleanup(); +}); + vi.mock("./ToolLogos", () => ({ ToolLogo: () => , })); @@ -104,6 +162,12 @@ vi.mock("../../lib/sessions", () => ({ isChatToolType: vi.fn(() => false), primarySessionLabel: vi.fn((session: TerminalSessionSummary) => session.title), secondarySessionLabel: vi.fn(() => null), + stripTerminalLabelControls: vi.fn((raw: string) => raw + .replace(/\u001b\][^\u0007]*(?:\u0007|\u001b\\)/g, "") + .replace(/\u001b\[[0-?]*[ -/]*[@-~]/g, "") + .replace(/\u001b[\(\)][0-9A-Za-z]/g, "") + .replace(/\u001b(?:[@-Z\\-_]|[0-9=>])/g, "") + .replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/g, "")), truncateSessionLabel: vi.fn((label: string) => label), formatToolTypeLabel: vi.fn((toolType: string | null | undefined) => toolType ?? "Tool"), chatToolTypeForProvider: vi.fn(() => "opencode-chat"), @@ -165,10 +229,55 @@ function makeChatSession(id: string): TerminalSessionSummary { } describe("WorkViewArea", () => { + it("shows only Chat and CLI start modes on the empty Work surface", () => { + render( + {}} + onSelectItem={() => {}} + onCloseItem={() => {}} + onOpenChatSession={() => {}} + onLaunchPtySession={async () => ({})} + onShowDraftKind={() => {}} + onToggleTabGroupCollapsed={() => {}} + closingPtyIds={new Set()} + />, + ); + + expect(screen.getByRole("button", { name: "Chat" })).toBeTruthy(); + expect(screen.getByRole("button", { name: "CLI" })).toBeTruthy(); + expect(screen.queryByRole("button", { name: "Shell" })).toBeNull(); + }); + it("shows the draft surface when no tab is active, even if tabs are open", () => { const session = makeSession(); - render( + const view = render( { const first = makeRunningSession("session-1", "pty-1"); const second = makeRunningSession("session-2", "pty-2"); - render( + const view = render( { expect(screen.getAllByTestId("terminal-view")).toHaveLength(2); }); + it("adds the CLI session header above agent PTY sessions", () => { + const session = { ...makeRunningSession("session-1", "pty-1"), toolType: "claude" as const }; + + const view = render( + {}} + onSelectItem={() => {}} + onCloseItem={() => {}} + onOpenChatSession={() => {}} + onLaunchPtySession={async () => ({})} + onShowDraftKind={() => {}} + onToggleTabGroupCollapsed={() => {}} + closingPtyIds={new Set()} + />, + ); + + const header = within(view.container).getByTestId("work-cli-session-header"); + const terminals = within(view.container).getAllByTestId("terminal-view"); + expect(header.getAttribute("data-session-id")).toBe("session-1"); + expect(terminals.map((terminal) => terminal.getAttribute("data-session-id"))).toContain("session-1"); + }); + + it("shows the transcript for closed agent CLI sessions instead of the generic ended card", async () => { + terminalPreviewMock.mockResolvedValueOnce({ + terminalId: "session-1", + source: "snapshot", + transcript: "\u001b7older output\rfinal answer\nResume this session with:\nclaude --resume abc123\n", + capturedAt: "2026-04-06T12:10:00.000Z", + snapshot: { + version: 1, + terminalId: "session-1", + cols: 24, + rows: 2, + capturedAt: "2026-04-06T12:10:00.000Z", + status: "completed", + runtimeState: "exited", + bufferType: "normal", + cursorX: 0, + cursorY: 0, + baseY: 0, + viewportY: 0, + serialized: "", + visibleRows: [{ + text: "Resume this session with:", + wrapped: false, + cells: "Resume this session with:".split("").map((text) => ({ + text, + fg: 2, + bg: null, + fgMode: "palette" as const, + bgMode: "default" as const, + })), + }], + }, + }); + const session = { + ...makeSession(), + toolType: "claude" as const, + resumeCommand: "claude --resume abc123", + resumeMetadata: { + provider: "claude" as const, + targetKind: "session" as const, + targetId: "abc123", + launch: { permissionMode: "default" as const }, + }, + }; + + const view = render( + {}} + onSelectItem={() => {}} + onCloseItem={() => {}} + onOpenChatSession={() => {}} + onLaunchPtySession={async () => ({})} + onShowDraftKind={() => {}} + onToggleTabGroupCollapsed={() => {}} + closingPtyIds={new Set()} + />, + ); + const local = within(view.container); + + expect(await local.findByText(/older output/)).toBeTruthy(); + expect(local.getByText(/final answer/)).toBeTruthy(); + expect(local.queryByText("Resume this session with:")).toBeNull(); + expect(local.getByLabelText("Continue Claude Code session")).toBeTruthy(); + expect(local.getByRole("button", { name: "Select model" })).toBeTruthy(); + expect(local.getByLabelText("Reasoning effort")).toBeTruthy(); + expect(local.getByLabelText("Claude Code permission mode")).toBeTruthy(); + expect(local.queryByText("Resume")).toBeNull(); + expect(local.getAllByTestId("work-cli-session-header").some((header) => header.getAttribute("data-session-id") === "session-1")).toBe(true); + expect(local.queryAllByTestId("terminal-view")).toHaveLength(0); + expect(terminalPreviewMock).toHaveBeenCalledWith({ terminalId: "session-1", maxBytes: 160_000 }); + expect(slashCommandsMock).toHaveBeenCalledWith({ laneId: "lane-1", provider: "claude" }); + expect(modelsMock).toHaveBeenCalledWith({ provider: "claude" }); + }); + + it("submits continuation text for ended agent CLI sessions", async () => { + const onContinue = vi.fn().mockResolvedValue(undefined); + const session = { + ...makeSession(), + toolType: "codex" as const, + resumeCommand: "codex resume thread-1", + resumeMetadata: { + provider: "codex" as const, + targetKind: "thread" as const, + targetId: "thread-1", + launch: { permissionMode: "plan" as const }, + }, + }; + + const view = render( + {}} + onSelectItem={() => {}} + onCloseItem={() => {}} + onOpenChatSession={() => {}} + onLaunchPtySession={async () => ({})} + onShowDraftKind={() => {}} + onToggleTabGroupCollapsed={() => {}} + closingPtyIds={new Set()} + onContinueCliSession={onContinue} + />, + ); + + const textarea = await within(view.container).findByLabelText("Continue Codex session"); + await waitFor(() => { + expect(within(view.container).getByRole("button", { name: "Select model" })).toBeTruthy(); + expect((within(view.container).getByLabelText("Reasoning effort") as HTMLSelectElement).value).toBe("high"); + }); + fireEvent.change(textarea, { target: { value: "fix the test" } }); + fireEvent.keyDown(textarea, { key: "Enter" }); + + await waitFor(() => expect(onContinue).toHaveBeenCalledWith(session, "fix the test", { + model: "gpt-5.4", + permissionMode: "plan", + reasoningEffort: "high", + })); + expect((textarea as HTMLTextAreaElement).value).toBe(""); + }); + + it("keeps a changed continuation permission when session metadata is refreshed with the same values", async () => { + const session = { + ...makeSession(), + toolType: "codex" as const, + resumeCommand: "codex resume thread-1", + resumeMetadata: { + provider: "codex" as const, + targetKind: "thread" as const, + targetId: "thread-1", + launch: { permissionMode: "plan" as const }, + }, + }; + const renderView = (nextSession: typeof session) => ( + {}} + onSelectItem={() => {}} + onCloseItem={() => {}} + onOpenChatSession={() => {}} + onLaunchPtySession={async () => ({})} + onShowDraftKind={() => {}} + onToggleTabGroupCollapsed={() => {}} + closingPtyIds={new Set()} + /> + ); + + const view = render(renderView(session)); + const local = within(view.container); + + const picker = await local.findByLabelText("Codex permission mode"); + expect(picker.textContent).toContain("Plan mode"); + fireEvent.click(picker); + fireEvent.click(await screen.findByText("Full access")); + expect(local.getByLabelText("Codex permission mode").textContent).toContain("Full access"); + + const refreshedSession = { + ...session, + title: "Updated title", + resumeMetadata: { + ...session.resumeMetadata, + launch: { ...session.resumeMetadata.launch }, + }, + }; + view.rerender(renderView(refreshedSession)); + + expect(local.getByLabelText("Codex permission mode").textContent).toContain("Full access"); + }); + + it("shows provider-specific slash command suggestions in the continuation composer", async () => { + slashCommandsMock.mockResolvedValue([ + { name: "/status", description: "Show status", source: "sdk" }, + ]); + const session = { + ...makeSession(), + toolType: "claude" as const, + resumeCommand: "claude --resume abc123", + resumeMetadata: { + provider: "claude" as const, + targetKind: "session" as const, + targetId: "abc123", + launch: { permissionMode: "default" as const }, + }, + }; + + render( + {}} + onSelectItem={() => {}} + onCloseItem={() => {}} + onOpenChatSession={() => {}} + onLaunchPtySession={async () => ({})} + onShowDraftKind={() => {}} + onToggleTabGroupCollapsed={() => {}} + closingPtyIds={new Set()} + />, + ); + + await waitFor(() => expect(slashCommandsMock).toHaveBeenCalledWith({ laneId: "lane-1", provider: "claude" })); + const textareas = await screen.findAllByLabelText("Continue Claude Code session"); + const textarea = textareas.at(-1); + expect(textarea).toBeTruthy(); + fireEvent.focus(textarea!); + fireEvent.change(textarea!, { target: { value: "/st", selectionStart: 3 } }); + + expect(await screen.findByText("/status")).toBeTruthy(); + }); + it("keeps the grid tiling tree stable when refreshed session objects keep the same ids", () => { const first = makeRunningSession("session-1", "pty-1"); const second = makeRunningSession("session-2", "pty-2"); diff --git a/apps/desktop/src/renderer/components/terminals/WorkViewArea.tsx b/apps/desktop/src/renderer/components/terminals/WorkViewArea.tsx index 82b765f58..a03cce8ca 100644 --- a/apps/desktop/src/renderer/components/terminals/WorkViewArea.tsx +++ b/apps/desktop/src/renderer/components/terminals/WorkViewArea.tsx @@ -1,4 +1,5 @@ -import { useMemo, useCallback, useEffect, useRef, useState } from "react"; +import { useMemo, useCallback, useEffect, useRef, useState, type CSSProperties } from "react"; +import { createPortal } from "react-dom"; import { AnimatePresence, motion } from "motion/react"; import { ArrowSquareOut, @@ -6,7 +7,6 @@ import { CaretRight, Chats, Check, - Clipboard, Code, Columns, Crosshair, @@ -15,28 +15,46 @@ import { GitBranch, GridFour, List, - Play, Plus, + PaperPlaneTilt, Rows, SidebarSimple, - Terminal, + SpinnerGap, X, } from "@phosphor-icons/react"; -import type { AgentChatSession, LaneLinearIssue, LaneSummary, TerminalSessionSummary } from "../../../shared/types"; +import type { + AgentChatModelInfo, + AgentChatPermissionMode, + AgentChatSession, + AgentChatSlashCommand, + ChatTerminalPreviewResult, + LaneLinearIssue, + LaneSummary, + TerminalResumeProvider, + TerminalSessionSummary, + TerminalSnapshotCell, + TerminalSnapshotRow, +} from "../../../shared/types"; +import { getRuntimeModelRefForDescriptor, resolveModelDescriptorForProvider } from "../../../shared/modelRegistry"; import type { WorkDraftKind, WorkViewMode } from "../../state/appStore"; import { TerminalView } from "./TerminalView"; import { ToolLogo } from "./ToolLogos"; import { LaneChip } from "./LaneChip"; import { AgentChatPane } from "../chat/AgentChatPane"; +import { ChatCommandMenu, handleCommandMenuKeyDown, type ChatCommandMenuHandle, type ChatCommandMenuItem } from "../chat/ChatCommandMenu"; +import { ChatComposerShell } from "../chat/ChatComposerShell"; +import { ProviderModelSelector } from "../shared/ProviderModelSelector"; +import { getPermissionOptions, safetyColors, type PermissionOption } from "../shared/permissionOptions"; import { WorkStartSurface } from "./WorkStartSurface"; -import { isChatToolType, primarySessionLabel, truncateSessionLabel, formatToolTypeLabel } from "../../lib/sessions"; +import { WorkCliSessionHeader } from "./WorkCliSessionHeader"; +import { isChatToolType, primarySessionLabel, stripTerminalLabelControls, truncateSessionLabel, formatToolTypeLabel } from "../../lib/sessions"; import { sessionStatusBucket, sessionStatusDot } from "../../lib/terminalAttention"; import type { WorkTabGroup } from "./useWorkSessions"; import { SmartTooltip } from "../ui/SmartTooltip"; import { useFloatingPaneEmbeddedChrome, type FloatingPaneEmbeddedChrome } from "../ui/FloatingPane"; import { PaneTilingLayout, type PaneConfig } from "../ui/PaneTilingLayout"; import { cn } from "../ui/cn"; -import { resolveTrackedCliResumeCommand, type LaunchProfile } from "./cliLaunch"; +import { launchProfileForTerminalSession, type LaunchProfile } from "./cliLaunch"; import { buildWorkSessionTilingTree, type TilingPreset } from "./workSessionTiling"; import { laneSurfaceTint } from "../lanes/laneDesignTokens"; @@ -60,6 +78,758 @@ function isRunningPtySession( ); } +function isAgentCliSession(session: TerminalSessionSummary): boolean { + return Boolean( + session.toolType + && session.toolType !== "shell" + && session.toolType !== "run-shell" + && !isChatToolType(session.toolType), + ); +} + +function stoppedBySignal(exitCode: number | null | undefined): boolean { + return exitCode === 130 || exitCode === 143; +} + +function terminalExitLabel(exitCode: number | null | undefined): string | null { + if (exitCode == null || exitCode === 0) return null; + return stoppedBySignal(exitCode) ? "Stopped" : `Exit ${exitCode}`; +} + +function stripTerminalControls(value: string): string { + return stripTerminalLabelControls(value).replace(/\r(?!\n)/g, "\n"); +} + +const XTERM_16_COLORS = [ + "#000000", + "#cd3131", + "#0dbc79", + "#e5e510", + "#2472c8", + "#bc3fbc", + "#11a8cd", + "#e5e5e5", + "#666666", + "#f14c4c", + "#23d18b", + "#f5f543", + "#3b8eea", + "#d670d6", + "#29b8db", + "#ffffff", +] as const; + +function rgbColor(value: number | null | undefined): string | undefined { + if (typeof value !== "number" || !Number.isFinite(value)) return undefined; + const safe = Math.max(0, Math.min(0xffffff, Math.floor(value))); + return `#${safe.toString(16).padStart(6, "0")}`; +} + +function paletteColor(value: number | null | undefined): string | undefined { + if (typeof value !== "number" || !Number.isFinite(value)) return undefined; + const index = Math.max(0, Math.min(255, Math.floor(value))); + if (index < XTERM_16_COLORS.length) return XTERM_16_COLORS[index]; + if (index >= 16 && index <= 231) { + const offset = index - 16; + const r = Math.floor(offset / 36); + const g = Math.floor((offset % 36) / 6); + const b = offset % 6; + const channel = (part: number) => part === 0 ? 0 : 55 + part * 40; + return rgbColor((channel(r) << 16) + (channel(g) << 8) + channel(b)); + } + const gray = 8 + (index - 232) * 10; + return rgbColor((gray << 16) + (gray << 8) + gray); +} + +function cellColor(mode: "default" | "palette" | "rgb", value: number | null): string | undefined { + if (mode === "rgb") return rgbColor(value); + if (mode === "palette") return paletteColor(value); + return undefined; +} + +function styleForSnapshotCell(cell: TerminalSnapshotCell): CSSProperties { + let color = cellColor(cell.fgMode, cell.fg); + let backgroundColor = cellColor(cell.bgMode, cell.bg); + if (cell.inverse) { + const nextColor = backgroundColor; + backgroundColor = color; + color = nextColor; + } + return { + ...(color ? { color } : {}), + ...(backgroundColor ? { backgroundColor } : {}), + ...(cell.bold ? { fontWeight: 700 } : {}), + ...(cell.dim ? { opacity: 0.65 } : {}), + ...(cell.italic ? { fontStyle: "italic" } : {}), + ...(cell.underline || cell.strikethrough + ? { textDecoration: [cell.underline ? "underline" : "", cell.strikethrough ? "line-through" : ""].filter(Boolean).join(" ") } + : {}), + }; +} + +function styleKey(style: CSSProperties): string { + return [ + style.color ?? "", + style.backgroundColor ?? "", + style.fontWeight ?? "", + style.opacity ?? "", + style.fontStyle ?? "", + style.textDecoration ?? "", + ].join("|"); +} + +function stableKeyHash(value: string): string { + let hash = 5381; + for (let index = 0; index < value.length; index += 1) { + hash = ((hash << 5) + hash) ^ value.charCodeAt(index); + } + return (hash >>> 0).toString(36); +} + +function withStableDuplicateKeys( + items: T[], + fingerprint: (item: T) => string, +): Array<{ item: T; key: string }> { + const counts = new Map(); + return items.map((item) => { + const base = stableKeyHash(fingerprint(item)); + const count = counts.get(base) ?? 0; + counts.set(base, count + 1); + return { item, key: count ? `${base}:${count}` : base }; + }); +} + +function isTrimmedBlankCell(cell: TerminalSnapshotCell | undefined): boolean { + return Boolean( + cell + && (cell.text || " ") === " " + && cell.bgMode === "default" + && !cell.inverse, + ); +} + +function snapshotRuns(row: TerminalSnapshotRow): Array<{ text: string; style: CSSProperties }> { + let cells = row.cells; + let end = cells.length; + while (end > 1 && isTrimmedBlankCell(cells[end - 1])) end -= 1; + cells = cells.slice(0, end); + + const runs: Array<{ text: string; style: CSSProperties }> = []; + for (const cell of cells) { + const text = cell.text || " "; + const style = styleForSnapshotCell(cell); + const last = runs[runs.length - 1]; + if (last && styleKey(last.style) === styleKey(style)) { + last.text += text; + } else { + runs.push({ text, style }); + } + } + if (!runs.length) return [{ text: row.text || " ", style: {} }]; + return runs; +} + +function TerminalSnapshotTranscript({ rows }: { rows: TerminalSnapshotRow[] }) { + const renderedRows = useMemo(() => withStableDuplicateKeys(rows, (row) => [ + row.text, + row.wrapped ? "wrapped" : "plain", + ...row.cells.map((cell) => [ + cell.text, + cell.fg, + cell.bg, + cell.fgMode, + cell.bgMode, + cell.bold ? "bold" : "", + cell.dim ? "dim" : "", + cell.italic ? "italic" : "", + cell.underline ? "underline" : "", + cell.inverse ? "inverse" : "", + cell.strikethrough ? "strikethrough" : "", + ].join("\u0001")), + ].join("\u0002")).map(({ item: row, key }) => ({ + key, + runs: withStableDuplicateKeys(snapshotRuns(row), (run) => `${run.text}\u0001${styleKey(run.style)}`), + })), [rows]); + + return ( +
+ {renderedRows.map((row) => ( +
+ {row.runs.map(({ item: run, key }) => ( + + {run.text} + + ))} +
+ ))} +
+ ); +} + +type CommandMenuAnchor = { top: number; left: number; bottom: number }; + +function getCommandMenuAnchor(element: HTMLElement | null): CommandMenuAnchor | null { + if (!element) return null; + const rect = element.getBoundingClientRect(); + return { top: rect.top, bottom: rect.bottom, left: rect.left + 16 }; +} + +function continuationProviderForSession(session: TerminalSessionSummary): TerminalResumeProvider | null { + const profile = launchProfileForTerminalSession(session); + return profile && profile !== "shell" ? profile : null; +} + +function canContinueAgentCliSession(session: TerminalSessionSummary): boolean { + return Boolean(session.tracked && continuationProviderForSession(session) && (session.resumeMetadata || session.resumeCommand)); +} + +function continuationProviderLabel(provider: TerminalResumeProvider | null): string { + if (provider === "claude") return "Claude Code"; + if (provider === "codex") return "Codex"; + if (provider === "cursor") return "Cursor Agent"; + if (provider === "droid") return "Droid"; + if (provider === "opencode") return "OpenCode"; + return "agent CLI"; +} + +function continuationSupportsModelSelection( + provider: TerminalResumeProvider | null, +): provider is "claude" | "codex" { + return provider === "claude" || provider === "codex"; +} + +function canonicalContinuationModelId(provider: "claude" | "codex" | null, modelId: string): string { + if (!provider) return modelId; + return resolveModelDescriptorForProvider(modelId, provider)?.id ?? modelId; +} + +function defaultContinuationModel(models: AgentChatModelInfo[], provider: "claude" | "codex" | null): string { + const modelId = models.find((model) => model.isDefault)?.id ?? models[0]?.id ?? ""; + return canonicalContinuationModelId(provider, modelId); +} + +type WorkCliContinuationOptions = { + model?: string | null; + reasoningEffort?: string | null; + permissionMode?: AgentChatPermissionMode | null; +}; + +function continuationPermissionFamily(provider: TerminalResumeProvider | null): string | null { + if (provider === "claude") return "anthropic"; + if (provider === "codex") return "openai"; + if (provider === "cursor") return "cursor"; + if (provider === "droid") return "factory"; + if (provider === "opencode") return "opencode"; + return null; +} + +function continuationPermissionOptions(provider: TerminalResumeProvider | null): PermissionOption[] { + const family = continuationPermissionFamily(provider); + return family ? getPermissionOptions({ family, isCliWrapped: true }) : []; +} + +function defaultContinuationPermissionMode(session: TerminalSessionSummary): AgentChatPermissionMode { + const raw = session.resumeMetadata?.launch?.permissionMode ?? session.resumeMetadata?.permissionMode ?? "default"; + const options = continuationPermissionOptions(continuationProviderForSession(session)); + return options.some((option) => option.value === raw) + ? raw + : options[0]?.value ?? "default"; +} + +function defaultContinuationReasoningEffort(tiers: readonly string[] | null | undefined): string | null { + if (!tiers?.length) return null; + for (const preferred of ["high", "medium", "low"]) { + if (tiers.includes(preferred)) return preferred; + } + return tiers[0] ?? null; +} + +function runtimeModelForContinuation(provider: "claude" | "codex", modelId: string): string { + const descriptor = resolveModelDescriptorForProvider(modelId, provider); + return descriptor ? getRuntimeModelRefForDescriptor(descriptor, provider) : modelId; +} + +function permissionSafetyDotClass(option: PermissionOption): string { + if (option.safety === "safe") return "bg-emerald-400/80"; + if (option.safety === "semi-auto") return "bg-amber-400/80"; + if (option.safety === "full-auto" || option.safety === "danger") return "bg-red-400/80"; + return "bg-violet-400/80"; +} + +function WorkCliPermissionPicker({ + provider, + value, + onChange, + disabled, +}: { + provider: TerminalResumeProvider | null; + value: AgentChatPermissionMode; + onChange: (mode: AgentChatPermissionMode) => void; + disabled?: boolean; +}) { + const options = useMemo(() => continuationPermissionOptions(provider), [provider]); + const selected = options.find((option) => option.value === value) ?? options[0] ?? null; + const providerLabel = continuationProviderLabel(provider); + const containerRef = useRef(null); + const [panelStyle, setPanelStyle] = useState(null); + const [open, setOpen] = useState(false); + const updatePanelStyle = useCallback(() => { + const rect = containerRef.current?.getBoundingClientRect(); + if (!rect) return; + const viewportPadding = 8; + const panelWidth = Math.min(352, Math.max(240, window.innerWidth - viewportPadding * 2)); + const availableBelow = window.innerHeight - rect.bottom - viewportPadding; + const availableAbove = rect.top - viewportPadding; + const openAbove = availableBelow < 180 && availableAbove > availableBelow; + const maxHeight = Math.max(160, Math.min(360, (openAbove ? availableAbove : availableBelow) - 6)); + const left = Math.min( + Math.max(viewportPadding, rect.right - panelWidth), + Math.max(viewportPadding, window.innerWidth - panelWidth - viewportPadding), + ); + const top = openAbove + ? Math.max(viewportPadding, rect.top - maxHeight - 6) + : Math.max(viewportPadding, Math.min(window.innerHeight - viewportPadding - maxHeight, rect.bottom + 6)); + setPanelStyle({ top, left, width: panelWidth, maxHeight }); + }, []); + + useEffect(() => { + if (!open) return; + updatePanelStyle(); + const handlePointerDown = (event: MouseEvent) => { + const target = event.target as Node; + if (containerRef.current?.contains(target)) return; + const panels = document.querySelectorAll("[data-cli-permission-picker-panel='true']"); + for (const panel of panels) { + if (panel.contains(target)) return; + } + setOpen(false); + }; + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape") setOpen(false); + }; + document.addEventListener("mousedown", handlePointerDown); + document.addEventListener("keydown", handleKeyDown); + window.addEventListener("resize", updatePanelStyle); + window.addEventListener("scroll", updatePanelStyle, true); + return () => { + document.removeEventListener("mousedown", handlePointerDown); + document.removeEventListener("keydown", handleKeyDown); + window.removeEventListener("resize", updatePanelStyle); + window.removeEventListener("scroll", updatePanelStyle, true); + }; + }, [open, updatePanelStyle]); + + useEffect(() => { + if (disabled && open) setOpen(false); + }, [disabled, open]); + + if (!selected) return null; + + const panel = createPortal( + + {open ? ( + + {options.map((option) => { + const optionColors = safetyColors(option.safety); + const active = option.value === selected.value; + return ( + + ); + })} + + ) : null} + , + document.body, + ); + + return ( +
+ + {panel} +
+ ); +} + +function WorkCliContinuationComposer({ + session, + onContinue, +}: { + session: TerminalSessionSummary; + onContinue?: (session: TerminalSessionSummary, text: string, options?: WorkCliContinuationOptions) => Promise | void; +}) { + const provider = continuationProviderForSession(session); + const providerLabel = continuationProviderLabel(provider); + const modelProvider = continuationSupportsModelSelection(provider) ? provider : null; + const textareaRef = useRef(null); + const commandMenuRef = useRef(null); + const [draft, setDraft] = useState(""); + const [slashCommands, setSlashCommands] = useState([]); + const [models, setModels] = useState([]); + const [selectedModel, setSelectedModel] = useState(""); + const [selectedReasoningEffort, setSelectedReasoningEffort] = useState(null); + const [selectedPermissionMode, setSelectedPermissionMode] = useState(() => defaultContinuationPermissionMode(session)); + const [modelsLoading, setModelsLoading] = useState(false); + const [commandMenuTrigger, setCommandMenuTrigger] = useState<{ type: "slash"; query: string; cursorIndex: number } | null>(null); + const [commandMenuAnchor, setCommandMenuAnchor] = useState(null); + const [sending, setSending] = useState(false); + const [submitError, setSubmitError] = useState(null); + const defaultPermissionMode = defaultContinuationPermissionMode(session); + const availableModelIds = useMemo( + () => models.map((model) => canonicalContinuationModelId(modelProvider, model.id)), + [modelProvider, models], + ); + + useEffect(() => { + let cancelled = false; + setSlashCommands([]); + if (!provider) return () => { + cancelled = true; + }; + void window.ade.agentChat.slashCommands({ laneId: session.laneId, provider }) + .then((commands) => { + if (!cancelled) { + setSlashCommands(commands.filter((command) => command.source !== "local")); + } + }) + .catch(() => { + if (!cancelled) setSlashCommands([]); + }); + return () => { + cancelled = true; + }; + }, [provider, session.laneId]); + + useEffect(() => { + let cancelled = false; + setModels([]); + setSelectedModel(""); + setSelectedReasoningEffort(null); + if (!modelProvider) return () => { + cancelled = true; + }; + setModelsLoading(true); + void window.ade.agentChat.models({ provider: modelProvider }) + .then((rows) => { + if (cancelled) return; + setModels(rows); + setSelectedModel((current) => ( + current && rows.some((model) => canonicalContinuationModelId(modelProvider, model.id) === current) + ? current + : defaultContinuationModel(rows, modelProvider) + )); + }) + .catch(() => { + if (!cancelled) { + setModels([]); + setSelectedModel(""); + } + }) + .finally(() => { + if (!cancelled) setModelsLoading(false); + }); + return () => { + cancelled = true; + }; + }, [modelProvider]); + + useEffect(() => { + setSelectedPermissionMode(defaultPermissionMode); + }, [defaultPermissionMode, session.id]); + + useEffect(() => { + if (!modelProvider || !selectedModel) { + setSelectedReasoningEffort(null); + return; + } + const descriptor = resolveModelDescriptorForProvider(selectedModel, modelProvider); + const tiers = descriptor?.reasoningTiers ?? []; + setSelectedReasoningEffort((current) => ( + current && tiers.includes(current) + ? current + : defaultContinuationReasoningEffort(tiers) + )); + }, [modelProvider, selectedModel]); + + const updateDraft = useCallback((next: string, element: HTMLTextAreaElement | null) => { + setDraft(next); + setSubmitError(null); + if (next.startsWith("/") && !next.slice(1).includes("\n")) { + const afterSlash = next.slice(1); + if (!/\s/.test(afterSlash)) { + const query = afterSlash.match(/^[^\s/]*/)?.[0] ?? ""; + setCommandMenuTrigger({ type: "slash", query, cursorIndex: 0 }); + const anchor = getCommandMenuAnchor(element); + if (anchor) setCommandMenuAnchor(anchor); + return; + } + } + setCommandMenuTrigger(null); + }, []); + + const handleCommandSelect = useCallback((item: ChatCommandMenuItem) => { + if (item.type !== "command") return; + const command = slashCommands.find((candidate) => candidate.name.replace(/^\//, "") === item.name); + const argumentHint = command?.argumentHint ? ` ${command.argumentHint}` : ""; + const next = `/${item.name}${argumentHint} `; + setDraft(next); + setCommandMenuTrigger(null); + requestAnimationFrame(() => textareaRef.current?.focus({ preventScroll: true })); + }, [slashCommands]); + + const submit = useCallback(async () => { + const text = draft.trim(); + if (!text || sending) return; + setSending(true); + setSubmitError(null); + try { + const options: WorkCliContinuationOptions = { + permissionMode: selectedPermissionMode, + }; + if (modelProvider && selectedModel) { + options.model = runtimeModelForContinuation(modelProvider, selectedModel); + } + if (selectedReasoningEffort) { + options.reasoningEffort = selectedReasoningEffort; + } + await onContinue?.(session, text, options); + setDraft(""); + setCommandMenuTrigger(null); + } catch (err) { + setSubmitError(err instanceof Error ? err.message : String(err)); + } finally { + setSending(false); + } + }, [draft, modelProvider, onContinue, selectedModel, selectedPermissionMode, selectedReasoningEffort, sending, session]); + + return ( +
+ +
+ {providerLabel} +
+ {modelProvider ? ( + ( + modelProvider === "claude" + ? model.family === "anthropic" && model.isCliWrapped + : model.family === "openai" && model.isCliWrapped + )} + compactToolbar + showReasoning + reasoningEffort={selectedReasoningEffort} + onReasoningEffortChange={setSelectedReasoningEffort} + /> + ) : null} + + +
+ )} + > + ({ + name: command.name.replace(/^\//, ""), + description: command.description, + argumentHint: command.argumentHint, + source: command.source, + }))} + sessionId={null} + anchor={commandMenuAnchor} + onSelect={handleCommandSelect} + onClose={() => setCommandMenuTrigger(null)} + /> +