diff --git a/README.md b/README.md index c0f2f26ab..81e22cd1d 100644 --- a/README.md +++ b/README.md @@ -128,7 +128,7 @@ ade actions list --text # discover every service action ## Architecture -Local-first, on purpose. The center of ADE is the **runtime daemon** — a single per-machine `ade` service that owns projects, lanes, chats, processes, sync, and proof artifacts. Desktop, the terminal client, the iOS app, and SSH-attached desktop windows all attach to it as clients. Runtime state lives under `.ade/` inside each project (SQLite db, worktree checkouts, proof artifacts, encrypted secrets) and the machine-wide socket lives under `~/.ade/sock/ade.sock`. +Local-first, on purpose. The center of ADE is the **runtime daemon** — a single per-machine `ade` service that owns projects, lanes, chats, processes, sync, and proof artifacts. Desktop, the terminal client, the iOS app, and SSH-attached desktop windows all attach to it as clients. Runtime state lives under `.ade/` inside each project (SQLite db, worktree checkouts, proof artifacts, encrypted secrets) and the machine-wide socket lives under `~/.ade/sock/ade.sock`. When desktop is running, its Electron main process also hosts a **bridge socket** at `~/.ade/sock/desktop-bridge.sock` (override: `ADE_DESKTOP_BRIDGE_SOCKET_PATH`) so the headless daemon can proxy `ade browser …` calls into the Electron-only `WebContentsView` APIs it can't reach under `ELECTRON_RUN_AS_NODE=1`. ```text apps/ade-cli ADE runtime daemon (`ade serve`) + `ade` CLI + `ade code` terminal client @@ -189,6 +189,7 @@ Override it when needed: npm run dev:desktop -- --socket /tmp/my-ade-dev.sock npm run dev:code -- --socket /tmp/my-ade-dev.sock ADE_DEV_RUNTIME_SOCKET_PATH=/tmp/my-ade-dev.sock npm run dev:runtime +ADE_DESKTOP_BRIDGE_SOCKET_PATH=/tmp/my-bridge.sock npm run dev:desktop ``` To test auto-runtime creation, use the `:auto`/default commands after stopping the dev runtime: diff --git a/apps/ade-cli/pnpm-workspace.yaml b/apps/ade-cli/pnpm-workspace.yaml index b000fdf61..9b5c7213c 100644 --- a/apps/ade-cli/pnpm-workspace.yaml +++ b/apps/ade-cli/pnpm-workspace.yaml @@ -1,4 +1,5 @@ allowBuilds: esbuild: set this to true or false node-pty: set this to true or false + opencode-ai: set this to true or false sqlite3: set this to true or false diff --git a/apps/ade-cli/src/bootstrap.ts b/apps/ade-cli/src/bootstrap.ts index 84ce72b50..f5e91680e 100644 --- a/apps/ade-cli/src/bootstrap.ts +++ b/apps/ade-cli/src/bootstrap.ts @@ -89,6 +89,11 @@ import { } from "../../desktop/src/main/services/appControl/appControlService"; import { createMacosVmService } from "../../desktop/src/main/services/macosVm/macosVmService"; import type { BuiltInBrowserService } from "../../desktop/src/main/services/builtInBrowser/builtInBrowserService"; +import { + createBuiltInBrowserDesktopBridgeClient, + type BuiltInBrowserDesktopBridgeClient, +} from "./services/builtInBrowser/desktopBridgeClient"; +import { resolveMachineAdeLayout } from "./services/projects/machineLayout"; import type { createFileService } from "../../desktop/src/main/services/files/fileService"; import type { AppNavigationRequest, AppNavigationResult, PortLease } from "../../desktop/src/shared/types"; import { @@ -841,6 +846,21 @@ export async function createAdeRuntime(args: { }), }); + // `built_in_browser` is hosted by the desktop's Electron main process (the + // browser pane owns a WebContentsView). The runtime daemon proxies calls + // through `/sock/desktop-bridge.sock`; if no desktop is running, + // individual calls fail clearly. Override the socket path with + // `ADE_DESKTOP_BRIDGE_SOCKET_PATH` for dev launches that use a non-default + // ADE home. + const builtInBrowserBridge: BuiltInBrowserDesktopBridgeClient | null = chatOnlyRuntime + ? null + : createBuiltInBrowserDesktopBridgeClient({ + socketPath: + process.env.ADE_DESKTOP_BRIDGE_SOCKET_PATH?.trim() + || resolveMachineAdeLayout().desktopBridgeSocketPath, + logger, + }); + const aiOrchestratorService = createAiOrchestratorService({ db, logger, @@ -1187,6 +1207,7 @@ export async function createAdeRuntime(args: { computerUseArtifactBrokerService, iosSimulatorService, appControlService, + builtInBrowserService: builtInBrowserBridge as unknown as BuiltInBrowserService | null, macosVmService, orchestratorService, aiOrchestratorService, @@ -1205,6 +1226,7 @@ export async function createAdeRuntime(args: { swallow(() => portAllocationService.dispose()); swallow(() => iosSimulatorService?.dispose()); swallow(() => appControlService?.dispose()); + swallow(() => builtInBrowserBridge?.dispose()); swallow(() => macosVmService?.dispose()); swallow(() => linearOAuthService.dispose()); swallow(() => headlessLinearServices.dispose()); diff --git a/apps/ade-cli/src/cli.ts b/apps/ade-cli/src/cli.ts index 0a8cc593b..ae55b1ce1 100644 --- a/apps/ade-cli/src/cli.ts +++ b/apps/ade-cli/src/cli.ts @@ -3521,7 +3521,6 @@ function buildPrPlan(args: string[]): CliPlan { status: "getStatus", files: "getFiles", "action-runs": "getActionRuns", - activity: "getActivity", reviews: "getReviews", threads: "getReviewThreads", deployments: "getDeployments", diff --git a/apps/ade-cli/src/multiProjectRpcServer.test.ts b/apps/ade-cli/src/multiProjectRpcServer.test.ts index 07a827335..c2a07ccf8 100644 --- a/apps/ade-cli/src/multiProjectRpcServer.test.ts +++ b/apps/ade-cli/src/multiProjectRpcServer.test.ts @@ -19,6 +19,7 @@ function createRegistry() { secretsDir: path.join(root, "home", "secrets"), sockDir: path.join(root, "home", "sock"), socketPath: path.join(root, "home", "sock", "ade.sock"), + desktopBridgeSocketPath: path.join(root, "home", "sock", "desktop-bridge.sock"), binDir: path.join(root, "home", "bin"), runtimeDir: path.join(root, "home", "runtime"), }); @@ -34,6 +35,9 @@ function makeRuntime(label: string) { laneService: { list: vi.fn(async () => [{ id: `${label}-lane`, name: label }]), }, + sessionService: { + get: vi.fn(() => null), + }, syncService: { getStatus: vi.fn(async () => ({ role: "brain", label })), }, diff --git a/apps/ade-cli/src/services/builtInBrowser/desktopBridgeClient.test.ts b/apps/ade-cli/src/services/builtInBrowser/desktopBridgeClient.test.ts new file mode 100644 index 000000000..d6df6f515 --- /dev/null +++ b/apps/ade-cli/src/services/builtInBrowser/desktopBridgeClient.test.ts @@ -0,0 +1,180 @@ +import fs from "node:fs"; +import net from "node:net"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import { + startJsonRpcServer, + type JsonRpcRequest, + type JsonRpcTransport, +} from "../../jsonrpc"; +import { createBuiltInBrowserDesktopBridgeClient } from "./desktopBridgeClient"; + +function silentLogger() { + return { + debug: () => {}, + info: () => {}, + warn: () => {}, + error: () => {}, + }; +} + +type ServerHandle = { + socketPath: string; + close: () => Promise; +}; + +async function startBridgeServer( + handler: (request: JsonRpcRequest) => Promise, +): Promise { + const socketPath = path.join( + fs.mkdtempSync(path.join(os.tmpdir(), "ade-bridge-test-")), + "bridge.sock", + ); + const stopHandles = new Set<() => void>(); + const sockets = new Set(); + const server = net.createServer((conn) => { + sockets.add(conn); + const transport: JsonRpcTransport = { + onData: (callback) => conn.on("data", callback), + write: (data) => conn.write(data), + close: () => { + if (!conn.destroyed) conn.destroy(); + }, + }; + const stop = startJsonRpcServer(handler, transport, { nonFatal: true }); + stopHandles.add(stop); + conn.on("close", () => { + sockets.delete(conn); + stopHandles.delete(stop); + stop(); + }); + conn.on("error", () => {}); + }); + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(socketPath, () => resolve()); + }); + return { + socketPath, + close: () => + new Promise((resolve) => { + for (const s of sockets) { + try { + s.destroy(); + } catch { + // ignore + } + } + for (const stop of stopHandles) { + try { + stop(); + } catch { + // ignore + } + } + server.close(() => { + try { + fs.unlinkSync(socketPath); + } catch { + // ignore + } + resolve(); + }); + }), + }; +} + +describe("createBuiltInBrowserDesktopBridgeClient", () => { + let server: ServerHandle | null = null; + + afterEach(async () => { + if (server) { + await server.close(); + server = null; + } + }); + + it("forwards method + params and resolves the JSON-RPC response", async () => { + const seen: JsonRpcRequest[] = []; + server = await startBridgeServer(async (request) => { + seen.push(request); + if (request.method === "built_in_browser.navigate") { + return { ok: true, url: (request.params as { url: string }).url }; + } + throw new Error(`unexpected method: ${request.method}`); + }); + const client = createBuiltInBrowserDesktopBridgeClient({ + socketPath: server.socketPath, + logger: silentLogger(), + }); + const result = await client.navigate({ url: "https://example.com" }); + expect(result).toEqual({ ok: true, url: "https://example.com" }); + expect(seen).toHaveLength(1); + expect(seen[0]?.method).toBe("built_in_browser.navigate"); + expect(seen[0]?.params).toEqual({ url: "https://example.com" }); + client.dispose(); + }); + + it("dispatches no-arg methods without params field", async () => { + const recorded: JsonRpcRequest[] = []; + server = await startBridgeServer(async (request) => { + recorded.push(request); + return { tabs: [] }; + }); + const client = createBuiltInBrowserDesktopBridgeClient({ + socketPath: server.socketPath, + logger: silentLogger(), + }); + await client.getStatus(); + expect(recorded[0]?.method).toBe("built_in_browser.getStatus"); + expect(recorded[0]?.params).toBeUndefined(); + client.dispose(); + }); + + it("surfaces a clear error when the bridge socket does not exist", async () => { + const missingPath = path.join( + fs.mkdtempSync(path.join(os.tmpdir(), "ade-bridge-test-missing-")), + "absent.sock", + ); + const client = createBuiltInBrowserDesktopBridgeClient({ + socketPath: missingPath, + logger: silentLogger(), + }); + await expect(client.getStatus()).rejects.toThrow( + /Desktop browser bridge not running/, + ); + client.dispose(); + }); + + it("propagates JSON-RPC server errors", async () => { + server = await startBridgeServer(async () => { + throw new Error("Browser pane is offline"); + }); + const client = createBuiltInBrowserDesktopBridgeClient({ + socketPath: server.socketPath, + logger: silentLogger(), + }); + await expect(client.getStatus()).rejects.toThrow(/Browser pane is offline/); + client.dispose(); + }); + + it("reconnects after a transient failure on the next call", async () => { + let callCount = 0; + server = await startBridgeServer(async () => { + callCount += 1; + if (callCount === 1) throw new Error("temporary"); + return { ok: true }; + }); + const client = createBuiltInBrowserDesktopBridgeClient({ + socketPath: server.socketPath, + logger: silentLogger(), + }); + await expect(client.getStatus()).rejects.toThrow(/temporary/); + const result = await client.getStatus(); + expect(result).toEqual({ ok: true }); + expect(callCount).toBe(2); + client.dispose(); + }); +}); diff --git a/apps/ade-cli/src/services/builtInBrowser/desktopBridgeClient.ts b/apps/ade-cli/src/services/builtInBrowser/desktopBridgeClient.ts new file mode 100644 index 000000000..68e67b898 --- /dev/null +++ b/apps/ade-cli/src/services/builtInBrowser/desktopBridgeClient.ts @@ -0,0 +1,168 @@ +import fs from "node:fs"; +import path from "node:path"; +import { JsonRpcClient } from "../../tuiClient/jsonRpcClient"; +import type { Logger } from "../../../../desktop/src/main/services/logging/logger"; + +/** + * Proxy `built_in_browser` service used by the runtime daemon. + * + * The real `BuiltInBrowserService` lives in the desktop's Electron main + * process because it owns the browser pane's `WebContentsView`. The runtime + * daemon runs under `ELECTRON_RUN_AS_NODE=1` and has no Electron APIs, so it + * cannot construct the service itself. Instead the desktop hosts a + * side-channel JSON-RPC socket at `/sock/desktop-bridge.sock` + * (see `MachineAdeLayout.desktopBridgeSocketPath`) and the daemon proxies + * `built_in_browser.` calls through this client. + * + * The connection is lazy. If no desktop is running, the first call surfaces + * a clear error ("Desktop browser bridge not running…") and the daemon stays + * functional for every other domain. Reconnection on next call is automatic + * when the desktop comes back. + */ + +const REQUEST_TIMEOUT_MS = 30_000; +const CONNECT_TIMEOUT_MS = 3_000; + +async function raceWithTimeout( + operation: Promise, + timeoutMs: number, + timeoutMessage: string, +): Promise { + let timeoutId: ReturnType | null = null; + const timeout = new Promise((_, reject) => { + timeoutId = setTimeout(() => reject(new Error(timeoutMessage)), timeoutMs); + }); + try { + return await Promise.race([operation, timeout]); + } finally { + if (timeoutId) clearTimeout(timeoutId); + } +} + +export type BuiltInBrowserDesktopBridgeClient = { + getStatus: (args?: unknown) => Promise; + showPanel: (args?: unknown) => Promise; + setBounds: (args: unknown) => Promise; + navigate: (args: unknown) => Promise; + createTab: (args?: unknown) => Promise; + switchTab: (args: unknown) => Promise; + closeTab: (args: unknown) => Promise; + reload: () => Promise; + goBack: () => Promise; + goForward: () => Promise; + stop: () => Promise; + startInspect: () => Promise; + stopInspect: () => Promise; + captureScreenshot: () => Promise; + selectPoint: (args: unknown) => Promise; + selectCurrent: () => Promise; + clearSelection: () => Promise; + dispose: () => void; +}; + +export function createBuiltInBrowserDesktopBridgeClient(args: { + socketPath: string; + logger: Logger; +}): BuiltInBrowserDesktopBridgeClient { + const { socketPath, logger } = args; + let client: JsonRpcClient | null = null; + let connecting: Promise | null = null; + let disposed = false; + + const isNamedPipe = socketPath.startsWith("\\\\"); + const socketDescription = path.basename(socketPath) || socketPath; + + async function connect(): Promise { + if (disposed) throw new Error("Desktop browser bridge client has been disposed."); + if (!isNamedPipe && !fs.existsSync(socketPath)) { + throw new Error( + `Desktop browser bridge not running at ${socketPath}. Open ADE Desktop with a project to enable \`ade browser\` commands.`, + ); + } + return await raceWithTimeout( + JsonRpcClient.connect(socketPath), + CONNECT_TIMEOUT_MS, + `Timed out connecting to desktop browser bridge at ${socketDescription}.`, + ); + } + + async function ensureClient(): Promise { + if (disposed) throw new Error("Desktop browser bridge client has been disposed."); + if (client) return client; + if (!connecting) { + connecting = connect() + .then((c) => { + if (disposed) { + try { + c.close(); + } catch { + // ignore + } + throw new Error("Desktop browser bridge client has been disposed."); + } + client = c; + return c; + }) + .finally(() => { + connecting = null; + }); + } + return await connecting; + } + + function drop(reason?: unknown): void { + if (client) { + try { + client.close(); + } catch { + // ignore + } + client = null; + } + if (reason) { + logger.warn("built_in_browser_bridge.connection_dropped", { + socketPath, + reason: reason instanceof Error ? reason.message : String(reason), + }); + } + } + + async function callBridge(method: string, params?: unknown): Promise { + const c = await ensureClient(); + try { + return await raceWithTimeout( + c.request(`built_in_browser.${method}`, params), + REQUEST_TIMEOUT_MS, + `Desktop browser bridge call ${method} timed out after ${REQUEST_TIMEOUT_MS}ms.`, + ); + } catch (error) { + // Drop the connection on any error so the next call reconnects. + drop(error); + throw error; + } + } + + return { + getStatus: (args) => callBridge("getStatus", args), + showPanel: (args) => callBridge("showPanel", args), + setBounds: (args) => callBridge("setBounds", args), + navigate: (args) => callBridge("navigate", args), + createTab: (args) => callBridge("createTab", args), + switchTab: (args) => callBridge("switchTab", args), + closeTab: (args) => callBridge("closeTab", args), + reload: () => callBridge("reload"), + goBack: () => callBridge("goBack"), + goForward: () => callBridge("goForward"), + stop: () => callBridge("stop"), + startInspect: () => callBridge("startInspect"), + stopInspect: () => callBridge("stopInspect"), + captureScreenshot: () => callBridge("captureScreenshot"), + selectPoint: (args) => callBridge("selectPoint", args), + selectCurrent: () => callBridge("selectCurrent"), + clearSelection: () => callBridge("clearSelection"), + dispose: () => { + disposed = true; + drop(); + }, + }; +} diff --git a/apps/ade-cli/src/services/projects/machineLayout.test.ts b/apps/ade-cli/src/services/projects/machineLayout.test.ts index 9d81bb473..5e033a95e 100644 --- a/apps/ade-cli/src/services/projects/machineLayout.test.ts +++ b/apps/ade-cli/src/services/projects/machineLayout.test.ts @@ -25,4 +25,30 @@ describe("resolveMachineAdeLayout", () => { expect(alpha.socketPath).toBe("\\\\.\\pipe\\ade-runtime-ade-alpha"); expect(beta.socketPath).toBe("\\\\.\\pipe\\ade-runtime-ade-beta"); }); + + it("derives the desktop-bridge socket from the ADE home", () => { + const stable = resolveMachineAdeLayout( + { ADE_HOME: "/Users/arul/.ade" }, + "darwin", + ); + const beta = resolveMachineAdeLayout( + { ADE_HOME: "/Users/arul/.ade-beta" }, + "darwin", + ); + expect(stable.desktopBridgeSocketPath).toBe("/Users/arul/.ade/sock/desktop-bridge.sock"); + expect(beta.desktopBridgeSocketPath).toBe("/Users/arul/.ade-beta/sock/desktop-bridge.sock"); + }); + + it("uses distinct Windows desktop-bridge pipes for channel ADE homes", () => { + const stable = resolveMachineAdeLayout( + { ADE_HOME: "/Users/arul/.ade" }, + "win32", + ); + const beta = resolveMachineAdeLayout( + { ADE_HOME: "/Users/arul/.ade-beta" }, + "win32", + ); + expect(stable.desktopBridgeSocketPath).toBe("\\\\.\\pipe\\ade-desktop-bridge"); + expect(beta.desktopBridgeSocketPath).toBe("\\\\.\\pipe\\ade-desktop-bridge-ade-beta"); + }); }); diff --git a/apps/ade-cli/src/services/projects/machineLayout.ts b/apps/ade-cli/src/services/projects/machineLayout.ts index 2790962eb..22583a61d 100644 --- a/apps/ade-cli/src/services/projects/machineLayout.ts +++ b/apps/ade-cli/src/services/projects/machineLayout.ts @@ -7,6 +7,14 @@ export type MachineAdeLayout = { secretsDir: string; sockDir: string; socketPath: string; + /** + * Side-channel JSON-RPC socket for Electron-main-only domains + * (currently `built_in_browser`). Hosted by the desktop main process; the + * runtime daemon proxies calls through here when present. The runtime daemon + * cannot host these domains itself because they need Electron APIs + * (WebContentsView, etc.) that aren't available under ELECTRON_RUN_AS_NODE. + */ + desktopBridgeSocketPath: string; binDir: string; runtimeDir: string; }; @@ -23,6 +31,12 @@ function windowsPipePathForAdeDir(adeDir: string): string { return `\\\\.\\pipe\\ade-runtime-${homeName.replace(/^-+/, "")}`; } +function windowsDesktopBridgePipePathForAdeDir(adeDir: string): string { + const homeName = path.basename(adeDir).replace(/[^a-zA-Z0-9_-]+/g, "-"); + if (!homeName || homeName === "-ade") return "\\\\.\\pipe\\ade-desktop-bridge"; + return `\\\\.\\pipe\\ade-desktop-bridge-${homeName.replace(/^-+/, "")}`; +} + export function resolveMachineAdeLayout( env: NodeJS.ProcessEnv = process.env, platform: NodeJS.Platform = process.platform, @@ -33,12 +47,16 @@ export function resolveMachineAdeLayout( const socketPath = platform === "win32" ? windowsPipePathForAdeDir(adeDir) : path.join(sockDir, "ade.sock"); + const desktopBridgeSocketPath = platform === "win32" + ? windowsDesktopBridgePipePathForAdeDir(adeDir) + : path.join(sockDir, "desktop-bridge.sock"); return { adeDir, projectsPath: path.join(adeDir, "projects.json"), secretsDir, sockDir, socketPath, + desktopBridgeSocketPath, binDir: path.join(adeDir, "bin"), runtimeDir: path.join(adeDir, "runtime"), }; diff --git a/apps/ade-cli/src/services/projects/projectRegistry.test.ts b/apps/ade-cli/src/services/projects/projectRegistry.test.ts index 6d081cb03..57af6e4c3 100644 --- a/apps/ade-cli/src/services/projects/projectRegistry.test.ts +++ b/apps/ade-cli/src/services/projects/projectRegistry.test.ts @@ -34,6 +34,7 @@ describe("ProjectRegistry", () => { secretsDir: path.join(homeDir, ".ade-runtime", "secrets"), sockDir: path.join(homeDir, ".ade-runtime", "sock"), socketPath: path.join(homeDir, ".ade-runtime", "sock", "ade.sock"), + desktopBridgeSocketPath: path.join(homeDir, ".ade-runtime", "sock", "desktop-bridge.sock"), binDir: path.join(homeDir, ".ade-runtime", "bin"), runtimeDir: path.join(homeDir, ".ade-runtime", "runtime"), }); @@ -76,6 +77,7 @@ describe("ProjectRegistry", () => { secretsDir: path.join(registryDir, "secrets"), sockDir: path.join(registryDir, "sock"), socketPath: path.join(registryDir, "sock", "ade.sock"), + desktopBridgeSocketPath: path.join(registryDir, "sock", "desktop-bridge.sock"), binDir: path.join(registryDir, "bin"), runtimeDir: path.join(registryDir, "runtime"), }); @@ -99,6 +101,7 @@ describe("ProjectRegistry", () => { secretsDir: path.join(registryDir, "secrets"), sockDir: path.join(registryDir, "sock"), socketPath: path.join(registryDir, "sock", "ade.sock"), + desktopBridgeSocketPath: path.join(registryDir, "sock", "desktop-bridge.sock"), binDir: path.join(registryDir, "bin"), runtimeDir: path.join(registryDir, "runtime"), }); diff --git a/apps/ade-cli/src/services/projects/projectScope.test.ts b/apps/ade-cli/src/services/projects/projectScope.test.ts index ad76c4a92..1fe3b84b6 100644 --- a/apps/ade-cli/src/services/projects/projectScope.test.ts +++ b/apps/ade-cli/src/services/projects/projectScope.test.ts @@ -25,6 +25,7 @@ function createRegistry() { secretsDir: path.join(root, "home", "secrets"), sockDir: path.join(root, "home", "sock"), socketPath: path.join(root, "home", "sock", "ade.sock"), + desktopBridgeSocketPath: path.join(root, "home", "sock", "desktop-bridge.sock"), binDir: path.join(root, "home", "bin"), runtimeDir: path.join(root, "home", "runtime"), }); diff --git a/apps/ade-cli/src/tuiClient/__tests__/Drawer.test.tsx b/apps/ade-cli/src/tuiClient/__tests__/Drawer.test.tsx index cc47a2094..df71f64b5 100644 --- a/apps/ade-cli/src/tuiClient/__tests__/Drawer.test.tsx +++ b/apps/ade-cli/src/tuiClient/__tests__/Drawer.test.tsx @@ -42,16 +42,22 @@ afterEach(() => { }); describe("Drawer diff stats", () => { - it("renders per-lane diff stats from line stats, not ahead/behind commits", () => { + it("renders the selected lane's diff stats from line stats, not ahead/behind, and hides others", () => { vi.useFakeTimers(); vi.setSystemTime(new Date("2026-05-12T12:00:00.000Z")); - const frame = stripAnsi(render( + const lanes = [ + lane("lane-1", "TUI", "feat", "2026-05-12T11:55:00.000Z", 99, 88), + lane("lane-2", "Drawer", "draw", "2026-05-12T11:57:00.000Z", 42, 24), + ]; + const diffByLaneId = { + "lane-1": { additions: 64, deletions: 18, files: 5 }, + "lane-2": { additions: 428, deletions: 112, files: 6 }, + }; + + const frameSelectFirst = stripAnsi(render( { selectedLaneIndex={0} selectedChatIndex={-1} panelHeight={30} - diffByLaneId={{ - "lane-1": { additions: 64, deletions: 18, files: 5 }, - "lane-2": { additions: 428, deletions: 112, files: 6 }, - }} + diffByLaneId={diffByLaneId} + />, + ).lastFrame() ?? ""); + + // lane-1 is selected → its diff renders; lane-2 stays hidden. + expect(frameSelectFirst).toContain("+64"); + expect(frameSelectFirst).toContain("−18"); + expect(frameSelectFirst).toContain("5m"); + expect(frameSelectFirst).not.toContain("+428"); + expect(frameSelectFirst).not.toContain("−112"); + expect(frameSelectFirst).not.toContain("+492"); + expect(frameSelectFirst).not.toContain("−130"); + expect(frameSelectFirst).not.toContain("+141 / −112"); + expect(frameSelectFirst).not.toContain("-18"); + + const frameSelectSecond = stripAnsi(render( + , ).lastFrame() ?? ""); - expect(frame).toContain("+64"); - expect(frame).toContain("−18"); - expect(frame).toContain("5m"); - expect(frame).toContain("+428"); - expect(frame).toContain("−112"); - expect(frame).not.toContain("+492"); - expect(frame).not.toContain("−130"); - expect(frame).not.toContain("+141 / −112"); - expect(frame).not.toContain("-18"); + // lane-2 selected → its diff shows; lane-1 stays hidden. + expect(frameSelectSecond).toContain("+428"); + expect(frameSelectSecond).toContain("−112"); + expect(frameSelectSecond).not.toContain("+64"); + expect(frameSelectSecond).not.toContain("−18"); }); }); @@ -141,9 +165,18 @@ describe("Drawer lane and chat navigation layout", () => { expect(laneModeFrame).toContain("First chat"); expect(laneModeFrame).toContain("enter chats"); + expect(laneModeFrame).not.toContain("││"); expect(chatModeFrame).toContain("CHATS · 1"); expect(chatModeFrame.indexOf("First chat")).toBeLessThan(chatModeFrame.indexOf("+ new chat")); - expect(chatModeFrame).toContain("lane card"); + expect(chatModeFrame).not.toContain("││"); + // Chats-mode footer now hints at how to escape the chat list since arrows + // no longer pop back into lanes on their own. + expect(chatModeFrame).toContain("esc lanes"); + expect(chatModeFrame).toContain("select chat"); + // Old "lane card" / "next lane" hints are gone now that arrows stay in + // chats. + expect(chatModeFrame).not.toContain("lane card"); + expect(chatModeFrame).not.toContain("next lane"); }); it("does not offer a new chat action for a missing lane worktree", () => { diff --git a/apps/ade-cli/src/tuiClient/__tests__/RightPane.test.tsx b/apps/ade-cli/src/tuiClient/__tests__/RightPane.test.tsx index de5a29bff..fb5635236 100644 --- a/apps/ade-cli/src/tuiClient/__tests__/RightPane.test.tsx +++ b/apps/ade-cli/src/tuiClient/__tests__/RightPane.test.tsx @@ -1,7 +1,7 @@ import React from "react"; import { describe, expect, it } from "vitest"; import { render } from "ink-testing-library"; -import { RightPane } from "../components/RightPane"; +import { LANE_DETAIL_ACTIONS, LANE_DETAIL_PR_ACTION_INDEX, RightPane } from "../components/RightPane"; import type { LaneSummary } from "../../../../desktop/src/shared/types/lanes"; function stripAnsi(text: string): string { @@ -160,6 +160,40 @@ describe("RightPane chat info", () => { expect(frame).toMatch(/↑\s+\d+\s+earlier/); expect(frame).toContain("agent-07"); }); + + it("separates foreground subagents from background tasks with section headers", () => { + const result = render( + , + ); + const frame = stripAnsi(result.lastFrame() ?? ""); + + expect(frame).toContain("subagents"); + expect(frame).toContain("background"); + const exploreIndex = frame.indexOf("explore-code"); + const backgroundIndex = frame.indexOf("background"); + const desktopIndex = frame.indexOf("dev:desktop"); + expect(exploreIndex).toBeGreaterThanOrEqual(0); + expect(backgroundIndex).toBeGreaterThanOrEqual(0); + expect(desktopIndex).toBeGreaterThanOrEqual(0); + expect(exploreIndex).toBeLessThan(backgroundIndex); + expect(backgroundIndex).toBeLessThan(desktopIndex); + // bg count shows up in the header + expect(frame).toMatch(/2\s+bg/); + }); }); function lane(overrides: Partial = {}): LaneSummary { @@ -294,7 +328,7 @@ describe("RightPane lane-details", () => { content={{ kind: "lane-details", ...baseLaneDetails, - selectedActionIndex: 0, + selectedActionIndex: LANE_DETAIL_ACTIONS.findIndex((action) => action.k === "a"), }} focused width={80} @@ -303,10 +337,11 @@ describe("RightPane lane-details", () => { const frame = stripAnsi(result.lastFrame() ?? ""); expect(frame).toContain("ACTIONS"); - expect(frame).toMatch(/\[a\]\s*stage all/); + // Selected row renders as: "▎ [a] {glyph} stage all" — allow the glyph between key and label. + expect(frame).toMatch(/\[a\]\s+\S+\s+stage all/); expect(frame).toContain("commit"); - expect(frame).not.toMatch(/\[c\]\s*commit/); - expect(frame).not.toMatch(/\[p\]\s*push/); + expect(frame).not.toMatch(/\[c\][^\n]*commit/); + expect(frame).not.toMatch(/\[p\][^\n]*push/); }); it("renders PR activity and chat counts", () => { @@ -346,7 +381,7 @@ describe("RightPane lane-details", () => { content={{ kind: "lane-details", ...baseLaneDetails, - selectedActionIndex: 5, + selectedActionIndex: LANE_DETAIL_PR_ACTION_INDEX, pr: { number: 311, state: "open", @@ -389,3 +424,99 @@ describe("RightPane lane-details", () => { expect(frame).not.toContain("push"); }); }); + +describe("RightPane setup panes", () => { + it("renders lane delete as a real preflight with scope, remote, force, and confirmation rows", () => { + const result = render( + , + ); + const frame = stripAnsi(result.lastFrame() ?? ""); + + expect(frame).toContain("DELETE LANE"); + expect(frame).toContain("Destructive action"); + expect(frame).toContain("scratch-delete-tui"); + expect(frame).toContain("uncommitted changes detected"); + expect(frame).toContain("[remote]"); + expect(frame).toContain("also delete origin/ade/scratch-delete-tui"); + expect(frame).toContain("Remote name"); + expect(frame).toContain("origin"); + expect(frame).toContain("[x] skip safety checks"); + expect(frame).toContain("enter deletes this lane"); + }); + + it("renders model setup rows and selected row detail", () => { + const result = render( + , + ); + const frame = stripAnsi(result.lastFrame() ?? ""); + + expect(frame).toContain("MODEL"); + expect(frame).toContain("Provider: Claude"); + expect(frame).toContain("Model: Sonnet"); + expect(frame).toContain("Reasoning: high"); + expect(frame).toContain("low, medium, high"); + expect(frame).toContain("Permissions: auto"); + expect(frame).toContain("↑↓ rows · ←→ change · ↵ apply · esc close"); + }); +}); + +describe("RightPane details", () => { + it("keeps the title visible for long details bodies", () => { + const result = render( + `line ${index + 1}`).join("\n"), + }} + focused + width={80} + />, + ); + const frame = stripAnsi(result.lastFrame() ?? ""); + + expect(frame).toContain("SKILLS"); + expect(frame).toContain("line 1"); + expect(frame).toContain("… 14 more lines"); + }); +}); diff --git a/apps/ade-cli/src/tuiClient/__tests__/TerminalPane.test.tsx b/apps/ade-cli/src/tuiClient/__tests__/TerminalPane.test.tsx index e9843cb6e..6c56c9325 100644 --- a/apps/ade-cli/src/tuiClient/__tests__/TerminalPane.test.tsx +++ b/apps/ade-cli/src/tuiClient/__tests__/TerminalPane.test.tsx @@ -188,4 +188,156 @@ describe("TerminalPane", () => { expect(frame).not.toContain("\u001b7"); expect(frame).not.toContain("\u001b8"); }); + + it("filters Claude spinner residue from closed transcript previews", async () => { + const result = render( + , + ); + await new Promise((resolve) => setTimeout(resolve, 0)); + const frame = stripAnsi(result.lastFrame() ?? ""); + + expect(frame).toContain("final answer"); + expect(frame).not.toContain("✻"); + expect(frame).not.toContain("✶8"); + expect(frame).not.toContain("Cogitated"); + expect(frame).not.toContain("2.2k tokens"); + }); + + it("filters numeric Claude spinner residue bursts from closed transcript previews", async () => { + const result = render( + , + ); + await new Promise((resolve) => setTimeout(resolve, 0)); + const frame = stripAnsi(result.lastFrame() ?? ""); + + expect(frame).toContain("final answer"); + expect(frame).toContain("follow-up detail"); + expect(frame).not.toContain("\n8"); + expect(frame).not.toContain("\n40"); + }); + + it("filters Claude input box chrome from closed transcript previews", async () => { + const result = render( + summarize this lane │", + "╰────────────────────────╯", + "final answer", + "╭────────────────────────╮", + "│ │", + "╰────────────────────────╯", + "esc to interrupt", + ].join("\n"), + status: "completed", + runtimeState: "exited", + })} + liveChunks={[]} + attached={false} + width={80} + height={8} + hiddenBottomRows={2} + />, + ); + await new Promise((resolve) => setTimeout(resolve, 0)); + const frame = stripAnsi(result.lastFrame() ?? ""); + + expect(frame).toContain("final answer"); + expect(frame).not.toContain("╭"); + expect(frame).not.toContain("summarize this lane"); + expect(frame).not.toContain("esc to interrupt"); + }); + + it("filters Claude startup chrome and spinner fragments from closed transcripts", async () => { + const result = render( + , + ); + await new Promise((resolve) => setTimeout(resolve, 0)); + const frame = stripAnsi(result.lastFrame() ?? ""); + + expect(frame).toContain("say ok then stop"); + expect(frame).toContain("ok"); + expect(frame).not.toContain("Claude Code v"); + expect(frame).not.toContain("What's new"); + expect(frame).not.toContain("Welcome back"); + expect(frame).not.toContain("Statusline JSON"); + expect(frame).not.toContain("Claude Max"); + expect(frame).not.toContain("sayokthenstop"); + expect(frame).not.toContain("Orbiting"); + expect(frame).not.toContain("Orit"); + expect(frame).not.toContain("Churned"); + expect(frame).not.toContain("MCP server failed"); + expect(frame).not.toContain("Resume this session"); + }); }); diff --git a/apps/ade-cli/src/tuiClient/__tests__/adeApi.test.ts b/apps/ade-cli/src/tuiClient/__tests__/adeApi.test.ts index 168309532..75d4da812 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, signalTerminal, steerChatMessage } from "../adeApi"; +import { cancelSteerMessage, createChatSession, DEFAULT_CODEX_REASONING_EFFORT, dispatchSteerMessage, discoverProjectSlashCommands, editSteerMessage, latestGoal, latestTokenStats, listLaneDiffStats, listPrsByLane, sendChatMessage, signalTerminal, startClaudeTerminalSession, steerChatMessage } from "../adeApi"; import type { AdeCodeConnection } from "../types"; const tmpPaths: string[] = []; @@ -313,6 +313,52 @@ describe("createChatSession", () => { }); }); +describe("startClaudeTerminalSession", () => { + it("passes Claude model reasoning and permission controls to start_cli_session", async () => { + const calls: Array<{ name: string; args?: Record }> = []; + const connection = { + tool: async (name: string, args?: Record) => { + calls.push({ name, args }); + return { + sessionId: "term-1", + terminalId: "term-1", + session: null, + }; + }, + } as unknown as AdeCodeConnection; + + await startClaudeTerminalSession({ + connection, + laneId: "lane-1", + title: "Claude smoke", + model: "anthropic/claude-sonnet-4-6", + reasoningEffort: "low", + permissionMode: "auto", + initialInput: "Hello", + cols: 100, + rows: 28, + }); + + expect(calls).toEqual([ + { + name: "start_cli_session", + args: expect.objectContaining({ + laneId: "lane-1", + provider: "claude", + title: "Claude smoke", + model: "anthropic/claude-sonnet-4-6", + reasoningEffort: "low", + permissionMode: "auto", + initialInput: "Hello", + cols: 100, + rows: 28, + tracked: true, + }), + }, + ]); + }); +}); + describe("listPrsByLane", () => { it("passes through the bulk PR lane action", async () => { const calls: Array<{ domain: string; action: string; args?: Record }> = []; diff --git a/apps/ade-cli/src/tuiClient/__tests__/appInput.test.ts b/apps/ade-cli/src/tuiClient/__tests__/appInput.test.ts index 72bc12e3c..32ecd1b73 100644 --- a/apps/ade-cli/src/tuiClient/__tests__/appInput.test.ts +++ b/apps/ade-cli/src/tuiClient/__tests__/appInput.test.ts @@ -1,28 +1,67 @@ import { describe, expect, it } from "vitest"; import { clampChatScrollOffsetRows, + cycleLaneDeleteScope, deletePreviousPromptLine, deletePreviousPromptWord, + drawerMouseHitForLine, encodeTerminalPromptSubmit, footerControlsForAvailability, + formFieldIndexForMouseLine, + formFieldUsesPromptInput, + isChatSessionAnimating, isPromptLineBackspace, isPromptWordBackspace, + isTerminalSessionFastPollActive, + isTerminalSessionWorking, isTerminalControlToggle, isTerminalMouseTrackingEnabled, isChatTextSelectionRange, isCtrlCCopyPlatform, + isCtrlInput, chatSelectionEdgeDirectionForMouseY, chatSelectionFromAnchor, chatSelectionPointFromVisibleRows, moveChatSelectionFocusByRows, parseTerminalMouseInput, promptDisplayRows, + promptHitLine, + laneDetailsActionIndexForMouseLine, + modelPickerSurfaceForSetupPane, + resolveContextDefault, + resolveDrawerPaneWidth, + resolveModelPickerEscape, resolveChatWrapWidth, resolveTerminalPaneWidth, + setupPaneRowIndexForMouseLine, splitTerminalControlInput, subagentSnapshotsFromEvents, } from "../app"; import { clampTerminalPaneCols } from "../components/TerminalPane"; +import type { ChatInfoSnapshot } from "../types"; +import type { LaneSummary } from "../../../../desktop/src/shared/types/lanes"; + +describe("session activity helpers", () => { + it("does not animate idle or input-blocked chat sessions", () => { + expect(isChatSessionAnimating({ status: "active", awaitingInput: false, idleSinceAt: null })).toBe(true); + expect(isChatSessionAnimating({ status: "active", awaitingInput: true, idleSinceAt: null })).toBe(false); + expect(isChatSessionAnimating({ status: "active", awaitingInput: false, idleSinceAt: "2026-05-20T07:00:00.000Z" })).toBe(false); + expect(isChatSessionAnimating({ status: "idle", awaitingInput: false, idleSinceAt: null })).toBe(false); + }); + + it("does not animate an idle terminal process but keeps fast polling while it is busy", () => { + expect(isTerminalSessionWorking({ status: "running", runtimeState: "running", pid: process.pid })).toBe(true); + expect(isTerminalSessionWorking({ status: "running", runtimeState: "running", pid: null })).toBe(false); + expect(isTerminalSessionWorking({ status: "running", runtimeState: "idle", pid: process.pid })).toBe(false); + expect(isTerminalSessionWorking({ status: "running", runtimeState: "waiting-input", pid: process.pid })).toBe(false); + + expect(isTerminalSessionFastPollActive({ status: "running", runtimeState: "running", pid: process.pid })).toBe(true); + expect(isTerminalSessionFastPollActive({ status: "running", runtimeState: "waiting-input", pid: process.pid })).toBe(true); + expect(isTerminalSessionFastPollActive({ status: "running", runtimeState: "running", pid: null })).toBe(false); + expect(isTerminalSessionFastPollActive({ status: "running", runtimeState: "idle", pid: process.pid })).toBe(false); + expect(isTerminalSessionFastPollActive({ status: "completed", runtimeState: "exited", pid: process.pid })).toBe(false); + }); +}); describe("parseTerminalMouseInput", () => { it("parses SGR mouse wheel events from Ink input", () => { @@ -130,6 +169,189 @@ describe("parseTerminalMouseInput", () => { }); }); +describe("control input normalization", () => { + it("matches both Ink ctrl metadata and raw terminal control bytes", () => { + expect(isCtrlInput("o", { ctrl: true }, "o")).toBe(true); + expect(isCtrlInput("\x0f", {}, "o")).toBe(true); + expect(isCtrlInput("\x10", {}, "p")).toBe(true); + expect(isCtrlInput("o", { ctrl: true, meta: true }, "o")).toBe(false); + expect(isCtrlInput("x", {}, "o")).toBe(false); + }); +}); + +describe("lane delete form helpers", () => { + it("cycles lane delete scope through visible destructive choices", () => { + expect(cycleLaneDeleteScope("worktree", 1)).toBe("local_branch"); + expect(cycleLaneDeleteScope("local_branch", 1)).toBe("remote_branch"); + expect(cycleLaneDeleteScope("remote_branch", 1)).toBe("worktree"); + expect(cycleLaneDeleteScope("worktree", -1)).toBe("remote_branch"); + expect(cycleLaneDeleteScope("nonsense", 1)).toBe("local_branch"); + }); + + it("does not treat lane-delete select and toggle rows as prompt text", () => { + expect(formFieldUsesPromptInput("lane-delete", "scope")).toBe(false); + expect(formFieldUsesPromptInput("lane-delete", "force")).toBe(false); + expect(formFieldUsesPromptInput("lane-delete", "confirm")).toBe(true); + expect(formFieldUsesPromptInput("feedback", "body")).toBe(true); + }); +}); + +describe("right pane context defaults", () => { + function laneForContext(overrides: Partial = {}): LaneSummary { + return { + id: "lane-1", + name: "Lane one", + laneType: "worktree", + baseRef: "main", + branchRef: "feature/lane-one", + worktreePath: "/tmp/lane-one", + 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-20T00:00:00.000Z", + ...overrides, + }; + } + + function chatInfoForContext(): ChatInfoSnapshot { + return { + provider: "claude", + modelLabel: "Claude Sonnet 4.6", + laneLabel: "Lane one", + contextPercent: null, + tokenSummary: null, + goal: null, + plan: { current: 0, total: 0, live: false, steps: [] }, + snapshots: [], + inspectedSubagentId: null, + streaming: false, + }; + } + + it("keeps the new-chat setup pane ahead of stale lane drawer highlights", () => { + const lane = laneForContext(); + const pane = resolveContextDefault({ + draftChatActive: true, + activeSession: null, + activeLane: lane, + liveAgentCount: 0, + highlightedDrawerLane: lane, + drawerMode: "lanes", + drawerNav: null, + chatInfo: chatInfoForContext(), + subagentSnapshots: [], + provider: "claude", + newChatSetup: { + laneId: lane.id, + laneLabel: lane.name, + rows: [{ kind: "model", label: "Model", value: "Claude Sonnet 4.6", cyclable: true }], + }, + unavailableLaneIds: new Set(), + }); + + expect(pane).toMatchObject({ + kind: "new-chat-setup", + laneId: "lane-1", + laneLabel: "Lane one", + }); + }); +}); + +describe("model setup picker routing", () => { + it("opens the rich picker against the current chat from /model or /effort setup panes", () => { + expect(modelPickerSurfaceForSetupPane("model-setup")).toBe("chat"); + }); + + it("keeps the new-chat picker scoped to the draft setup pane", () => { + expect(modelPickerSurfaceForSetupPane("new-chat-setup")).toBe("new-chat"); + }); +}); + +describe("drawer mouse hit testing", () => { + it("widens the drawer responsively on larger terminals", () => { + expect(resolveDrawerPaneWidth(100, false)).toBe(0); + expect(resolveDrawerPaneWidth(100, true)).toBe(32); + expect(resolveDrawerPaneWidth(160, true)).toBe(38); + expect(resolveDrawerPaneWidth(228, true)).toBe(43); + expect(resolveDrawerPaneWidth(400, true)).toBe(48); + }); + + it("maps card-style drawer lines to lane and chat rows", () => { + // Layout for the selected lane (index 0) with 6 chats: + // y=3 border, y=4 name, y=5 branch, y=6 diff, y=7 (chat margin), + // y=8 CHATS header, y=9 chat 0, y=10 margin, y=11 chat 1, y=12 margin, + // y=13 chat 2, y=14 margin, y=15 chat 3, y=16 margin, y=17 chat 4, + // y=18 margin, y=19 chat 5, y=20 + new chat, y=21 bottom border, + // y=22 marginTop separator, y=23 lane 1 top border, y=24 lane 1 name. + expect(drawerMouseHitForLine({ y: 5, laneCount: 5, selectedLaneIndex: 0, chatCount: 6 })).toEqual({ + kind: "lane", + index: 0, + }); + expect(drawerMouseHitForLine({ y: 9, laneCount: 5, selectedLaneIndex: 0, chatCount: 6 })).toEqual({ + kind: "chat", + index: 0, + }); + expect(drawerMouseHitForLine({ y: 11, laneCount: 5, selectedLaneIndex: 0, chatCount: 6 })).toEqual({ + kind: "chat", + index: 1, + }); + expect(drawerMouseHitForLine({ y: 20, laneCount: 5, selectedLaneIndex: 0, chatCount: 6 })).toEqual({ + kind: "new-chat", + }); + expect(drawerMouseHitForLine({ y: 24, laneCount: 5, selectedLaneIndex: 0, chatCount: 6 })).toEqual({ + kind: "lane", + index: 1, + }); + // 5 unselected lanes ahead consume 5 rows each (border+2 content+border+margin), + // so lane 5's card body starts at y=28. With 2 chats, chat 0 lands at y=34. + expect(drawerMouseHitForLine({ y: 34, laneCount: 6, selectedLaneIndex: 5, chatCount: 2 })).toEqual({ + kind: "chat", + index: 0, + }); + }); + + it("maps lane details action lines to selectable action indexes", () => { + expect(laneDetailsActionIndexForMouseLine(18, 8)).toBe(0); + expect(laneDetailsActionIndexForMouseLine(25, 8)).toBe(7); + expect(laneDetailsActionIndexForMouseLine(26, 8)).toBeNull(); + }); + + it("maps setup pane rows including selected detail lines", () => { + expect(setupPaneRowIndexForMouseLine({ y: 6, rowCount: 4, selectedIndex: 2, hasLaneLabel: false })).toBe(0); + expect(setupPaneRowIndexForMouseLine({ y: 7, rowCount: 4, selectedIndex: 2, hasLaneLabel: false })).toBe(1); + expect(setupPaneRowIndexForMouseLine({ y: 8, rowCount: 4, selectedIndex: 2, hasLaneLabel: false })).toBe(2); + expect(setupPaneRowIndexForMouseLine({ y: 9, rowCount: 4, selectedIndex: 2, hasLaneLabel: false })).toBe(2); + expect(setupPaneRowIndexForMouseLine({ y: 10, rowCount: 4, selectedIndex: 2, hasLaneLabel: false })).toBe(3); + expect(setupPaneRowIndexForMouseLine({ y: 7, rowCount: 4, selectedIndex: 0, hasLaneLabel: true })).toBe(0); + }); + + it("maps form pane rows to their field indexes", () => { + expect(formFieldIndexForMouseLine({ y: 5, fieldCount: 2, command: "new-lane" })).toBe(0); + expect(formFieldIndexForMouseLine({ y: 6, fieldCount: 2, command: "new-lane" })).toBe(1); + expect(formFieldIndexForMouseLine({ y: 9, fieldCount: 4, command: "lane-delete" })).toBe(0); + expect(formFieldIndexForMouseLine({ y: 13, fieldCount: 4, command: "lane-delete" })).toBe(2); + expect(formFieldIndexForMouseLine({ y: 16, fieldCount: 4, command: "lane-delete" })).toBe(1); + expect(formFieldIndexForMouseLine({ y: 19, fieldCount: 4, command: "lane-delete" })).toBe(3); + expect(formFieldIndexForMouseLine({ y: 21, fieldCount: 4, command: "lane-delete" })).toBeNull(); + }); +}); + +describe("prompt mouse hit testing", () => { + it("maps bottom prompt box lines back to chat focus", () => { + expect(promptHitLine({ y: 84, rows: 88, promptRowCount: 1 })).toBe(true); + expect(promptHitLine({ y: 87, rows: 88, promptRowCount: 1 })).toBe(true); + expect(promptHitLine({ y: 83, rows: 88, promptRowCount: 1 })).toBe(false); + expect(promptHitLine({ y: 82, rows: 88, promptRowCount: 3 })).toBe(true); + expect(promptHitLine({ y: null, rows: 88, promptRowCount: 1 })).toBe(false); + }); +}); + describe("chat text selection helpers", () => { it("resolves visible rows to absolute transcript rows", () => { const rows = [ @@ -233,6 +455,44 @@ describe("footer control ordering", () => { }); }); +describe("model picker escape handling", () => { + const picker = { + kind: "model-picker" as const, + surface: "chat" as const, + query: "", + searchMode: false, + selection: { kind: "favorites" as const }, + focusedIndex: 3, + }; + + it("clears active search before closing the model picker", () => { + expect(resolveModelPickerEscape({ ...picker, query: "sonnet", searchMode: true })).toEqual({ + kind: "clear-search", + pane: { + ...picker, + query: "", + searchMode: false, + focusedIndex: 0, + }, + }); + + expect(resolveModelPickerEscape({ ...picker, query: "", searchMode: true })).toEqual({ + kind: "clear-search", + pane: { + ...picker, + query: "", + searchMode: false, + focusedIndex: 0, + }, + }); + }); + + it("closes chat model pickers and returns new-chat model pickers to setup", () => { + expect(resolveModelPickerEscape(picker)).toEqual({ kind: "close" }); + expect(resolveModelPickerEscape({ ...picker, surface: "new-chat" })).toEqual({ kind: "return-new-chat" }); + }); +}); + describe("terminal control toggle", () => { it("recognizes ctrl-t from Ink key data and raw terminal bytes", () => { expect(isTerminalControlToggle("t", { ctrl: true })).toBe(true); diff --git a/apps/ade-cli/src/tuiClient/__tests__/appPolling.test.tsx b/apps/ade-cli/src/tuiClient/__tests__/appPolling.test.tsx index d7f12064c..922cf6014 100644 --- a/apps/ade-cli/src/tuiClient/__tests__/appPolling.test.tsx +++ b/apps/ade-cli/src/tuiClient/__tests__/appPolling.test.tsx @@ -104,8 +104,9 @@ function event(sessionId: string, sequence: number, type: string): AgentChatEven async function flushAsyncEffects() { await act(async () => { - await Promise.resolve(); - await Promise.resolve(); + for (let i = 0; i < 12; i++) { + await Promise.resolve(); + } }); } @@ -159,14 +160,14 @@ describe("AdeCodeApp polling", () => { vi.clearAllMocks(); }); - it("keeps active polling on summary refreshes without hydrating chat history", async () => { + it("polls summary refreshes without hydrating chat history", async () => { const instance = render(); await flushAsyncEffects(); expect(mocks.getChatHistory).toHaveBeenCalledTimes(0); await act(async () => { - await vi.advanceTimersByTimeAsync(1_000); + await vi.advanceTimersByTimeAsync(15_000); }); await flushAsyncEffects(); diff --git a/apps/ade-cli/src/tuiClient/__tests__/commands.test.ts b/apps/ade-cli/src/tuiClient/__tests__/commands.test.ts index e0ab428d7..23c1fd8da 100644 --- a/apps/ade-cli/src/tuiClient/__tests__/commands.test.ts +++ b/apps/ade-cli/src/tuiClient/__tests__/commands.test.ts @@ -40,6 +40,17 @@ describe("commands", () => { })); }); + it("routes /effort to the ADE Code right pane", () => { + const parsed = parseCommand("/effort"); + expect(parsed?.spec?.name).toBe("/effort"); + expect(parsed ? commandPlacement(parsed) : null).toBe("right"); + expect(paletteCommands("/eff")).toContainEqual(expect.objectContaining({ + name: "/effort", + source: "ade", + description: "Open the reasoning-effort picker", + })); + }); + it("routes /feedback to the ADE Code right pane", () => { const parsed = parseCommand("/feedback"); expect(parsed?.spec?.name).toBe("/feedback"); @@ -296,15 +307,17 @@ describe("commands", () => { expect(parsed?.spec).toBeNull(); }); - it("surfaces /info in the palette and not /subagents, /effort, or /plan", () => { + it("surfaces active ADE Code panes in the palette and not removed aliases", () => { const infoRows = paletteCommands("info"); expect(infoRows.some((row) => row.name === "/info")).toBe(true); + const effortRows = paletteCommands("effort"); + expect(effortRows.some((row) => row.name === "/effort" && row.source === "ade")).toBe(true); + const subagentRows = paletteCommands("subagents"); expect(subagentRows.some((row) => row.name === "/subagents")).toBe(false); 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); }); }); @@ -317,6 +330,14 @@ describe("linear command routing", () => { }); }); + it("shows usage for bare /linear instead of running a default tool", () => { + expect(buildLinearToolRequest("")).toEqual({ + kind: "usage", + title: "Linear", + body: "Usage: /linear ...", + }); + }); + it("routes sync dashboard and queue resolution", () => { expect(buildLinearToolRequest("sync dashboard")).toEqual({ kind: "tool", diff --git a/apps/ade-cli/src/tuiClient/__tests__/rightPaneFormatters.test.ts b/apps/ade-cli/src/tuiClient/__tests__/rightPaneFormatters.test.ts new file mode 100644 index 000000000..bf00c308e --- /dev/null +++ b/apps/ade-cli/src/tuiClient/__tests__/rightPaneFormatters.test.ts @@ -0,0 +1,130 @@ +import { describe, expect, it } from "vitest"; +import { + formatLinearStatus, + formatMemorySearch, + formatPrChecks, + formatPrComments, + formatPrReview, + formatPrSummary, + formatSystemDetails, +} from "../rightPaneFormatters"; + +describe("rightPaneFormatters", () => { + it("formats system details as stable rows", () => { + const body = formatSystemDetails({ + project: { projectRoot: "/repo", workspaceRoot: "/repo/.ade/worktrees/a" }, + pid: 123, + mode: "ready", + }); + + expect(body).toContain("project"); + expect(body).toContain("/repo"); + expect(body).toContain("workspace"); + expect(body).toContain("process 123"); + expect(body).not.toContain("{"); + }); + + it("formats a PR summary without dumping JSON", () => { + const body = formatPrSummary({ + id: "pr-1", + number: 42, + title: "Tighten ADE Code panes", + state: "open", + isDraft: true, + headBranch: "feature/tui", + baseBranch: "main", + htmlUrl: "https://example.com/pr/42", + }); + + expect(body).toContain("#42 · open · draft"); + expect(body).toContain("Tighten ADE Code panes"); + expect(body).toContain("feature/tui -> main"); + expect(body).not.toContain("\"title\""); + }); + + it("summarizes PR checks", () => { + const body = formatPrChecks([ + { name: "ci / unit", status: "completed", conclusion: "success", completedAt: "2026-05-20T12:34:00.000Z" }, + { name: "lint", status: "queued", conclusion: null }, + ]); + + expect(body).toContain("PR checks"); + expect(body).toContain("1 passing"); + expect(body).toContain("1 pending"); + expect(body).toContain("OK ci / unit"); + expect(body).toContain("WAIT lint"); + }); + + it("summarizes PR review comments and threads", () => { + const body = formatPrComments({ + summary: { checksStatus: "passing", actionableComments: 2 }, + reviewThreads: [ + { + id: "thread-1", + isResolved: false, + path: "src/index.ts", + line: 12, + comments: [{ author: "reviewer", body: "Please handle the loading state." }], + }, + ], + comments: [{ author: "reviewer", body: "Please fix the loading state." }], + }); + + expect(body).toContain("PR comments · passing · 2 actionable"); + expect(body).toContain("open src/index.ts:12"); + expect(body).toContain("reviewer: Please handle the loading state."); + expect(body).not.toContain("\"reviewThreads\""); + }); + + it("summarizes full PR review data", () => { + const body = formatPrReview({ + reviews: [{ reviewer: "maintainer", state: "changes_requested", body: "Needs a test." }], + threads: [{ path: "src/a.ts", line: 9, isResolved: true, body: "Nit." }], + comments: [], + }); + + expect(body).toContain("PR review · 1 review · 1 thread · 0 comments"); + expect(body).toContain("FAIL maintainer: Needs a test."); + expect(body).toContain("resolved src/a.ts:9"); + }); + + it("summarizes memory search results", () => { + const body = formatMemorySearch({ + query: "deploy lag", + scope: "project", + status: "candidate", + memories: [ + { + id: "memory-1", + status: "candidate", + category: "pattern", + importance: "high", + content: "Service B can lag by ~90s after deploy.", + }, + ], + }); + + expect(body).toContain("Memory · project · candidate · 1 match"); + expect(body).toContain("candidate/pattern/high memory-1"); + expect(body).toContain("Service B can lag"); + }); + + it("formats Linear status as stable rows", () => { + const body = formatLinearStatus({ + tokenStored: true, + connected: false, + viewerName: null, + organizationName: null, + checkedAt: "2026-05-20T07:34:36.379Z", + authMode: "oauth", + oauthAvailable: true, + tokenExpiresAt: "2026-05-14T06:54:46.643Z", + }); + + expect(body).toContain("connected no"); + expect(body).toContain("token stored"); + expect(body).toContain("auth oauth"); + expect(body).toContain("expires 2026-05-14 06:54"); + expect(body).not.toContain("\"connected\""); + }); +}); diff --git a/apps/ade-cli/src/tuiClient/adeApi.ts b/apps/ade-cli/src/tuiClient/adeApi.ts index b3e43dedd..bb7fac509 100644 --- a/apps/ade-cli/src/tuiClient/adeApi.ts +++ b/apps/ade-cli/src/tuiClient/adeApi.ts @@ -190,6 +190,7 @@ export async function startClaudeTerminalSession(args: { laneId: string; title?: string | null; model?: string | null; + reasoningEffort?: string | null; permissionMode?: AgentChatPermissionMode | null; initialInput?: string | null; cols: number; @@ -202,6 +203,7 @@ export async function startClaudeTerminalSession(args: { provider: "claude", title: args.title ?? undefined, model: args.model ?? undefined, + reasoningEffort: args.reasoningEffort ?? undefined, permissionMode: args.permissionMode ?? "default", initialInput: args.initialInput ?? undefined, cols: args.cols, diff --git a/apps/ade-cli/src/tuiClient/app.tsx b/apps/ade-cli/src/tuiClient/app.tsx index bc1ffdf67..f8a6ff3a3 100644 --- a/apps/ade-cli/src/tuiClient/app.tsx +++ b/apps/ade-cli/src/tuiClient/app.tsx @@ -123,6 +123,15 @@ import { appendReservedTuiEvent, reserveTuiEventDedupKey, syncTuiEventDedupKeys import { loadAdeCodeState, saveAdeCodeProjectState, scopedAdeCodeState } from "./state"; import { SpinTickProvider } from "./spinTick"; import { buildLinearToolRequest } from "./linearCommands"; +import { + formatLinearStatus, + formatMemorySearch, + formatPrChecks, + formatPrComments, + formatPrReview, + formatPrSummary, + formatSystemDetails, +} from "./rightPaneFormatters"; import { buildFeedbackDraftInput, buildFeedbackEnvironment, @@ -151,6 +160,7 @@ import type { ProjectLaunchContext, RightPaneContent, SetupPaneRow, + SetupPaneRowKind, SubagentSnapshot, RuntimeMode, } from "./types"; @@ -183,6 +193,54 @@ export function footerControlsForAvailability(agentsAvailable: boolean): FooterC return agentsAvailable ? ["agents", "drawer", "details"] : ["drawer", "details"]; } +export type ModelPickerEscapeAction = + | { kind: "clear-search"; pane: Extract } + | { kind: "return-new-chat" } + | { kind: "close" }; + +export function resolveModelPickerEscape( + picker: Extract, +): ModelPickerEscapeAction { + if (picker.query.length > 0 || picker.searchMode) { + return { + kind: "clear-search", + pane: { ...picker, query: "", searchMode: false, focusedIndex: 0 }, + }; + } + if (picker.surface === "new-chat") return { kind: "return-new-chat" }; + return { kind: "close" }; +} + +type ChatSessionActivity = Pick; +type TerminalSessionActivity = Pick; + +export function isChatSessionAnimating(session: ChatSessionActivity): boolean { + return session.status === "active" && !session.awaitingInput && !session.idleSinceAt; +} + +function isProcessLikelyAlive(pid: number | null | undefined): boolean { + if (!Number.isInteger(pid) || (pid ?? 0) <= 0) return false; + try { + process.kill(pid!, 0); + return true; + } catch (error) { + return typeof error === "object" + && error !== null + && "code" in error + && (error as { code?: unknown }).code === "EPERM"; + } +} + +export function isTerminalSessionWorking(session: TerminalSessionActivity): boolean { + return session.status === "running" && session.runtimeState === "running" && isProcessLikelyAlive(session.pid); +} + +export function isTerminalSessionFastPollActive(session: TerminalSessionActivity): boolean { + return session.status === "running" + && (session.runtimeState === "running" || session.runtimeState === "waiting-input") + && isProcessLikelyAlive(session.pid); +} + function terminalSessionToChatSummary(session: ChatTerminalSession): AgentChatSessionSummary { const status: AgentChatSessionSummary["status"] = session.status === "running" ? session.runtimeState === "idle" ? "idle" : "active" @@ -483,11 +541,29 @@ function delay(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } +function routeRowLabel(entry: unknown): string { + const record = entry && typeof entry === "object" ? entry as Record : {}; + const trimmedString = (key: string): string | null => { + const value = record[key]; + return typeof value === "string" && value.trim() ? value.trim() : null; + }; + const shortSha = trimmedString("shortSha"); + const subject = trimmedString("subject"); + if (shortSha && subject) return `${shortSha} · ${subject}`; + const identifier = trimmedString("identifier"); + const title = trimmedString("title"); + if (identifier && title) return `${identifier} · ${title}`; + const label = + title + ?? trimmedString("name") + ?? trimmedString("branchRef") + ?? trimmedString("id") + ?? shortSha; + return String(label ?? JSON.stringify(entry)).slice(0, 90); +} + function routeRows(value: unknown): string[] { - if (Array.isArray(value)) return value.slice(0, 16).map((entry) => { - const record = entry && typeof entry === "object" ? entry as Record : {}; - return String(record.title ?? record.name ?? record.branchRef ?? record.id ?? JSON.stringify(entry)).slice(0, 90); - }); + if (Array.isArray(value)) return value.slice(0, 16).map((entry) => routeRowLabel(entry).slice(0, 90)); const record = value && typeof value === "object" ? value as Record : {}; const list = Object.values(record).find(Array.isArray); return Array.isArray(list) ? routeRows(list) : renderObject(value, 12).split(/\r?\n/); @@ -676,7 +752,7 @@ type ContextDefaultArgs = { unavailableLaneIds: ReadonlySet; }; -function resolveContextDefault(args: ContextDefaultArgs): RightPaneContent { +export function resolveContextDefault(args: ContextDefaultArgs): RightPaneContent { const nav = args.drawerNav; if (nav) { switch (nav.kind) { @@ -693,9 +769,6 @@ function resolveContextDefault(args: ContextDefaultArgs): RightPaneContent { return { kind: "chat-info", info: nav.info }; } } - if (args.drawerMode === "lanes" && args.highlightedDrawerLane) { - return seedLaneDetails(args.highlightedDrawerLane, !args.unavailableLaneIds.has(args.highlightedDrawerLane.id)); - } if ( args.draftChatActive && args.newChatSetup @@ -708,6 +781,9 @@ function resolveContextDefault(args: ContextDefaultArgs): RightPaneContent { rows: args.newChatSetup.rows, }; } + if (args.drawerMode === "lanes" && args.highlightedDrawerLane) { + return seedLaneDetails(args.highlightedDrawerLane, !args.unavailableLaneIds.has(args.highlightedDrawerLane.id)); + } if (args.activeSession) { return { kind: "chat-info", @@ -1046,6 +1122,14 @@ function defaultModelPickerSelectionIndex(rows: SetupPaneRow[]): number { return defaultSetupSelectionIndex(rows); } +function setupSelectionIndexForKind(rows: SetupPaneRow[], preferredKind: SetupPaneRowKind | null | undefined): number { + if (preferredKind) { + const preferredIndex = rows.findIndex((row) => row.kind === preferredKind); + if (preferredIndex >= 0) return preferredIndex; + } + return defaultModelPickerSelectionIndex(rows); +} + type ConnectionStatusProvider = Extract; function providerConnectionDetail(status: AiSettingsStatus | null, provider: ConnectionStatusProvider): ProviderReadinessRow { @@ -1203,7 +1287,7 @@ export function deletePreviousPromptLine(value: string): string { } export function isPromptWordBackspace(input: string, key: { ctrl?: boolean; meta?: boolean; backspace?: boolean; delete?: boolean }): boolean { - if (key.ctrl && input === "w") return true; + if (isCtrlInput(input, key, "w")) 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; @@ -1212,7 +1296,7 @@ export function isPromptWordBackspace(input: string, key: { ctrl?: boolean; meta } export function isPromptLineBackspace(input: string, key: { ctrl?: boolean; meta?: boolean; backspace?: boolean; delete?: boolean }): boolean { - if (key.ctrl && !key.meta && input === "u") return true; + if (isCtrlInput(input, key, "u")) return true; return false; } @@ -1398,6 +1482,149 @@ export function parseTerminalMouseInput(input: string): TerminalMouseInput | nul return null; } +export type DrawerMouseHit = + | { kind: "lane"; index: number } + | { kind: "chat"; index: number } + | { kind: "new-chat" } + | null; + +export function drawerMouseHitForLine({ + y, + laneCount, + selectedLaneIndex, + chatCount, +}: { + y: number | null; + laneCount: number; + selectedLaneIndex: number; + chatCount: number; +}): DrawerMouseHit { + // Lane drawer layout (terminal mouse Y is 1-based): + // row 1 outer drawer top border + // row 2 "LANES · N" header + // row 3+ lane cards, each: + // ╭──────╮ top border + // │ name │ line 1 + // │ meta │ line 2 + // │ diff │ only on selected cards + // [chat block inline, only on selected card] + // ╰──────╯ bottom border + // + 1 blank row of marginTop between adjacent cards + // Chat block on selected card: + // (blank marginTop) + // CHATS · N + // chat 0 + // (blank between chats) + // chat 1 + // … + // + new chat + if (y == null || laneCount <= 0) return null; + let line = 3; // first lane card's top border row + for (let index = 0; index < laneCount; index += 1) { + const isSelected = index === selectedLaneIndex; + // Card body before the chat block / bottom border: + // top border + line1 + line2 + (selected ? diff row : 0) + const cardBodyHeight = 3 + (isSelected ? 1 : 0); + if (y >= line && y < line + cardBodyHeight) return { kind: "lane", index }; + line += cardBodyHeight; + if (isSelected) { + // Chat block (inside the card, above the bottom border). + const chatBlockMarginTop = 1; + const chatHeader = 1; + // Each chat row consumes 1 row; chats >0 get a 1-row top margin. + const blockStart = line + chatBlockMarginTop + chatHeader; + for (let chatIdx = 0; chatIdx < chatCount; chatIdx += 1) { + const chatRowY = blockStart + chatIdx * 2; + if (y === chatRowY) return { kind: "chat", index: chatIdx }; + } + const newChatY = chatCount > 0 + ? blockStart + (chatCount - 1) * 2 + 1 + : blockStart; + if (y === newChatY) return { kind: "new-chat" }; + line += chatBlockMarginTop + + chatHeader + + (chatCount > 0 ? chatCount * 2 - 1 : 0) + + 1; // + new chat row + } + line += 1; // bottom border of card + if (index < laneCount - 1) line += 1; // marginTop=1 separator to next card + } + return null; +} + +export function laneDetailsActionIndexForMouseLine(y: number | null, actionCount: number): number | null { + if (y == null || actionCount <= 0) return null; + const firstActionLine = 18; + const index = y - firstActionLine; + return index >= 0 && index < actionCount ? index : null; +} + +export function setupPaneRowIndexForMouseLine({ + y, + rowCount, + selectedIndex, + hasLaneLabel, +}: { + y: number | null; + rowCount: number; + selectedIndex: number; + hasLaneLabel: boolean; +}): number | null { + if (y == null || rowCount <= 0) return null; + let line = hasLaneLabel ? 7 : 6; + for (let index = 0; index < rowCount; index += 1) { + if (y === line || (index === selectedIndex && y === line + 1)) return index; + line += index === selectedIndex ? 2 : 1; + } + return null; +} + +export function formFieldIndexForMouseLine({ + y, + fieldCount, + command, +}: { + y: number | null; + fieldCount: number; + command: string; +}): number | null { + if (y == null || fieldCount <= 0) return null; + if (command === "lane-delete") { + if (y >= 9 && y <= 11) return fieldCount > 0 ? 0 : null; + if (y >= 13 && y <= 14) return fieldCount > 2 ? 2 : null; + if (y >= 16 && y <= 17) return fieldCount > 1 ? 1 : null; + if (y >= 19 && y <= 20) return fieldCount > 3 ? 3 : null; + return null; + } + const index = y - 5; + return index >= 0 && index < fieldCount ? index : null; +} + +type LaneDeleteScope = "worktree" | "local_branch" | "remote_branch"; +const LANE_DELETE_SCOPES: LaneDeleteScope[] = ["worktree", "local_branch", "remote_branch"]; + +function normalizeLaneDeleteScope(value: string | null | undefined): LaneDeleteScope { + return value === "local_branch" || value === "remote_branch" ? value : "worktree"; +} + +export function cycleLaneDeleteScope(value: string | null | undefined, delta: number): LaneDeleteScope { + const current = normalizeLaneDeleteScope(value); + const index = LANE_DELETE_SCOPES.indexOf(current); + const next = (index + delta + LANE_DELETE_SCOPES.length) % LANE_DELETE_SCOPES.length; + return LANE_DELETE_SCOPES[next] ?? "worktree"; +} + +export function formFieldUsesPromptInput(command: string, fieldName: string): boolean { + if (command === "lane-delete" && (fieldName === "scope" || fieldName === "force")) return false; + return true; +} + +export function modelPickerSurfaceForSetupPane( + paneKind: "new-chat-setup" | "model-setup", +): "new-chat" | "chat" { + return paneKind === "new-chat-setup" ? "new-chat" : "chat"; +} + export function clampChatScrollOffsetRows(value: number, maxOffset: number): number { const safeMax = Number.isFinite(maxOffset) ? Math.max(0, Math.floor(maxOffset)) : 0; if (Number.isNaN(value)) return 0; @@ -1505,7 +1732,8 @@ function useTerminalMouseTracking(): void { }, []); } -const DRAWER_PANE_WIDTH = 32; +const DRAWER_PANE_MIN_WIDTH = 32; +const DRAWER_PANE_MAX_WIDTH = 48; const MIN_CENTER_PANE_WIDTH = 24; const MIN_RIGHT_PANE_WIDTH = 30; const RIGHT_PANE_MAX_WIDTH = 42; @@ -1527,14 +1755,52 @@ export function resolveTerminalPaneWidth(centerWidth: number): number { return safeCenterWidth(centerWidth); } +export function resolveDrawerPaneWidth(columns: number, drawerOpen: boolean): number { + if (!drawerOpen) return 0; + const safeColumns = finiteFloor(columns, DRAWER_PANE_MIN_WIDTH); + let responsive = DRAWER_PANE_MIN_WIDTH; + if (safeColumns >= 180) { + responsive = Math.floor(safeColumns * 0.19); + } else if (safeColumns >= 132) { + responsive = Math.floor(safeColumns * 0.24); + } + return Math.max(DRAWER_PANE_MIN_WIDTH, Math.min(DRAWER_PANE_MAX_WIDTH, responsive)); +} + +export function promptHitLine(args: { + y: number | null; + rows: number; + promptRowCount: number; + modelStatusRows?: number; + footerRows?: number; +}): boolean { + if (args.y == null) return false; + const rows = finiteFloor(args.rows, 0); + if (rows <= 0) return false; + const promptRows = Math.max(1, finiteFloor(args.promptRowCount, 1)); + const modelStatusRows = Math.max(0, finiteFloor(args.modelStatusRows ?? 0, 0)); + const footerRows = Math.max(1, finiteFloor(args.footerRows ?? 1, 1)); + const promptBoxRows = promptRows + 2; + const firstPromptLine = rows - footerRows - modelStatusRows - promptBoxRows + 1; + return args.y >= firstPromptLine - 1 && args.y <= firstPromptLine + promptBoxRows - 1; +} + 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`; } -export function isTerminalControlToggle(input: string, key: { ctrl?: boolean }): boolean { - return input === "\x14" || (key.ctrl === true && input.toLowerCase() === "t"); +export function isCtrlInput(input: string, key: { ctrl?: boolean; meta?: boolean }, letter: string): boolean { + const normalized = letter.toLowerCase(); + if (normalized.length !== 1) return false; + if (key.ctrl === true && key.meta !== true && input.toLowerCase() === normalized) return true; + const code = normalized.charCodeAt(0) - 96; + return code >= 1 && code <= 26 && input === String.fromCharCode(code); +} + +export function isTerminalControlToggle(input: string, key: { ctrl?: boolean; meta?: boolean }): boolean { + return isCtrlInput(input, key, "t"); } export function splitTerminalControlInput(raw: string): { detach: boolean; forwarded: string } { @@ -1673,7 +1939,7 @@ function modelStatePatchForArg( function resolveRightPaneWidth(columns: number, rightOpen: boolean, drawerOpen: boolean, maxWidth = RIGHT_PANE_MAX_WIDTH): number { if (!rightOpen) return 0; - const drawerWidth = drawerOpen ? DRAWER_PANE_WIDTH : 0; + const drawerWidth = resolveDrawerPaneWidth(columns, drawerOpen); const maxRightWidth = columns - drawerWidth - MIN_CENTER_PANE_WIDTH; if (maxRightWidth < MIN_RIGHT_PANE_WIDTH) return 0; const widthFraction = maxWidth > RIGHT_PANE_MAX_WIDTH ? 0.56 : 0.24; @@ -1686,7 +1952,7 @@ function resolveRightPaneWidth(columns: number, rightOpen: boolean, drawerOpen: function resolveCenterPaneWidth(columns: number, drawerOpen: boolean, rightPaneWidth: number): number { return Math.max( MIN_CENTER_PANE_WIDTH, - columns - (drawerOpen ? DRAWER_PANE_WIDTH : 0) - rightPaneWidth, + columns - resolveDrawerPaneWidth(columns, drawerOpen) - rightPaneWidth, ); } @@ -1760,7 +2026,7 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } const [selectedDrawerChatId, setSelectedDrawerChatId] = useState(null); const [selectedDrawerLaneAction, setSelectedDrawerLaneAction] = useState(null); const [selectedDrawerChatAction, setSelectedDrawerChatAction] = useState(null); - const [formDiscardArmed, setFormDiscardArmed] = useState(false); + const [, setFormDiscardArmedState] = 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; @@ -1773,11 +2039,16 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } const activeLaneIdRef = useRef(null); const activeSessionIdRef = useRef(null); const draftChatActiveRef = useRef(false); + const formDiscardArmedRef = useRef(false); const activePaneRef = useRef("chat"); const keybindingDispatchStateRef = useRef({ prefix: null, prefixAt: 0 }); const footerControlRef = useRef(null); const paneBeforeDetailsRef = useRef("chat"); const chatDraftRef = useRef(""); + const setFormDiscardArmed = useCallback((next: boolean) => { + formDiscardArmedRef.current = next; + setFormDiscardArmedState(next); + }, []); const promptRef = useRef(""); const promptHistoryRef = useRef([]); const promptHistoryIndexRef = useRef(null); @@ -2025,7 +2296,7 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } } if (pane === "details" && rightPane.kind === "form") { const field = rightPane.fields[formFieldIndex] ?? rightPane.fields[0]; - if (field) { + if (field && formFieldUsesPromptInput(rightPane.command, field.name)) { setFormValues((prev) => ({ ...prev, [field.name]: promptRef.current })); } } @@ -2068,7 +2339,9 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } setRightOpen(true); if (rightPane.kind === "form") { const field = rightPane.fields[formFieldIndex] ?? rightPane.fields[0]; - setPrompt(field ? formValues[field.name] ?? field.initialValue ?? "" : ""); + setPrompt(field && formFieldUsesPromptInput(rightPane.command, field.name) + ? formValues[field.name] ?? field.initialValue ?? "" + : ""); } else { setPrompt(""); } @@ -2085,7 +2358,9 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } setFormDiscardArmed(false); if (rightPane.kind === "form") { const field = rightPane.fields[formFieldIndex] ?? rightPane.fields[0]; - setPrompt(field ? formValues[field.name] ?? field.initialValue ?? "" : ""); + setPrompt(field && formFieldUsesPromptInput(rightPane.command, field.name) + ? formValues[field.name] ?? field.initialValue ?? "" + : ""); } else { setPrompt(""); } @@ -2504,6 +2779,8 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } : null; const statusLineRows = statusLineText ? Math.min(3, statusLineText.split(/\r?\n/).filter(Boolean).length || 1) : 0; const statusRows = statusLineRows; + const modelStatusOverlayRows = statusRows + + (draftChatActive || (vimModeEnabled && !hideVimModeIndicator) || modelState.codexFastMode ? 1 : 0); const goalBannerRows = goalBannerText ? 1 : 0; const rightPaneMaxWidth = RIGHT_PANE_MAX_WIDTH; const rightPaneWidth = resolveRightPaneWidth(columns, rightOpen, drawerOpen, rightPaneMaxWidth); @@ -2534,10 +2811,9 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } }, [selectedAgentSnapshot?.id, stopChatSelectionEdgeScroll, updateChatMouseSelection]); const spinTickActive = displayStreaming || mode === "connecting" - || sessions.some((session) => session.status === "active") - || terminalSessions.some((session) => session.status === "running") - || liveAgentCount > 0 - || (rightPane.kind === "lane-details" && rightPane.chats.active > 0); + || (drawerOpen && displaySessions.some(isChatSessionAnimating)) + || (activeTerminalSession != null && isTerminalSessionWorking(activeTerminalSession)) + || liveAgentCount > 0; const chatScrollMaxOffset = useMemo(() => computeChatScrollMaxOffset({ events: displayEvents, notices: displayNotices, @@ -3426,10 +3702,13 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } paneBeforeDetailsRef.current = previousPane; } const nextValues = Object.fromEntries(content.fields.map((field) => [field.name, field.initialValue ?? ""])); + const firstField = content.fields[0] ?? null; setFormValues(nextValues); setFormFieldIndex(0); setFormDiscardArmed(false); - setPrompt(content.fields[0]?.initialValue ?? ""); + setPrompt(firstField && formFieldUsesPromptInput(content.command, firstField.name) + ? firstField.initialValue ?? "" + : ""); setRightPane(content); setRightOpen(true); // Forms are explicit user actions; mark sticky so the context default @@ -3450,6 +3729,77 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } }); }, [openForm]); + const openMoveUnstagedForm = useCallback(() => { + const laneId = activeLaneIdRef.current; + const lane = lanes.find((entry) => entry.id === laneId) ?? activeLane; + if (!laneId || !lane) { + setRightPane({ kind: "details", title: "Move unstaged", body: "No active lane is selected." }); + focusDetails(); + return; + } + openForm({ + kind: "form", + title: "Move unstaged → new lane", + command: "new-lane-from-unstaged", + laneId, + description: `Carries unstaged + untracked changes from ${lane.name} into a new child lane.`, + fields: [ + { name: "name", label: "Name", required: true, placeholder: "rescue-work" }, + ], + }); + }, [activeLane, focusDetails, lanes, openForm]); + + const openLaneDeleteForm = useCallback(() => { + const laneId = activeLaneIdRef.current; + const lane = lanes.find((entry) => entry.id === laneId) ?? activeLane; + if (!laneId || !lane) { + setRightPane({ kind: "details", title: "Delete lane", body: "No active lane is selected." }); + focusDetails(); + return; + } + if (lane.laneType === "primary") { + setRightPane({ kind: "details", title: "Delete lane", body: "Primary lane cannot be deleted." }); + focusDetails(); + return; + } + openForm({ + kind: "form", + title: "Delete lane", + command: "lane-delete", + laneId, + laneDelete: { + laneId, + laneName: lane.name, + branchRef: lane.branchRef, + dirty: lane.status?.dirty === true, + }, + fields: [ + { + name: "scope", + label: "Scope", + initialValue: "worktree", + }, + { + name: "remoteName", + label: "Remote name", + placeholder: "origin", + initialValue: "origin", + }, + { + name: "force", + label: "Force delete", + initialValue: "no", + }, + { + name: "confirm", + label: "Type lane name", + required: true, + placeholder: lane.name, + }, + ], + }); + }, [activeLane, focusDetails, lanes, openForm]); + const openFeedbackForm = useCallback(() => { openForm({ kind: "form", @@ -3516,11 +3866,11 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } // /model opens the right-pane model picker. Provider stays editable on a fresh // chat; once the thread has user messages the provider row is locked to the // active chat family. - const openModelRow = useCallback((options: { forceRefresh?: boolean } = {}) => { + const openModelRow = useCallback((options: { forceRefresh?: boolean; focusKind?: SetupPaneRowKind } = {}) => { const rows = providerLockedRef.current ? modelPickerRows : modelSetupRows; userDismissedRightPaneRef.current = false; lastUserOpenedPaneRef.current = "details"; - setRightSelectionIndex(defaultModelPickerSelectionIndex(rows)); + setRightSelectionIndex(setupSelectionIndexForKind(rows, options.focusKind)); setRightPane({ kind: "model-setup", rows }); setRightOpen(true); focusDetails(); @@ -4190,8 +4540,9 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } }, [activeSessionId, connection, refreshState]); const chatRefreshPollActive = streaming - || sessions.some((session) => session.status === "active") - || terminalSessions.some((session) => session.status === "running"); + || (activeSession != null && isChatSessionAnimating(activeSession)) + || (drawerOpen && sessions.some(isChatSessionAnimating)) + || (activeTerminalSession != null && isTerminalSessionFastPollActive(activeTerminalSession)); useEffect(() => { if (!connection) return; @@ -4491,6 +4842,7 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } laneId, title, model: normalized.modelId ?? normalized.model, + reasoningEffort: normalized.reasoningEffort, permissionMode: normalized.permissionMode, initialInput: text.trim() ? text : null, cols, @@ -4508,12 +4860,22 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } const terminal = activeTerminalSessionRef.current; if (!terminal || modelStateRef.current.provider !== "claude") return false; const resolved = resolveClaudeCliModelForLaunch(modelRef ?? modelStateRef.current.modelId ?? modelStateRef.current.model); - if (!resolved) { + const reasoningEffort = modelStateRef.current.reasoningEffort?.trim() || null; + if (!resolved && !reasoningEffort) { 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"); + let sent = false; + if (resolved) { + sent = await submitClaudePromptToTerminal(terminal, `/model ${resolved}`) || sent; + } + if (reasoningEffort) { + sent = await submitClaudePromptToTerminal(terminal, `/effort ${reasoningEffort}`) || sent; + } + if (sent) { + const details = [resolved, reasoningEffort ? `effort ${reasoningEffort}` : null].filter(Boolean).join(" · "); + addNotice(`Claude Code model settings sent: ${details}.`, "success"); + } return sent; }, [addNotice, submitClaudePromptToTerminal]); @@ -4650,9 +5012,17 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } openModelPicker(); return; } + if (name === "/effort") { + openModelRow({ focusKind: "reasoning" }); + return; + } if (name === "/info") { if (!subagentPaneCommandAvailable) { - addNotice("Open a chat first to inspect chat info.", "info"); + setRightPane({ + kind: "details", + title: "Chat info", + body: "No active chat is selected. Start or open a chat to inspect plan, goal, and agents.", + }); return; } openSubagentsPane(); @@ -4662,7 +5032,7 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } setRightPane({ kind: "details", title: "System", - body: renderObject({ project, pid: process.pid, mode }, 24), + body: formatSystemDetails({ project, pid: process.pid, mode }), }); return; } @@ -4679,15 +5049,21 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } return; } if (name === "/keybindings") { - const keybindings = readClaudeKeybindingsFile({ create: true }); + const shouldOpen = args.trim().toLowerCase() === "open"; + const keybindings = readClaudeKeybindingsFile({ create: shouldOpen }); setKeybindings(keybindings.bindings); - try { - openKeybindingsFile(keybindings.filePath); - addNotice("Opening Claude keybindings config.", "info"); - } catch (error) { - addNotice(error instanceof Error ? error.message : String(error), "error"); + if (shouldOpen) { + try { + openKeybindingsFile(keybindings.filePath); + addNotice("Opening Claude keybindings config.", "info"); + } catch (error) { + addNotice(error instanceof Error ? error.message : String(error), "error"); + } } - setRightPane({ kind: "details", title: "Keybindings", body: keybindings.body }); + const body = shouldOpen + ? keybindings.body + : `${keybindings.body}\n\nRun /keybindings open to create or open this file.`; + setRightPane({ kind: "details", title: "Keybindings", body }); return; } if (name === "/statusline") { @@ -4736,12 +5112,22 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } setRightPane({ kind: "details", title: "Context", body: "/context is only available for Claude chats." }); return; } - const usage = await getContextUsage(conn, sessionId); - setRightPane({ kind: "details", title: "Context", body: formatContextUsage(usage) }); + setRightPane({ kind: "details", title: "Context", body: "Loading Claude context usage..." }); + try { + const usage = await getContextUsage(conn, sessionId); + setRightPane({ kind: "details", title: "Context", body: formatContextUsage(usage) }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + setRightPane({ + kind: "details", + title: "Context", + body: `Claude context usage is not available for this session.\n\n${message}`, + }); + } return; } if (name === "/agents") { - if (activeSession?.provider !== "claude") { + if (activeCommandProvider !== "claude") { setRightPane({ kind: "details", title: "Agents", body: "/agents is only available for Claude chats." }); return; } @@ -4749,7 +5135,7 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } return; } if (name === "/skills") { - if (activeSession?.provider !== "claude") { + if (activeCommandProvider !== "claude") { setRightPane({ kind: "details", title: "Skills", body: "/skills is only available for Claude chats." }); return; } @@ -4962,6 +5348,10 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } await refreshState(); return; } + if (name === "/lane delete") { + openLaneDeleteForm(); + return; + } if (name.startsWith("/pr")) { if (!laneId) { setRightPane({ kind: "details", title: name.slice(1) || "PR", body: "No active lane is selected." }); @@ -4976,7 +5366,7 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } kind: "details", title: "PR", body: activePr - ? renderObject(activePr, 24) + ? formatPrSummary(activePr) : `No PR is linked to this lane yet.\n${ahead > 0 ? `${ahead} commit${ahead === 1 ? "" : "s"} ahead of base.\n` : ""}Run /pr open to create a draft.`, }); return; @@ -4992,7 +5382,7 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } prNumber: typeof activePr.number === "number" ? activePr.number : null, }, }); - setRightPane({ kind: "details", title: "PR open", body: renderObject(activePr, 24) }); + setRightPane({ kind: "details", title: "PR open", body: formatPrSummary(activePr) }); return; } if (!args) { @@ -5013,23 +5403,29 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } body: "", draft: true, }); - setRightPane({ kind: "details", title: "PR open", body: renderObject(created, 24) }); + setRightPane({ kind: "details", title: "PR open", body: formatPrSummary(created) }); return; } if (!prId) { setRightPane({ kind: "details", title: name.slice(1), body: "No PR is linked to this lane yet." }); return; } - const pr = name === "/pr checks" - ? await conn.actionList("pr", "getChecks", [prId]).catch((err) => ({ error: err instanceof Error ? err.message : String(err) })) - : name === "/pr comments" - ? await conn.tool("pr_get_review_comments", { prId }).catch((err) => ({ error: err instanceof Error ? err.message : String(err) })) - : await Promise.all([ - conn.actionList("pr", "getReviews", [prId]).catch((err) => ({ error: err instanceof Error ? err.message : String(err) })), - conn.actionList("pr", "getReviewThreads", [prId]).catch((err) => ({ error: err instanceof Error ? err.message : String(err) })), - conn.actionList("pr", "getComments", [prId]).catch((err) => ({ error: err instanceof Error ? err.message : String(err) })), - ]).then(([reviews, threads, comments]) => ({ reviews, threads, comments })); - setRightPane({ kind: "details", title: name.slice(1), body: renderObject(pr, 24) }); + if (name === "/pr checks") { + const checks = await conn.actionList("pr", "getChecks", [prId]).catch((err) => ({ error: err instanceof Error ? err.message : String(err) })); + setRightPane({ kind: "details", title: "PR checks", body: formatPrChecks(checks) }); + return; + } + if (name === "/pr comments") { + const comments = await conn.tool("pr_get_review_comments", { prId }).catch((err) => ({ error: err instanceof Error ? err.message : String(err) })); + setRightPane({ kind: "details", title: "PR comments", body: formatPrComments(comments) }); + return; + } + const review = await Promise.all([ + conn.actionList("pr", "getReviews", [prId]).catch((err) => ({ error: err instanceof Error ? err.message : String(err) })), + conn.actionList("pr", "getReviewThreads", [prId]).catch((err) => ({ error: err instanceof Error ? err.message : String(err) })), + conn.actionList("pr", "getComments", [prId]).catch((err) => ({ error: err instanceof Error ? err.message : String(err) })), + ]).then(([reviews, threads, comments]) => ({ reviews, threads, comments })); + setRightPane({ kind: "details", title: "PR review", body: formatPrReview(review) }); return; } if (name === "/linear list") { @@ -5039,7 +5435,7 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } } if (name === "/linear status") { const status = await conn.action("linear_issue_tracker", "getStatus", {}); - setRightPane({ kind: "details", title: "Linear status", body: renderObject(status, 24) }); + setRightPane({ kind: "details", title: "Linear status", body: formatLinearStatus(status) }); return; } if (name === "/linear pull") { @@ -5097,6 +5493,7 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } setRightPane({ kind: "details", title: request.title, body: request.body }); return; } + setRightPane({ kind: "details", title: request.title, body: "Loading Linear data..." }); const result = await conn.tool(request.toolName, request.args); setRightPane({ kind: "details", title: request.title, body: renderObject(result, 24) }); return; @@ -5108,7 +5505,7 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } if (name === "/memory") { const query = args || "project"; const result = await conn.tool("memory_search", { query, scope: "project", limit: 10 }); - setRightPane({ kind: "details", title: "Memory", body: renderObject(result, 24) }); + setRightPane({ kind: "details", title: "Memory", body: formatMemorySearch(result) }); return; } if (name === "/forget") { @@ -5160,9 +5557,17 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } openModelPicker(); return; } + if (name === "/effort") { + openModelRow({ focusKind: "reasoning" }); + return; + } if (name === "/info") { if (!subagentPaneCommandAvailable) { - addNotice("Open a chat first to inspect chat info.", "info"); + setRightPane({ + kind: "details", + title: "Chat info", + body: "No active chat is selected. Start or open a chat to inspect plan, goal, and agents.", + }); return; } openSubagentsPane(); @@ -5172,7 +5577,7 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } setRightPane({ kind: "details", title: "System", - body: renderObject({ project, pid: process.pid }, 24), + body: formatSystemDetails({ project, pid: process.pid, mode: "ready" }), }); return; } @@ -5213,7 +5618,7 @@ 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, modelState.reasoningEffort, models, openFeedbackForm, openForm, openModelRow, openNewChatSetup, openNewLaneForm, openSubagentsPane, pendingSteers, project, refreshState, selectActiveLaneId, selectActiveSessionId, sendOrSteerChatMessage, sessions, setChatScrollOffset, subagentPaneCommandAvailable]); + }, [activeCommandProvider, activeLane?.name, activeSession?.provider, activeSession?.sessionId, activeSession?.title, addNotice, ensureActiveSession, focusDetails, lanes, mode, modelState.modelId, modelState.reasoningEffort, models, openFeedbackForm, openForm, openLaneDeleteForm, openModelRow, openNewChatSetup, openNewLaneForm, openSubagentsPane, pendingSteers, project, refreshState, selectActiveLaneId, selectActiveSessionId, sendOrSteerChatMessage, sessions, setChatScrollOffset, subagentPaneCommandAvailable]); const runInlineCommand = useCallback(async (name: string, args: string) => { if (name === "/quit") { @@ -5453,6 +5858,39 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } return; } + if (form.command === "new-lane-from-unstaged") { + const sourceLaneId = form.laneId ?? activeLaneIdRef.current; + if (!sourceLaneId) { + addNotice("No active lane to rescue from.", "error"); + return; + } + const name = requireField("name", "Name"); + if (!name) return; + try { + const created = await conn.action<LaneSummary>("lane", "createFromUnstaged", { + sourceLaneId, + name, + }); + selectActiveLaneId(created.id); + selectActiveSessionId(null); + setDrawerLaneId(created.id); + setSelectedDrawerLaneId(created.id); + setSelectedDrawerChatId(null); + setSelectedDrawerLaneAction(null); + setSelectedDrawerChatAction(null); + setDrawerSection("lanes"); + setRightOpen(false); + setRightPane({ kind: "empty" }); + lastUserOpenedPaneRef.current = null; + focusAfterDetails(); + addNotice(`Moved unstaged work to ${created.name}.`, "success"); + await refreshState(); + } catch (err) { + addNotice(err instanceof Error ? err.message : String(err), "error"); + } + return; + } + if (form.command === "rename") { if (!sessionId) return; const title = requireField("title", "Title"); @@ -5483,6 +5921,59 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } await refreshState(); } + if (form.command === "lane-delete") { + const targetLaneId = form.laneDelete?.laneId ?? form.laneId ?? laneId; + if (!targetLaneId) return; + const lane = lanes.find((entry) => entry.id === targetLaneId) ?? null; + if (!lane) { + addNotice("Selected lane is no longer loaded.", "error"); + return; + } + if (lane.laneType === "primary") { + addNotice("Primary lane cannot be deleted.", "error"); + return; + } + const confirm = requireField("confirm", "Lane name"); + if (!confirm) return; + if (confirm !== lane.name) { + addNotice(`Type "${lane.name}" exactly to delete this lane.`, "error"); + return; + } + const scope = normalizeLaneDeleteScope(values.scope); + const deleteArgs: Record<string, unknown> = { + laneId: targetLaneId, + deleteBranch: scope !== "worktree", + force: values.force === "yes", + }; + if (scope === "remote_branch") { + deleteArgs.deleteRemoteBranch = true; + deleteArgs.remoteName = values.remoteName?.trim() || "origin"; + } + setRightPane({ + kind: "details", + title: "Delete lane", + body: `Deleting ${lane.name}...\nScope: ${scope.replace("_", " ")}\nForce: ${deleteArgs.force ? "yes" : "no"}`, + }); + await conn.action("lane", "delete", deleteArgs); + setFormDiscardArmed(false); + setFormValues({}); + setFormFieldIndex(0); + setPrompt(""); + setRightOpen(false); + setRightPane({ kind: "empty" }); + lastUserOpenedPaneRef.current = null; + const fallbackLane = lanes.find((entry) => entry.id !== targetLaneId && !entry.archivedAt) ?? null; + selectActiveLaneId(fallbackLane?.id ?? null); + selectActiveSessionId(null); + setDrawerLaneId(fallbackLane?.id ?? null); + setSelectedDrawerLaneId(fallbackLane?.id ?? null); + setSelectedDrawerChatId(null); + focusAfterDetails(); + addNotice(`Deleted lane ${lane.name}.`, "success"); + await refreshState(); + return; + } + if (form.command === "feedback") { const summary = requireField("summary", "Summary"); if (!summary) return; @@ -5523,7 +6014,7 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } addNotice(`Feedback failed: ${message}`, "error"); } } - }, [addNotice, focusAfterDetails, refreshState, selectActiveLaneId, selectActiveSessionId]); + }, [addNotice, focusAfterDetails, lanes, refreshState, selectActiveLaneId, selectActiveSessionId]); const openLatestImage = useCallback(() => { const target = latestOpenableImageTarget(events); @@ -5559,6 +6050,10 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } if (!parsed?.spec) return false; if (parsed.spec.providers?.length && !parsed.spec.providers.includes(activeCommandProvider)) { clearChatPromptDraft(); + if (parsed.spec.placement === "right") { + await runRightCommand(parsed.name, parsed.args); + return true; + } addNotice(`${parsed.name} is only available for ${parsed.spec.providers.join(", ")} chats.`, "error"); return true; } @@ -5634,7 +6129,9 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } } if (rightPane.kind === "form" && !text.startsWith("/")) { const field = activeFormField; - const values = field ? { ...formValues, [field.name]: value } : formValues; + const values = field && formFieldUsesPromptInput(rightPane.command, field.name) + ? { ...formValues, [field.name]: value } + : formValues; setFormValues(values); await submitRightForm(rightPane, values); return; @@ -5776,6 +6273,7 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } laneId, title: pendingNewChatTitleRef.current ?? "Claude Code", model: normalized.modelId ?? normalized.model, + reasoningEffort: normalized.reasoningEffort, permissionMode: normalized.permissionMode, initialInput: terminalPrompt.trim() ? terminalPrompt : null, cols, @@ -5883,14 +6381,18 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } const descriptor = getModelById(modelId); const provider: AdeCodeProvider = descriptor ? normalizeProvider(resolveProviderGroupForModel(descriptor)) - : catalogProvider ?? modelState.provider; - applyModelState((prev) => ({ - ...prev, + : catalogProvider ?? modelStateRef.current.provider; + const previousModelState = modelStateRef.current; + const nextModelState: AdeCodeModelState = { + ...previousModelState, ...modelStatePatchForModel(provider, target), codexFastMode: (target.serviceTiers?.some((tier) => tier.trim().toLowerCase() === "fast") || modelSupportsFastMode(descriptor)) - ? prev.codexFastMode + ? previousModelState.codexFastMode : false, - })); + }; + modelStateRef.current = nextModelState; + setModelState(nextModelState); + scheduleModelStateCommit(nextModelState); setModelPickerRecents((prev) => { const filtered = prev.filter((entry) => entry !== modelId); return [modelId, ...filtered].slice(0, 10); @@ -5927,9 +6429,13 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } setRightOpen(false); setPaneFocus("chat"); } + if (rightPane.kind === "model-picker" && rightPane.surface === "chat" && activeTerminalSessionRef.current && provider === "claude") { + void sendClaudeModelCommandToTerminal(modelId) + .catch((err) => addNotice(err instanceof Error ? err.message : String(err), "error")); + } addNotice(`Model set to ${target.displayName}.`, "success"); }, - [addNotice, applyModelState, lanes, models, modelCatalog, modelState.provider, newChatSetupRows, setPaneFocus], + [addNotice, lanes, models, modelCatalog, newChatSetupRows, rightPane, scheduleModelStateCommit, sendClaudeModelCommandToTerminal, setPaneFocus], ); const selectProvider = useCallback((provider: AdeCodeProvider) => { @@ -6485,7 +6991,7 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } clampToChat: boolean, ): ChatSelectionPoint | null => { if (x == null || y == null) return null; - const drawerWidth = drawerOpen ? DRAWER_PANE_WIDTH : 0; + const drawerWidth = resolveDrawerPaneWidth(columns, drawerOpen); const textStartColumn = drawerWidth + 2; const textEndColumn = textStartColumn + Math.max(1, chatWrapWidth) - 1; const topRow = 2 + goalBannerRows; @@ -6529,7 +7035,107 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } const mouse = parseTerminalMouseInput(input); if (mouse) { const activeSelection = chatMouseSelectionRef.current; + const rightWidth = resolveRightPaneWidth(columns, rightOpen, drawerOpen); + const drawerWidth = resolveDrawerPaneWidth(columns, drawerOpen); + const rightStart = columns - rightWidth + 1; if (mouse.kind === "click") { + if (promptHitLine({ + y: mouse.y, + rows, + promptRowCount: promptRows.length, + modelStatusRows: modelStatusOverlayRows, + footerRows: 1, + })) { + stopChatSelectionEdgeScroll(); + chatSelectionAnchorRef.current = null; + if (activeSelection) updateChatMouseSelection(null); + focusChat(); + return; + } + if (mouse.x != null && mouse.y != null && drawerOpen && mouse.x <= drawerWidth) { + stopChatSelectionEdgeScroll(); + chatSelectionAnchorRef.current = null; + if (activeSelection) updateChatMouseSelection(null); + focusDrawerOnly(); + const hit = drawerMouseHitForLine({ + y: mouse.y, + laneCount: drawerLaneRows.length, + selectedLaneIndex, + chatCount: drawerVisibleLaneSessions.length, + }); + if (hit?.kind === "lane") { + const lane = drawerLaneRows[hit.index]; + if (lane) { + setDrawerSection("lanes"); + setSelectedDrawerLaneAction(null); + setSelectedDrawerLaneId(lane.id); + setDrawerLaneId(lane.id); + selectActiveLaneId(lane.id); + applyDrawerChatSelection({ session: null, action: null }); + } + } else if (hit?.kind === "chat") { + const session = drawerVisibleLaneSessions[hit.index]; + if (session) { + setDrawerSection("chats"); + setSelectedDrawerChatAction(null); + setSelectedDrawerChatId(session.sessionId); + applyDrawerChatSelection({ session, action: null }); + } + } else if (hit?.kind === "new-chat") { + setDrawerSection("chats"); + setSelectedDrawerChatAction("new-chat"); + setSelectedDrawerChatId(null); + openNewChatSetup(); + setRightOpen(true); + } + return; + } + if (mouse.x != null && mouse.y != null && rightOpen && rightWidth > 0 && mouse.x >= rightStart) { + stopChatSelectionEdgeScroll(); + chatSelectionAnchorRef.current = null; + if (activeSelection) updateChatMouseSelection(null); + setRightOpen(true); + focusDetailsOnly(); + if (rightPane.kind === "lane-details") { + const nextActionIndex = laneDetailsActionIndexForMouseLine(mouse.y, LANE_DETAIL_ACTIONS.length); + if (nextActionIndex != null) { + setRightPane({ ...rightPane, selectedActionIndex: nextActionIndex }); + } + } + if (rightPane.kind === "new-chat-setup" || rightPane.kind === "model-setup") { + const nextIndex = setupPaneRowIndexForMouseLine({ + y: mouse.y, + rowCount: rightPane.rows.length, + selectedIndex: rightSelectionIndex, + hasLaneLabel: rightPane.kind === "new-chat-setup", + }); + if (nextIndex != null) setRightSelectionIndex(nextIndex); + } + if (rightPane.kind === "form") { + const nextIndex = formFieldIndexForMouseLine({ + y: mouse.y, + fieldCount: rightPane.fields.length, + command: rightPane.command, + }); + if (nextIndex != null) { + const field = rightPane.fields[nextIndex]; + setFormFieldIndex(nextIndex); + setFormDiscardArmed(false); + if (field && formFieldUsesPromptInput(rightPane.command, field.name)) { + setPrompt(formValues[field.name] ?? field.initialValue ?? ""); + } else { + setPrompt(""); + } + } + } + if (rightPane.kind === "chat-info") { + const subagentPaneTop = 4 + goalBannerRows; + const subagentContent = subagentPaneContentFromRightPane(rightPane); + const nextIndex = subagentContent ? subagentIndexForPaneLine(subagentContent, mouse.y - subagentPaneTop, rightSelectionIndex) : null; + if (nextIndex != null) setRightSelectionIndex(nextIndex); + } + return; + } stopChatSelectionEdgeScroll(); const point = chatPointFromMouse(mouse.x, mouse.y, false); if (point) { @@ -6585,8 +7191,6 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } return; } - const rightWidth = resolveRightPaneWidth(columns, rightOpen, drawerOpen); - const drawerWidth = drawerOpen ? DRAWER_PANE_WIDTH : 0; const centerStart = drawerWidth + 1; const centerEnd = columns - rightWidth; const inCenterPane = mouse.x == null || (mouse.x >= centerStart && mouse.x <= centerEnd); @@ -6604,7 +7208,6 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } && mouse.x != null && mouse.y != null ) { - const rightStart = columns - rightWidth + 1; if (mouse.x >= rightStart) { const subagentPaneTop = 4 + goalBannerRows; const subagentContent = subagentPaneContentFromRightPane(rightPane); @@ -6707,12 +7310,14 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } setInlineRowFocus({ cell: providerLockedRef.current ? "model" : "provider" }); return; } - if (pageUp || (key.ctrl && input === "u")) { - setChatScrollOffset((offset) => offset + (key.ctrl ? Math.max(1, Math.floor(pageRows / 2)) : pageRows)); + const halfPageUp = isCtrlInput(input, key, "u"); + const halfPageDown = isCtrlInput(input, key, "d"); + if (pageUp || halfPageUp) { + setChatScrollOffset((offset) => offset + (halfPageUp ? Math.max(1, Math.floor(pageRows / 2)) : pageRows)); return; } - if (pageDown || (key.ctrl && input === "d")) { - setChatScrollOffset((offset) => offset - (key.ctrl ? Math.max(1, Math.floor(pageRows / 2)) : pageRows)); + if (pageDown || halfPageDown) { + setChatScrollOffset((offset) => offset - (halfPageDown ? Math.max(1, Math.floor(pageRows / 2)) : pageRows)); return; } if (home) { @@ -6773,12 +7378,35 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } const currentFormValues = (): Record<string, string> => { if (rightPane.kind !== "form") return formValues; const currentField = rightPane.fields[formFieldIndex] ?? rightPane.fields[0]; - return currentField ? { ...formValues, [currentField.name]: prompt } : formValues; + if (!currentField || !formFieldUsesPromptInput(rightPane.command, currentField.name)) return formValues; + return { ...formValues, [currentField.name]: prompt }; }; const formHasChanges = (values: Record<string, string>): boolean => { if (rightPane.kind !== "form") return false; return rightPane.fields.some((field) => (values[field.name] ?? "") !== (field.initialValue ?? "")); }; + const discardChatDraft = (): void => { + setFormDiscardArmed(false); + newChatPreviewLaneIdRef.current = null; + draftChatActiveRef.current = false; + setDraftChatMode(false); + setSelectedDrawerChatAction(null); + clearChatPromptDraft(); + setRightPane((prev) => prev.kind === "new-chat-setup" ? { kind: "empty" } : prev); + setRightOpen(false); + lastUserOpenedPaneRef.current = null; + userDismissedRightPaneRef.current = true; + }; + const confirmOrDiscardChatDraft = (): boolean => { + if (!draftChatActiveRef.current || activeSessionIdRef.current) return false; + if (!formDiscardArmedRef.current) { + setFormDiscardArmed(true); + addNotice("Press Esc again to discard this chat draft.", "info"); + return true; + } + discardChatDraft(); + return true; + }; if (key.tab && key.shift) { cyclePermission(1); @@ -6790,12 +7418,12 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } return; } - if (key.ctrl && input === "o") { + if (isCtrlInput(input, key, "o")) { toggleDrawerPane(); return; } - if (key.ctrl && input === "l" && pane === "chat") { + if (isCtrlInput(input, key, "l") && pane === "chat") { setClearedAt(new Date().toISOString()); eventDedupKeysRef.current.clear(); eventDedupKeyOrderRef.current = []; @@ -6806,12 +7434,12 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } return; } - if (key.ctrl && input === "p") { + if (isCtrlInput(input, key, "p")) { toggleDetailsPane(); return; } - if (key.ctrl && input === "a") { + if (isCtrlInput(input, key, "a")) { toggleSubagentsPane(); return; } @@ -6855,17 +7483,17 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } } } - if (pane === "chat" && textInputActive && key.ctrl && input === "r") { + if (pane === "chat" && textInputActive && isCtrlInput(input, key, "r")) { openHistorySearch(); return; } - if (pane === "chat" && textInputActive && key.ctrl && input === "v") { + if (pane === "chat" && textInputActive && isCtrlInput(input, key, "v")) { attachClipboardImage(); return; } - if (pane === "chat" && textInputActive && key.ctrl && input === "g") { + if (pane === "chat" && textInputActive && isCtrlInput(input, key, "g")) { const edited = editPromptInExternalEditor(prompt); if (edited == null) { addNotice("External editor exited without updating the prompt.", "error"); @@ -6929,6 +7557,33 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } } } + if (key.escape && pane === "details" && rightOpen && rightPane.kind === "model-picker") { + const escapeAction = resolveModelPickerEscape(rightPane); + if (escapeAction.kind === "clear-search") { + setRightPane(escapeAction.pane); + return; + } + if (escapeAction.kind === "return-new-chat") { + const laneId = activeLaneIdRef.current; + const lane = laneId ? lanes.find((entry) => entry.id === laneId) : null; + if (lane) { + setRightPane({ + kind: "new-chat-setup", + laneId: lane.id, + laneLabel: lane.name, + rows: newChatSetupRows, + }); + setRightOpen(true); + setPaneFocus("details"); + return; + } + } + setRightPane({ kind: "empty" }); + setRightOpen(false); + setPaneFocus("chat"); + return; + } + if (key.escape) { // First Esc unwinds a subagent transcript back to the main chat; the // right pane stays focused on the main agent's info, so a second Esc @@ -6944,9 +7599,12 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } return; } if (pane === "details" && rightOpen) { + if (rightPane.kind === "new-chat-setup" && confirmOrDiscardChatDraft()) { + return; + } if (rightPane.kind === "form") { const values = currentFormValues(); - if (formHasChanges(values) && !formDiscardArmed) { + if (formHasChanges(values) && !formDiscardArmedRef.current) { setFormValues(values); setFormDiscardArmed(true); addNotice("Press Esc again to discard this form.", "info"); @@ -6964,6 +7622,9 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } focusAfterDetails(); return; } + if (pane === "chat" && confirmOrDiscardChatDraft()) { + return; + } if (pane === "drawer") { if (drawerSection === "chats") { setDrawerSection("lanes"); @@ -6995,7 +7656,7 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } return; } - if ((key.ctrl && input === "c") || input === "\x03") { + if (isCtrlInput(input, key, "c")) { if (isCtrlCCopyPlatform() && isChatTextSelectionRange(chatMouseSelectionRef.current)) { copyChatSelection(); return; @@ -7021,6 +7682,44 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } return; } + if (pane === "details" && rightOpen && rightPane.kind === "form" && rightPane.command === "lane-delete") { + const fields = rightPane.fields; + const field = fields[formFieldIndex] ?? fields[0] ?? null; + const nextValues = currentFormValues(); + if (field?.name === "scope") { + if (key.leftArrow || key.rightArrow) { + const nextScope = cycleLaneDeleteScope(nextValues.scope, key.leftArrow ? -1 : 1); + const values = { ...nextValues, scope: nextScope }; + setFormValues(values); + setPrompt(""); + return; + } + const scopeByKey: Record<string, LaneDeleteScope> = { + "1": "worktree", + "2": "local_branch", + "3": "remote_branch", + }; + if (scopeByKey[input]) { + const nextScope = scopeByKey[input]; + const values = { ...nextValues, scope: nextScope }; + setFormValues(values); + setPrompt(""); + return; + } + if (printableInput(input) && !key.ctrl && !key.meta && !key.return) return; + } + if (field?.name === "force") { + if (key.leftArrow || key.rightArrow || input === " " || input === "f") { + const nextForce = nextValues.force === "yes" ? "no" : "yes"; + const values = { ...nextValues, force: nextForce }; + setFormValues(values); + setPrompt(""); + return; + } + if (printableInput(input) && !key.ctrl && !key.meta && !key.return) return; + } + } + if (pane === "details" && rightOpen && rightPane.kind === "form" && (key.upArrow || key.downArrow || key.return)) { const fields = rightPane.fields; const nextValues = currentFormValues(); @@ -7038,7 +7737,11 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } const nextIndex = fields.length ? (formFieldIndex + delta + fields.length) % fields.length : 0; setFormValues(nextValues); setFormFieldIndex(nextIndex); - setPrompt(fields[nextIndex] ? nextValues[fields[nextIndex]!.name] ?? "" : ""); + setPrompt( + fields[nextIndex] && formFieldUsesPromptInput(rightPane.command, fields[nextIndex]!.name) + ? nextValues[fields[nextIndex]!.name] ?? "" + : "", + ); return; } @@ -7087,7 +7790,7 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } // Other rows still fall through to "apply" for parity with the prior flow. const focusedRow = rows[rightSelectionIndex]; if (focusedRow?.kind === "model" && !focusedRow.disabled) { - openModelPicker({ surface: "new-chat" }); + openModelPicker({ surface: modelPickerSurfaceForSetupPane(rightPane.kind) }); return; } const applyRow = rows.find((entry) => entry.kind === "apply"); @@ -7119,31 +7822,6 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } searchMode: picker.searchMode, }); - if (key.escape) { - if (picker.query.length) { - setRightPane({ ...picker, query: "", searchMode: false, focusedIndex: 0 }); - return; - } - if (picker.surface === "new-chat") { - const laneId = activeLaneIdRef.current; - const lane = laneId ? lanes.find((entry) => entry.id === laneId) : null; - if (lane) { - setRightPane({ - kind: "new-chat-setup", - laneId: lane.id, - laneLabel: lane.name, - rows: newChatSetupRows, - }); - setRightOpen(true); - setPaneFocus("details"); - return; - } - } - setRightPane({ kind: "empty" }); - setRightOpen(false); - setPaneFocus("chat"); - return; - } if (key.upArrow) { const next = Math.max(0, layout.focusedIndex - 1); setRightPane({ ...picker, focusedIndex: next }); @@ -7270,6 +7948,10 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } if (index < LANE_DETAIL_ACTIONS.length) { const action = LANE_DETAIL_ACTIONS[index]; if (action) { + if (action.intent === "rescue-unstaged") { + openMoveUnstagedForm(); + return; + } const text = action.slashCommand === "/commit" ? `${action.slashCommand} ` : action.slashCommand; setPrompt(text); promptRef.current = text; @@ -7352,12 +8034,14 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } const end = Boolean((key as { end?: boolean }).end); if (pane === "chat" && !activeMentionRange && !slashRows.length) { const pageRows = Math.max(1, chatRowBudget - 2); - if (pageUp || (key.ctrl && input === "u")) { - setChatScrollOffset((offset) => offset + (key.ctrl ? Math.max(1, Math.floor(pageRows / 2)) : pageRows)); + const halfPageUp = isCtrlInput(input, key, "u"); + const halfPageDown = isCtrlInput(input, key, "d"); + if (pageUp || halfPageUp) { + setChatScrollOffset((offset) => offset + (halfPageUp ? Math.max(1, Math.floor(pageRows / 2)) : pageRows)); return; } - if (pageDown || (key.ctrl && input === "d")) { - setChatScrollOffset((offset) => offset - (key.ctrl ? Math.max(1, Math.floor(pageRows / 2)) : pageRows)); + if (pageDown || halfPageDown) { + setChatScrollOffset((offset) => offset - (halfPageDown ? Math.max(1, Math.floor(pageRows / 2)) : pageRows)); return; } if (home) { @@ -7424,13 +8108,9 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } applyDrawerChatSelection({ session: null, action: null }); } } else { - if (selectedChatIndex === 0 && selectedDrawerChatAction !== "new-chat") { - setDrawerSection("lanes"); - setSelectedDrawerChatAction(null); - setSelectedDrawerChatId(null); - applyDrawerChatSelection({ session: null, action: null }); - return; - } + // Chats section: clamp at the top — never pop back into lanes via arrows. + // The user uses Tab / Esc / Enter on the lane card to switch sections. + if (selectedChatIndex <= 0 && selectedDrawerChatAction !== "new-chat") return; const nextIndex = Math.max(0, selectedChatIndex - 1); const session = drawerVisibleLaneSessions[nextIndex] ?? null; const action: DrawerChatAction | null = session ? null : "new-chat"; @@ -7442,15 +8122,9 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } } if (pane === "drawer" && drawerOpen && key.downArrow) { if (drawerSection === "lanes") { - if (selectedDrawerLaneAction === "new-lane" || selectedLaneIndex >= drawerLaneRows.length) { - // fall through to new-lane row handling below - } else { - const lane = drawerLaneRows[selectedLaneIndex] ?? null; - if (lane && !unavailableLaneIds.has(lane.id)) { - enterDrawerChatListForLane(lane); - return; - } - } + // Arrow keys at lane-card level always navigate between lane cards. + // Entering a lane's chat list now requires Enter (or Tab to flip sections). + if (selectedDrawerLaneAction === "new-lane") return; const nextIndex = Math.min(drawerLaneRows.length, selectedLaneIndex + 1); const lane = drawerLaneRows[nextIndex] ?? null; if (lane) { @@ -7464,26 +8138,11 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } setSelectedDrawerLaneId(null); } } else { + // Chats section: clamp at the bottom (the "+ new chat" row) instead of + // popping over to the next lane card. const atChatBottom = selectedDrawerChatAction === "new-chat" || selectedChatIndex >= drawerVisibleLaneSessions.length; - if (atChatBottom) { - setDrawerSection("lanes"); - setSelectedDrawerChatAction(null); - setSelectedDrawerChatId(null); - applyDrawerChatSelection({ session: null, action: null }); - const nextLaneIndex = Math.min(drawerLaneRows.length, selectedLaneIndex + 1); - const lane = drawerLaneRows[nextLaneIndex] ?? null; - if (lane) { - setSelectedDrawerLaneAction(null); - setSelectedDrawerLaneId(lane.id); - setDrawerLaneId(lane.id); - selectActiveLaneId(lane.id); - } else if (drawerLaneRows.length > 0) { - setSelectedDrawerLaneAction("new-lane"); - setSelectedDrawerLaneId(null); - } - return; - } + if (atChatBottom) return; const nextIndex = Math.min(drawerVisibleLaneSessions.length, selectedChatIndex + 1); const session = drawerVisibleLaneSessions[nextIndex] ?? null; const action: DrawerChatAction | null = session ? null : "new-chat"; @@ -7605,7 +8264,12 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } if (activePaneRef.current === "chat") { chatDraftRef.current = value; } - if (activePaneRef.current === "details" && rightPane.kind === "form" && activeFormField) { + if ( + activePaneRef.current === "details" + && rightPane.kind === "form" + && activeFormField + && formFieldUsesPromptInput(rightPane.command, activeFormField.name) + ) { setFormValues((prev) => ({ ...prev, [activeFormField.name]: value })); } setPrompt(value); @@ -7641,8 +8305,6 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } const rightPaneShowsAgents = rightPaneVisible && rightPane.kind === "chat-info"; const showMentionPalette = activeMentionRange != null && mentionSuggestions.length > 0; const showSlashPalette = prompt.startsWith("/") && slashRows.length > 0; - const modelStatusOverlayRows = statusRows - + (draftChatActive || (vimModeEnabled && !hideVimModeIndicator) || modelState.codexFastMode ? 1 : 0); const paletteBottomRows = 5 + (promptRows.length - 1) + modelStatusOverlayRows @@ -7650,7 +8312,8 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } + (error ? 1 : 0); const paletteOverlayRows = showMentionPalette ? MENTION_PALETTE_ROWS : SLASH_PALETTE_ROWS; const paletteOverlayTop = Math.max(1, rows - paletteBottomRows - paletteOverlayRows); - const paletteOverlayLeft = drawerOpen ? DRAWER_PANE_WIDTH : 0; + const drawerPaneWidth = resolveDrawerPaneWidth(columns, drawerOpen); + const paletteOverlayLeft = drawerPaneWidth; const paletteOverlayWidth = Math.max(MIN_CENTER_PANE_WIDTH, centerWidth); if (error && !connection) { @@ -7693,6 +8356,7 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } prByLaneId={prByLaneId} diffByLaneId={diffByLaneId} unavailableLaneIds={unavailableLaneIds} + width={drawerPaneWidth} /> ) : null} <Box width={centerWidth} flexDirection="column"> diff --git a/apps/ade-cli/src/tuiClient/commands.ts b/apps/ade-cli/src/tuiClient/commands.ts index 5924ab2e9..ce2e0ee30 100644 --- a/apps/ade-cli/src/tuiClient/commands.ts +++ b/apps/ade-cli/src/tuiClient/commands.ts @@ -45,6 +45,7 @@ export const BUILTIN_COMMANDS: BuiltinCommand[] = [ { name: "/diff", description: "Show active lane diff", placement: "right" }, { name: "/log", description: "Show recent commits", placement: "right" }, { name: "/reparent", description: "Move the active lane under another lane", placement: "right", argumentHint: "<parent-lane-id|parent-name> [stack-base-ref]" }, + { name: "/lane delete", description: "Delete the active lane after confirmation", placement: "right" }, { name: "/pr", description: "Show pull request state", placement: "right" }, { name: "/pr open", description: "Create or open a PR for the active lane", placement: "right" }, { name: "/pr review", description: "Show PR reviews", placement: "right" }, @@ -67,10 +68,11 @@ export const BUILTIN_COMMANDS: BuiltinCommand[] = [ { name: "/chats", description: "List chats in the active lane", placement: "right" }, { name: "/switch", description: "Switch lane or chat", placement: "right", argumentHint: "[lane|chat]" }, { name: "/help", description: "Show keymap and command help", placement: "right" }, - { name: "/keybindings", description: "Show Claude-compatible keybinding config diagnostics", placement: "right" }, + { name: "/keybindings", description: "Show Claude-compatible keybinding config diagnostics", placement: "right", argumentHint: "[open]" }, { name: "/statusline", description: "Show Claude-compatible status line config", placement: "right" }, { name: "/doctor", description: "Show ADE Code and Claude-compat diagnostics", placement: "right" }, { name: "/model", description: "Open the model, reasoning, and permission picker", placement: "right" }, + { name: "/effort", description: "Open the reasoning-effort picker", placement: "right" }, { name: "/system", description: "Show system and runtime details", placement: "right" }, { name: "/ade", description: "Run an ADE action or force a TUI command", placement: "right", argumentHint: "<domain.action|command> [json]" }, ]; diff --git a/apps/ade-cli/src/tuiClient/components/AdeWordmark.tsx b/apps/ade-cli/src/tuiClient/components/AdeWordmark.tsx index efa7fbe12..6221912c5 100644 --- a/apps/ade-cli/src/tuiClient/components/AdeWordmark.tsx +++ b/apps/ade-cli/src/tuiClient/components/AdeWordmark.tsx @@ -2,22 +2,57 @@ import React from "react"; import { Box, Text } from "ink"; import { theme } from "../theme"; -const ROWS = [ - " ████ █████ ██████", - "██ ██ ██ ██ ██ ", - "██████ ██ ██ █████ ", - "██ ██ ██ ██ ██ ", - "██ ██ █████ ██████", +/** + * Detailed ADE wordmark in the ANSI-Shadow figlet style: 12 rows tall × 36 + * cells wide. The last row is the shadow and tints to a deeper violet. + */ +const FACE_ROWS = [ + " █████╗ ██████╗ ███████╗", + " ██╔══██╗ ██╔══██╗ ██╔════╝", + " ██║ ██║ ██║ ██║ ██║ ", + " ██║ ██║ ██║ ██║ ██║ ", + " ███████║ ██║ ██║ █████╗ ", + " ███████║ ██║ ██║ █████╗ ", + " ██╔══██║ ██║ ██║ ██║ ", + " ██╔══██║ ██║ ██║ ██║ ", + " ██║ ██║ ██║ ██║ ██║ ", + " ██║ ██║ ██║ ██║ ██║ ", + " ██║ ██║ ██████╔╝ ███████╗", + " ╚═╝ ╚═╝ ╚═════╝ ╚══════╝", ]; -export function AdeWordmark() { +/** + * Compact fallback used when the hero card is too narrow for the big version. + * 6 rows tall — same height as the previous wordmark — with shadow corners. + */ +const COMPACT_ROWS = [ + " █████╗ ██████╗ ███████╗", + "██╔══██╗ ██╔══██╗ ██╔════╝", + "███████║ ██║ ██║ █████╗ ", + "██╔══██║ ██║ ██║ ██╔══╝ ", + "██║ ██║ ██████╔╝ ███████╗", + "╚═╝ ╚═╝ ╚═════╝ ╚══════╝", +]; + +export const ADE_WORDMARK_FULL_WIDTH = 36; +export const ADE_WORDMARK_COMPACT_WIDTH = 26; + +export function AdeWordmark({ compact = false }: { compact?: boolean } = {}) { + const rows = compact ? COMPACT_ROWS : FACE_ROWS; return ( <Box flexDirection="column" alignItems="flex-start"> - {ROWS.map((row, index) => ( - <Text key={index} color={theme.color.accent} bold> - {row} - </Text> - ))} + {rows.map((row, index) => { + const isShadow = index === rows.length - 1; + return ( + <Text + key={index} + color={isShadow ? theme.color.violetDeep : theme.color.accent} + bold + > + {row} + </Text> + ); + })} </Box> ); } diff --git a/apps/ade-cli/src/tuiClient/components/ChatView.tsx b/apps/ade-cli/src/tuiClient/components/ChatView.tsx index 695450d21..938f8d59e 100644 --- a/apps/ade-cli/src/tuiClient/components/ChatView.tsx +++ b/apps/ade-cli/src/tuiClient/components/ChatView.tsx @@ -22,13 +22,20 @@ import { } from "../aggregate"; import { theme } from "../theme"; import { useBrailleSpin, useDotPulse, useSpinFrame } from "../spinTick"; -import { AdeWordmark } from "./AdeWordmark"; +import { + ADE_WORDMARK_COMPACT_WIDTH, + ADE_WORDMARK_FULL_WIDTH, + AdeWordmark, +} from "./AdeWordmark"; import { laneIconGlyph } from "./Header"; import type { AdeCodeProvider } from "../types"; -const HERO_TARGET_HALO_WIDTH = 56; +// Hero halo target: when no chat content is visible, let the hero stretch to a +// roomy reading width instead of staying narrow against an empty pane. +const HERO_TARGET_HALO_WIDTH = 78; const HERO_MIN_HALO_WIDTH = 28; -const HERO_WORDMARK_MIN_USABLE = 24; +const HERO_WORDMARK_FULL_MIN_USABLE = ADE_WORDMARK_FULL_WIDTH + 2; +const HERO_WORDMARK_COMPACT_MIN_USABLE = ADE_WORDMARK_COMPACT_WIDTH + 2; const DEFAULT_VIEW_WIDTH = 88; const BLANK_ROW_TEXT = " "; @@ -265,7 +272,12 @@ export function BootHero({ const cardWidth = haloWidth - 4; const usableWidth = Math.max(4, cardWidth - 8); const heroValueWidth = Math.max(4, usableWidth - 9); - const showWordmark = usableWidth >= HERO_WORDMARK_MIN_USABLE; + let wordmarkVariant: "full" | "compact" | "none" = "none"; + if (usableWidth >= HERO_WORDMARK_FULL_MIN_USABLE) { + wordmarkVariant = "full"; + } else if (usableWidth >= HERO_WORDMARK_COMPACT_MIN_USABLE) { + wordmarkVariant = "compact"; + } return ( <Box flexDirection="column" alignItems="center" paddingY={1}> @@ -291,9 +303,9 @@ export function BootHero({ > <Box flexDirection="column" paddingX={1}> <Box flexDirection="column" alignItems="center"> - {showWordmark ? ( - <AdeWordmark /> - ) : ( + {wordmarkVariant === "full" && <AdeWordmark />} + {wordmarkVariant === "compact" && <AdeWordmark compact />} + {wordmarkVariant === "none" && ( <Text color={theme.color.accent} bold>A · D · E</Text> )} <Box height={1} /> diff --git a/apps/ade-cli/src/tuiClient/components/Drawer.tsx b/apps/ade-cli/src/tuiClient/components/Drawer.tsx index a504eb895..956093522 100644 --- a/apps/ade-cli/src/tuiClient/components/Drawer.tsx +++ b/apps/ade-cli/src/tuiClient/components/Drawer.tsx @@ -13,6 +13,7 @@ type DrawerDensity = "full" | "mini"; type DrawerMode = "lanes" | "chats"; const DRAWER_WIDTH_FULL = 32; +const DRAWER_WIDTH_MAX = 48; const DRAWER_WIDTH_MINI = 22; export type DrawerPrSummary = { @@ -23,8 +24,11 @@ export type DrawerPrSummary = { }; export function visibleDrawerLaneCount(panelHeight: number, laneCount: number): number { - // Full drawer uses compact lane cards; leave room for a chat group + hints. - const lanesMaxRows = Math.max(2, Math.floor((panelHeight - 5) / 4)); + // Each lane card is 4 rows (2 content + 2 border) plus a 1-row margin between + // adjacent cards. Header + footer hints + new-lane row eat about 6 rows of + // outer chrome. Use 5 rows per card so the count stays inside the visible + // panel even when the selected card also expands its chat block. + const lanesMaxRows = Math.max(2, Math.floor((panelHeight - 6) / 5)); return Math.min(laneCount, 12, lanesMaxRows); } @@ -123,6 +127,7 @@ export function Drawer({ diffByLaneId = {}, loading = false, unavailableLaneIds = new Set<string>(), + width: requestedWidth, }: { lanes: LaneSummary[]; sessions: AgentChatSessionSummary[]; @@ -139,6 +144,7 @@ export function Drawer({ diffByLaneId?: Record<string, DiffLineStats>; loading?: boolean; unavailableLaneIds?: ReadonlySet<string>; + width?: number; }) { const { stdout } = useStdout(); const resolvedPanelHeight = panelHeight ?? stdout?.rows ?? 40; @@ -153,7 +159,9 @@ export function Drawer({ ? sessions.filter((s) => s.laneId === browsingLane.id).slice(0, visibleDrawerChatCount(sessions.length)) : []; - const width = density === "mini" ? DRAWER_WIDTH_MINI : DRAWER_WIDTH_FULL; + const width = density === "mini" + ? DRAWER_WIDTH_MINI + : Math.max(DRAWER_WIDTH_FULL, Math.min(DRAWER_WIDTH_MAX, Math.floor(requestedWidth ?? DRAWER_WIDTH_FULL))); const borderColor = focused ? theme.color.violet : theme.color.border; if (density === "mini") { @@ -186,7 +194,7 @@ export function Drawer({ </Text> </Box> - <Box flexDirection="column" paddingX={0} flexGrow={1} flexShrink={1}> + <Box flexDirection="column" paddingX={1} flexGrow={1} flexShrink={1}> {loading && laneRows.length === 0 ? ( <Box paddingX={1}> <Text dimColor>Loading lanes…</Text> @@ -203,13 +211,23 @@ export function Drawer({ const showChatBlock = mode === "chats" ? isBrowsing && browsingLane?.id === lane.id : isSelected; + const cardBorder = cardBorderColor(status, isSelected); + // width - 2 (outer drawer border) - 2 (lane container paddingX) - 2 (card border) - 2 (card paddingX) + const cardInnerWidth = width - 8; return ( - <React.Fragment key={lane.id}> + <Box + key={lane.id} + borderStyle="round" + borderColor={cardBorder} + paddingX={1} + flexDirection="column" + marginTop={index > 0 ? 1 : 0} + > <LaneCard lane={lane} status={status} prefix={meta.prefix} - width={width - 2 /* borders */} + width={cardInnerWidth} selected={isSelected} active={lane.id === activeLaneId} provider={sessionProviderFor(lane, sessions)} @@ -222,35 +240,52 @@ export function Drawer({ sessions={laneChatSessions} activeSessionId={activeSessionId} selectedChatIndex={selectedChatIndex} - width={width - 2} + width={cardInnerWidth} worktreeAvailable={worktreeAvailable} interactive={mode === "chats"} /> ) : null} - </React.Fragment> + </Box> ); })} </Box> - <Box paddingX={1} flexShrink={0}> - <Text color={theme.color.t4} wrap="truncate"> - {!focused ? ( - "\n" - ) : mode === "chats" ? ( - <> - <Text color={theme.color.violet}>↑↓</Text>{" "} - {browsingLane && unavailableLaneIds.has(browsingLane.id) ? "lane unavailable" : "open chat"} - {"\n"} - <Text color={theme.color.violet}>↑</Text> lane card · <Text color={theme.color.violet}>↓</Text> next lane - </> - ) : ( - <> - <Text color={theme.color.violet}>↑↓</Text> lanes · chats preview - {"\n"} - <Text color={theme.color.violet}>↓</Text> enter chats · <Text color={theme.color.violet}>↵</Text> details - </> - )} - </Text> + <Box flexDirection="column" paddingX={1} flexShrink={0}> + {!focused ? ( + <> + <Text> </Text> + <Text> </Text> + </> + ) : mode === "chats" ? ( + <> + <Text color={theme.color.t4} wrap="truncate-end"> + <Text color={theme.color.violet}>↑↓</Text> + {" "} + {browsingLane && unavailableLaneIds.has(browsingLane.id) ? "lane unavailable" : "select chat"} + </Text> + <Text color={theme.color.t4} wrap="truncate-end"> + <Text color={theme.color.violet}>↵</Text> + {" open · "} + <Text color={theme.color.violet}>esc</Text> + {" lanes · "} + <Text color={theme.color.violet}>tab</Text> + {" section"} + </Text> + </> + ) : ( + <> + <Text color={theme.color.t4} wrap="truncate-end"> + <Text color={theme.color.violet}>↑↓</Text> + {" lanes"} + </Text> + <Text color={theme.color.t4} wrap="truncate-end"> + <Text color={theme.color.violet}>↵</Text> + {" enter chats · "} + <Text color={theme.color.violet}>tab</Text> + {" section"} + </Text> + </> + )} </Box> <Box paddingX={1} flexShrink={0}> <Text @@ -265,9 +300,27 @@ export function Drawer({ } /** - * Full two-line lane row: - * line 1: rail/prefix · name · [chip] - * line 2: exec · branch · detail · age + * Pick the rounded-card border color from lane status + selection. Idle gets a + * dim border so the card still reads as a distinct surface without shouting. + */ +function cardBorderColor(status: LaneStatusKind, selected: boolean): string { + if (selected) return theme.color.violet; + switch (status) { + case "primary": return theme.color.violet; + case "running": return theme.color.running; + case "attention": return theme.color.attention; + case "failed": return theme.color.error; + default: return theme.color.border; + } +} + +/** + * Full two-line lane row rendered inside a per-lane card: + * line 1: [tree-prefix] name [chip if active state] [PR pill if any] + * line 2: [indent] exec branch · detail · age + * + * The card's border color already conveys the lane status, so we drop the rail + * glyph and the redundant "idle" / "PRIMARY" chips. */ function LaneCard({ lane, @@ -292,25 +345,28 @@ function LaneCard({ diffStats: DiffLineStats | null; worktreeAvailable: boolean; }) { - const railColor = theme.laneStatusColor(status); const nameColor = selected || active || status === "primary" ? theme.color.violet : theme.color.t1; const detail = laneDetailSuffix(lane, diffStats, worktreeAvailable); const exec = theme.provider(provider); const age = formatLaneAge(lane); - const contentWidth = Math.max(10, width - 4); + const contentWidth = Math.max(10, width); - const chipText = ((): string => { + // Hide the chip for idle (default) and primary (the lane is literally named + // "Primary" — chip would just repeat it). Surface every other transient + // state in plain text. + const chipText = ((): string | null => { switch (status) { - case "primary": return "PRIMARY"; + case "primary": + case "idle": + return null; case "running": return "run"; case "attention": return "wait"; case "failed": return worktreeAvailable ? "fail" : "miss"; - default: return "idle"; + default: return null; } })(); const chipColor = ((): string => { switch (status) { - case "primary": return theme.color.violet; case "running": return theme.color.running; case "attention": return theme.color.attention; case "failed": return theme.color.error; @@ -318,60 +374,56 @@ function LaneCard({ } })(); - // Indicator column (rail or stack prefix). Width: prefix may be 0..N chars. - const indicator = prefix ? prefix : `${theme.rail} `; - const indicatorWidth = indicator.length; - const chipWidth = chipText.length; + const indicatorWidth = prefix.length; + const chipWidth = chipText ? chipText.length : 0; const prPillText = pr?.state === "open" ? formatPrPillText(pr) : null; const prPillWidth = prPillText?.length ?? 0; - const canShowPrPill = Boolean(prPillText) && contentWidth >= 24 && contentWidth - indicatorWidth - chipWidth - prPillWidth - 3 >= 4; + const chipReservation = chipWidth ? chipWidth + 1 : 0; + const canShowPrPill = Boolean(prPillText) + && contentWidth >= 22 + && contentWidth - indicatorWidth - chipReservation - prPillWidth - 1 >= 4; // VM lanes live on the Mac VM, not the host worktree path. Surface a small // badge so users in the TUI know `/commit`, `/push`, etc. operate against // the VM-attached lane (not a normal local worktree). const isVmLane = lane.runtimePlacement === "macos-vm"; const VM_BADGE_WIDTH = 2; + const rightReservationWithoutVm = chipReservation + (canShowPrPill ? prPillWidth + 1 : 0); const canShowVmBadge = isVmLane - && contentWidth - indicatorWidth - chipWidth - (canShowPrPill ? prPillWidth + 1 : 0) - VM_BADGE_WIDTH - 2 >= 3; - const nameMax = Math.max( - 3, - contentWidth - - indicatorWidth - - chipWidth - - (canShowPrPill ? prPillWidth + 1 : 0) - - (canShowVmBadge ? VM_BADGE_WIDTH + 1 : 0) - - 1, - ); + && contentWidth - indicatorWidth - rightReservationWithoutVm - VM_BADGE_WIDTH - 1 >= 3; + const reservedRight = rightReservationWithoutVm + (canShowVmBadge ? VM_BADGE_WIDTH + 1 : 0); + const nameMax = Math.max(3, contentWidth - indicatorWidth - reservedRight); const name = truncate(lane.name, nameMax); const line2Indent = " ".repeat(Math.min(indicatorWidth, 4)); const branch = lane.branchRef ?? ""; - const detailText = detail.diff - ? `+${detail.diff.add} −${detail.diff.del}` - : detail.hint ?? ""; + // Diff is rendered on its own third line under selected cards; never inline + // on line 2. Hints (missing worktree, dirty, rebase, checkpoint Xd) still + // appear between branch and age on line 2. + const inlineHint = detail.hint ?? ""; const canShowAge = Boolean(age) && contentWidth >= 22; const metaWidth = contentWidth - line2Indent.length - 2 - (canShowAge ? age.length + 3 : 0); - const detailMax = detailText ? Math.min(detailText.length, Math.max(0, metaWidth - 7)) : 0; - const branchMax = Math.max(3, metaWidth - (detailMax ? detailMax + 3 : 0)); + const hintMax = inlineHint ? Math.min(inlineHint.length, Math.max(0, metaWidth - 7)) : 0; + const branchMax = Math.max(3, metaWidth - (hintMax ? hintMax + 3 : 0)); const truncBranch = truncate(branch, branchMax); - const truncDetail = detailText ? truncate(detailText, detailMax) : ""; + const truncHint = inlineHint ? truncate(inlineHint, hintMax) : ""; + // Diff renders only when the card is selected — otherwise the line list stays + // calm. Selected cards get an extra row under the branch with +adds/−dels. + const showDiffLine = selected && detail.diff !== null; return ( - <Box flexDirection="column" borderStyle="single" borderColor={selected ? theme.color.violet : theme.color.border} paddingX={1}> + <Box flexDirection="column"> <Box> <Text> - {prefix ? ( - <Text color={theme.color.t4}>{prefix}</Text> - ) : ( - <Text color={railColor} bold> - {theme.rail} - {" "} - </Text> - )} + {prefix ? <Text color={theme.color.t4}>{prefix}</Text> : null} <Text color={nameColor} bold={selected || status === "primary"}> {pad(name, nameMax)} </Text> - <Text> </Text> - <Text color={chipColor}>{chipText}</Text> + {chipText ? ( + <> + <Text> </Text> + <Text color={chipColor}>{chipText}</Text> + </> + ) : null} {canShowPrPill && pr ? ( <> <Text> </Text> @@ -397,18 +449,10 @@ function LaneCard({ <Text color={theme.color.t5}>· </Text> )} <Text color={theme.color.t3}>{truncBranch}</Text> - {truncDetail ? ( + {truncHint ? ( <> <Text color={theme.color.t5}> · </Text> - {detail.diff && truncDetail === detailText ? ( - <Text> - <Text color={theme.color.running}>+{detail.diff.add}</Text> - <Text> </Text> - <Text color={theme.color.error}>−{detail.diff.del}</Text> - </Text> - ) : ( - <Text color={theme.color.t3}>{truncDetail}</Text> - )} + <Text color={theme.color.t3}>{truncHint}</Text> </> ) : null} {canShowAge && age ? ( @@ -419,6 +463,16 @@ function LaneCard({ ) : null} </Text> </Box> + {showDiffLine && detail.diff ? ( + <Box> + <Text> + <Text>{line2Indent}</Text> + <Text color={theme.color.running}>+{detail.diff.add}</Text> + <Text> </Text> + <Text color={theme.color.error}>−{detail.diff.del}</Text> + </Text> + </Box> + ) : null} </Box> ); } @@ -441,10 +495,7 @@ function PrPill({ pr }: { pr: DrawerPrSummary }) { ); } -/** - * Chat block rendered beneath the browsing lane row, with a violet left border - * matching DFChat from the wireframe. - */ +/** Chat block rendered beneath the browsing lane row as a compact subsection. */ function ChatBlock({ sessions, activeSessionId, @@ -462,13 +513,11 @@ function ChatBlock({ }) { if (!worktreeAvailable) { return ( - <Box flexDirection="column"> + <Box flexDirection="column" marginTop={1}> <Box> - <Text color={theme.color.violet}>│ </Text> <Text color={theme.color.t4}>CHATS · unavailable</Text> </Box> <Box> - <Text color={theme.color.violet}>│ </Text> <Text color={theme.color.error}>worktree missing</Text> </Box> </Box> @@ -476,17 +525,15 @@ function ChatBlock({ } if (sessions.length === 0 && selectedChatIndex !== 0) { return ( - <Box paddingLeft={2}> - <Text color={theme.color.violet}>│ </Text> + <Box marginTop={1}> <Text dimColor>No chats in lane.</Text> </Box> ); } const max = Math.max(8, width - 4); return ( - <Box flexDirection="column"> + <Box flexDirection="column" marginTop={1}> <Box> - <Text color={theme.color.violet}>│ </Text> <Text color={theme.color.t4}>CHATS · {sessions.length}</Text> </Box> {sessions.map((session, index) => { @@ -496,17 +543,16 @@ function ChatBlock({ const exec = theme.provider(provider); const when = formatSessionAge(session); const label = truncate(formatSessionLabel(session), max - 6); - let titleColor: string = theme.color.t2; - if (running) { - titleColor = theme.color.violet; - } else if (selected) { - titleColor = theme.color.t1; - } + // White by default. Violet on selection (the only highlight state in a + // TUI). The running spinner + activeSession dot already convey activity + // — no need to also recolor the title, and bold is avoided because some + // xterm builds render bold characters slightly wider, which makes the + // selected row look outdented next to its neighbours. + const titleColor: string = selected ? theme.color.violet : theme.color.t1; return ( - <Box key={session.sessionId}> - <Text color={theme.color.violet}>│ </Text> + <Box key={session.sessionId} marginTop={index > 0 ? 1 : 0}> <Text color={exec.color}>{exec.glyph} </Text> - <Text color={titleColor} bold={running || selected}> + <Text color={titleColor}> {label} </Text> <Text> </Text> @@ -519,7 +565,6 @@ function ChatBlock({ ); })} <Box> - <Text color={theme.color.violet}>│ </Text> <Text color={interactive && selectedChatIndex === sessions.length ? theme.color.violet : theme.color.t4}> + new chat </Text> diff --git a/apps/ade-cli/src/tuiClient/components/ModelPicker/ModelPickerPane.tsx b/apps/ade-cli/src/tuiClient/components/ModelPicker/ModelPickerPane.tsx index 28dfb12e4..8b05c792e 100644 --- a/apps/ade-cli/src/tuiClient/components/ModelPicker/ModelPickerPane.tsx +++ b/apps/ade-cli/src/tuiClient/components/ModelPicker/ModelPickerPane.tsx @@ -35,14 +35,16 @@ function ModelPickerSearchBar({ searchMode: boolean; width: number; }) { - const displayed = endTruncate(query || "search models…", Math.max(8, width - 4)); + const displayed = endTruncate(query || "search models…", Math.max(8, width - 8)); return ( <Box flexDirection="row" marginBottom={1}> + <Text color={searchMode ? theme.color.violet : theme.color.borderSoft}>[</Text> <Text color={searchMode ? theme.color.violet : theme.color.t4}>{searchMode ? "▸" : "/"}</Text> - <Text> </Text> + <Text color={theme.color.borderSoft}> </Text> <Text color={query ? theme.color.t1 : theme.color.t4} dimColor={!query}> {displayed} </Text> + <Text color={searchMode ? theme.color.violet : theme.color.borderSoft}>]</Text> </Box> ); } @@ -56,7 +58,7 @@ function ModelPickerRail({ selectedIndex: number; width: number; }) { - const labelWidth = Math.max(6, Math.min(14, width - 4)); + const labelWidth = Math.max(4, Math.min(9, width - 4)); return ( <Box flexDirection="column" marginRight={1}> {entries.map((entry, index) => { @@ -93,6 +95,10 @@ function ModelListRow({ }) { const labelMax = Math.max(8, width - 4); const starColor = entry.isFavorite ? theme.color.warning : theme.color.t5; + const brand = theme.provider(entry.family); + const activeChip = active ? " now" : ""; + const localChip = entry.family === "ollama" || entry.family === "lmstudio" ? " local" : ""; + const chipWidth = activeChip.length + localChip.length; return ( <Box flexDirection="column"> <Box flexDirection="row"> @@ -102,6 +108,7 @@ function ModelListRow({ <Text color={starColor}> {entry.isFavorite ? " ★" : " ☆"} </Text> + <Text color={brand.color}> {brand.glyph}</Text> <Text color={ !entry.isAvailable @@ -116,17 +123,17 @@ function ModelListRow({ bold={selected || active} > {" "} - {endTruncate(entry.displayName, labelMax)} + {endTruncate(entry.displayName, Math.max(6, labelMax - chipWidth - 4))} </Text> {active ? ( - <Text color={theme.color.t4} dimColor> - {" "} - ·{" "}now - </Text> + <Text color={theme.color.violet}> now</Text> + ) : null} + {localChip ? ( + <Text color={theme.color.running}> local</Text> ) : null} </Box> {entry.subProvider ? ( - <Box marginLeft={4}> + <Box marginLeft={6}> <Text color={theme.color.t4} dimColor> {endTruncate(entry.subProvider, labelMax - 2)} </Text> @@ -166,6 +173,9 @@ export function ModelPickerPane({ }) { const innerWidth = Math.max(20, width - 4); const railEntry = state.railEntries[state.railIndex] ?? state.railEntries[0]; + const activeEntry = state.activeModelId + ? state.entries.find((entry) => entry.modelId === state.activeModelId) ?? null + : null; const headingLabel = state.query.trim() ? "Search results" : railEntry?.kind === "favorites" @@ -183,11 +193,22 @@ export function ModelPickerPane({ <Box flexDirection="column"> <ModelPickerSearchBar query={state.query} searchMode={state.searchMode} width={innerWidth} /> + <Box flexDirection="column" marginBottom={1}> + <Text color={theme.color.t4} dimColor> + {state.entries.length} model{state.entries.length === 1 ? "" : "s"} · {headingLabel} + </Text> + {activeEntry ? ( + <Text color={theme.color.violet} wrap="truncate-end"> + Using {theme.provider(activeEntry.family).glyph} {endTruncate(activeEntry.displayName, Math.max(8, innerWidth - 10))} + </Text> + ) : null} + </Box> + <Box flexDirection="row"> <ModelPickerRail entries={state.railEntries} selectedIndex={state.railIndex} - width={Math.max(10, Math.floor(innerWidth / 3))} + width={Math.max(8, Math.floor(innerWidth / 4))} /> <Box flexDirection="column" flexGrow={1}> diff --git a/apps/ade-cli/src/tuiClient/components/RightPane.tsx b/apps/ade-cli/src/tuiClient/components/RightPane.tsx index e18292da1..cec8cf74b 100644 --- a/apps/ade-cli/src/tuiClient/components/RightPane.tsx +++ b/apps/ade-cli/src/tuiClient/components/RightPane.tsx @@ -20,6 +20,7 @@ import type { AgentChatModelCatalog, AgentChatModelInfo } from "../../../../desk const DEFAULT_PANE_WIDTH = 38; const LANE_FILE_PREVIEW_ROWS = 5; +const DETAILS_BODY_MAX_LINES = 26; // --------------------------------------------------------------------------- // Actions for the lane-details pane (5 rows · wireframe) @@ -30,12 +31,27 @@ export const LANE_DETAIL_ACTIONS: ReadonlyArray<{ label: string; slashCommand: string; detail?: string; + glyph?: string; + glyphColorKind: "additive" | "navigation" | "destructive" | "rescue"; + intent?: "rescue-unstaged"; }> = [ - { k: "a", label: "stage all", slashCommand: "/stage all" }, - { k: "c", label: "commit", slashCommand: "/commit", detail: "claude will draft message" }, - { k: "p", label: "push", slashCommand: "/push" }, - { k: "d", label: "diff", slashCommand: "/diff" }, - { k: "r", label: "reparent", slashCommand: "/reparent", detail: "optional base ref" }, + { k: "n", label: "new chat", slashCommand: "/new chat", glyph: "✦", glyphColorKind: "additive" }, + { k: "o", label: "open / create PR", slashCommand: "/pr open", detail: "draft when missing", glyph: "↗", glyphColorKind: "navigation" }, + { k: "a", label: "stage all", slashCommand: "/stage all", glyph: "+", glyphColorKind: "additive" }, + { + k: "u", + label: "move unstaged to new lane", + slashCommand: "/lane-rescue-unstaged", + intent: "rescue-unstaged" as const, + detail: "child lane from unstaged work", + glyph: "⇄", + glyphColorKind: "rescue", + }, + { k: "c", label: "commit", slashCommand: "/commit", detail: "claude will draft message", glyph: "✓", glyphColorKind: "additive" }, + { k: "p", label: "push", slashCommand: "/push", glyph: "↑", glyphColorKind: "additive" }, + { k: "d", label: "diff", slashCommand: "/diff", glyph: "≡", glyphColorKind: "navigation" }, + { k: "r", label: "reparent", slashCommand: "/reparent", detail: "optional base ref", glyph: "⎇", glyphColorKind: "navigation" }, + { k: "x", label: "delete lane", slashCommand: "/lane delete", detail: "requires name", glyph: "✗", glyphColorKind: "destructive" }, ]; export const LANE_DETAIL_PR_ACTION_INDEX = LANE_DETAIL_ACTIONS.length; @@ -137,23 +153,69 @@ function SectionHead({ title, hint }: { title: string; hint?: string }) { ); } +function actionGlyphColor(kind: typeof LANE_DETAIL_ACTIONS[number]["glyphColorKind"]): string { + if (kind === "additive") return theme.color.running; + if (kind === "destructive") return theme.color.error; + if (kind === "rescue") return theme.color.attention; + return theme.color.violet; +} + function ActionRow({ k, label, detail, + glyph, + glyphColorKind, selected, + width, }: { k: string; label: string; detail?: string; + glyph?: string; + glyphColorKind: typeof LANE_DETAIL_ACTIONS[number]["glyphColorKind"]; selected?: boolean; + width: number; }) { + const glyphChar = glyph ?? " "; + const glyphColor = actionGlyphColor(glyphColorKind); + // Reserve at least a small budget so we never produce a negative width. + const safeWidth = Math.max(8, width); if (!selected) { - return <Text color={theme.color.t2}> {label}</Text>; + // Non-selected: " {glyph} {label}" — two leading spaces, glyph, space, label. + // Total prefix is 3 chars (" " + glyph + " "). + const remaining = Math.max(1, safeWidth - 3); + const labelText = endTruncate(label, remaining); + return ( + <Text wrap="truncate"> + <Text>{" "}</Text> + <Text color={glyphColor}>{glyphChar}</Text> + <Text color={theme.color.t2}>{` ${labelText}`}</Text> + </Text> + ); + } + // Selected: "▎ [k] {glyph} {label} {detail?}" + // Prefix string (rail + space + [k] + space): "▎ [k] " → 6 chars (rail counted as 1 cell). + const prefix = `${theme.rail} [${k}] `; + // After prefix we render: glyph + space + label + (optional " " + detail) + // Reserve width for prefix + glyph + space + label first. + const afterPrefix = Math.max(1, safeWidth - prefix.length); + // glyph+space costs 2 cells. + const labelBudget = Math.max(1, afterPrefix - 2); + const labelText = endTruncate(label, labelBudget); + const used = prefix.length + 2 + labelText.length; + let detailText = ""; + if (detail) { + const detailRoom = Math.max(0, safeWidth - used - 2); // 2 = " " gap + if (detailRoom > 1) { + detailText = ` ${endTruncate(detail, detailRoom)}`; + } } return ( - <Text color={theme.color.violet} bold> - {theme.rail} [{k}] {label}{detail ? ` ${detail}` : ""} + <Text wrap="truncate" color={theme.color.violet} bold> + <Text>{prefix}</Text> + <Text color={glyphColor}>{glyphChar}</Text> + <Text>{` ${labelText}${detailText}`}</Text> </Text> ); } @@ -327,7 +389,10 @@ function LaneDetailsPane({ k={action.k} label={action.label} detail={action.detail} + glyph={action.glyph} + glyphColorKind={action.glyphColorKind} selected={idx === content.selectedActionIndex} + width={contentWidth} /> ))} </Box> @@ -529,6 +594,7 @@ function ChatInfoRoster({ const runCount = snapshotRows.filter((row) => row.snapshot.status === "running").length; const doneCount = snapshotRows.filter((row) => row.snapshot.status === "completed").length; const failedCount = snapshotRows.filter((row) => row.snapshot.status === "failed").length; + const bgCount = snapshotRows.filter((row) => row.section === "background").length; // Selection convention: 0 = main row; 1..N = subagent rows (1-indexed). const totalSelectable = snapshotRows.length + 1; const selected = Math.max(0, Math.min(selectedIndex, totalSelectable - 1)); @@ -536,7 +602,12 @@ function ChatInfoRoster({ const showingMain = !info.inspectedSubagentId; const hint = snapshotRows.length === 0 ? "0 live" - : `${runCount} live · ${doneCount} done${failedCount ? ` · ${failedCount} failed` : ""}`; + : [ + `${runCount} live`, + `${doneCount} done`, + failedCount ? `${failedCount} failed` : null, + bgCount ? `${bgCount} bg` : null, + ].filter((value): value is string => value !== null).join(" · "); const ROSTER_CAPACITY = 5; const subagentSelectedIndex = mainSelected ? -1 : selected - 1; @@ -567,13 +638,23 @@ function ChatInfoRoster({ ) : null} {visibleSlice.map((row, sliceIndex) => { const rosterIndex = window.start + sliceIndex; + const previousSection = rosterIndex === 0 + ? "main" + : snapshotRows[rosterIndex - 1]?.section; + const showSection = row.section !== previousSection; const isSelected = !mainSelected && subagentSelectedIndex === rosterIndex; const kind = subagentAgentKind(row.snapshot.status); - const statusColor = theme.agentStatusColor(kind); + // Background rows get a cyan glyph tint so the eye can sort them out + // from foreground subagents at a glance. Falls back to the + // status-driven color for other rows. + const statusColor = row.section === "background" + ? theme.color.tool + : theme.agentStatusColor(kind); const inspected = info.inspectedSubagentId === row.snapshot.id; const detail = rosterRowDetail(row.snapshot); return ( <Box key={row.key} flexDirection="column"> + {showSection ? <RosterSectionHead section={row.section} /> : null} <Box flexDirection="row"> <Text color={isSelected ? theme.color.violet : theme.color.t5}>{isSelected ? theme.rail : " "}</Text> <Text color={statusColor}>{` ${theme.agentStatusGlyph(kind)}`}</Text> @@ -602,6 +683,21 @@ function ChatInfoRoster({ ); } +// Section heading for the roster — matches the 2-line allowance built into +// `subagentPaneSelectableLineOffsets` (one blank-margin line + one title line) +// so the mouse-click line-math stays accurate. +function RosterSectionHead({ section }: { section: SubagentPaneRow["section"] }) { + let label = "subagents"; + if (section === "background") label = "background"; + else if (section === "teammates") label = "teammates"; + const color = section === "background" ? theme.color.tool : theme.color.t4; + return ( + <Box marginTop={1}> + <Text color={color} dimColor>{label}</Text> + </Box> + ); +} + function ChatInfoPane({ info, selectedIndex, @@ -642,6 +738,201 @@ function HelpPane() { ); } +function detailsBodyLines(body: string): string[] { + const lines = body.split(/\r?\n/); + if (lines.length <= DETAILS_BODY_MAX_LINES) return lines; + const remaining = lines.length - DETAILS_BODY_MAX_LINES; + return [ + ...lines.slice(0, DETAILS_BODY_MAX_LINES), + `… ${remaining} more line${remaining === 1 ? "" : "s"}`, + ]; +} + +function isDetailsSectionLine(line: string): boolean { + const trimmed = line.trim(); + if (!trimmed || trimmed.length > 36) return false; + if (/^[A-Z][A-Z0-9 /_.-]+$/.test(trimmed)) return true; + return /^[A-Z][A-Za-z0-9 /_.-]+:$/.test(trimmed); +} + +function detailsKeyValue(line: string): { key: string; value: string } | null { + const trimmed = line.trim(); + const match = trimmed.match(/^([^:]{2,22}):\s+(.+)$/); + if (!match) return null; + const key = match[1]?.trim() ?? ""; + const value = match[2]?.trim() ?? ""; + if (!key || !value || key.includes("{") || key.includes("[")) return null; + return { key, value }; +} + +function DetailsPane({ title, body, width }: { title: string; body: string; width: number }) { + const bodyWidth = Math.max(12, width - 4); + const lines = detailsBodyLines(body); + return ( + <Box flexDirection="column"> + {lines.map((line, index) => { + const trimmed = line.trim(); + if (!trimmed) return <Text key={index}> </Text>; + if (isDetailsSectionLine(trimmed)) { + return ( + <Box key={index} flexDirection="row" marginTop={index === 0 ? 0 : 1}> + <Text bold color={theme.color.violet}>{endTruncate(trimmed.replace(/:$/, ""), Math.max(8, bodyWidth - 2))}</Text> + </Box> + ); + } + const kv = detailsKeyValue(trimmed); + if (kv) { + return ( + <Box key={index} flexDirection="row"> + <Text color={theme.color.t4}>{endTruncate(kv.key, 13).padEnd(13)} </Text> + <Text color={theme.color.t1} wrap="truncate-end">{endTruncate(kv.value, Math.max(8, bodyWidth - 14))}</Text> + </Box> + ); + } + if (/^[-*•]\s+/.test(trimmed)) { + return ( + <Text key={index} color={theme.color.t2} wrap="truncate-end"> + <Text color={theme.color.violet}>• </Text> + {endTruncate(trimmed.replace(/^[-*•]\s+/, ""), Math.max(8, bodyWidth - 2))} + </Text> + ); + } + if (/^\d+[.)]\s+/.test(trimmed)) { + const prefix = trimmed.match(/^\d+[.)]/)?.[0] ?? "1."; + return ( + <Text key={index} color={theme.color.t2} wrap="truncate-end"> + <Text color={theme.color.violet}>{prefix} </Text> + {endTruncate(trimmed.replace(/^\d+[.)]\s+/, ""), Math.max(8, bodyWidth - prefix.length - 1))} + </Text> + ); + } + if (/^[{}[\],"]/.test(trimmed) || trimmed.includes('":')) { + return ( + <Text key={index} color={theme.color.t4} dimColor wrap="truncate-end"> + {endTruncate(trimmed, bodyWidth)} + </Text> + ); + } + const tone = title.toLowerCase().includes("error") || /^error\b/i.test(trimmed) + ? theme.color.error + : theme.color.t2; + return ( + <Text key={index} color={tone} wrap="truncate-end"> + {endTruncate(trimmed, bodyWidth)} + </Text> + ); + })} + </Box> + ); +} + +type FormPaneContent = Extract<RightPaneContent, { kind: "form" }>; +type LaneDeleteFormContent = FormPaneContent & { command: "lane-delete" }; + +function LaneDeleteFormPane({ + content, + formValues, + activeFormField, + width, +}: { + content: LaneDeleteFormContent; + formValues: Record<string, string>; + activeFormField: number; + width: number; +}) { + const inner = Math.max(12, width - 4); + const meta = content.laneDelete; + const scope = formValues.scope === "local_branch" || formValues.scope === "remote_branch" + ? formValues.scope + : "worktree"; + const force = formValues.force === "yes"; + const confirm = formValues.confirm ?? ""; + const remoteName = formValues.remoteName?.trim() || "origin"; + const confirmMatch = Boolean(meta?.laneName) && confirm === meta?.laneName; + const fields = content.fields; + let scopeHint = "remove worktree only; keep branches"; + if (scope === "local_branch") scopeHint = "also delete the local branch"; + else if (scope === "remote_branch") scopeHint = `also delete ${remoteName}/${meta?.branchRef ?? "branch"}`; + const activeName = fields[activeFormField]?.name ?? fields[0]?.name ?? "scope"; + const active = (name: string) => activeName === name; + const scopeOption = (value: string, label: string) => ( + <Text color={scope === value ? theme.color.error : theme.color.t3} bold={scope === value}> + {scope === value ? `[${label}]` : ` ${label} `} + </Text> + ); + + return ( + <Box flexDirection="column"> + <Text color={theme.color.error} bold>Destructive action</Text> + <Text color={theme.color.t2} wrap="truncate-end"> + {meta ? endTruncate(meta.laneName, inner) : "No active lane"} + </Text> + {meta?.branchRef ? ( + <Text color={theme.color.t4} wrap="truncate-end">⎇ {tailTruncate(meta.branchRef, Math.max(8, inner - 2))}</Text> + ) : null} + {meta?.dirty ? ( + <Box marginTop={1}> + <Text color={theme.color.attention}>● uncommitted changes detected</Text> + </Box> + ) : null} + + <Box flexDirection="column" marginTop={1}> + <Text color={active("scope") ? theme.color.violet : theme.color.t3} bold={active("scope")}> + {active("scope") ? theme.rail : " "} Scope + </Text> + <Text> + {" "} + {scopeOption("worktree", "worktree")} + <Text> </Text> + {scopeOption("local_branch", "local")} + <Text> </Text> + {scopeOption("remote_branch", "remote")} + </Text> + <Text color={theme.color.t4} dimColor> + {" "}{scopeHint} + </Text> + </Box> + + <Box flexDirection="column" marginTop={1}> + <Text + color={active("remoteName") ? theme.color.violet : scope === "remote_branch" ? theme.color.t3 : theme.color.t4} + bold={active("remoteName")} + dimColor={scope !== "remote_branch" && !active("remoteName")} + > + {active("remoteName") ? theme.rail : " "} Remote name + </Text> + <Text color={scope === "remote_branch" ? theme.color.t1 : theme.color.t4} dimColor={scope !== "remote_branch"}> + {" "}{scope === "remote_branch" ? endTruncate(remoteName, inner - 2) : "used only for remote branch"} + </Text> + </Box> + + <Box flexDirection="column" marginTop={1}> + <Text color={active("force") ? theme.color.violet : theme.color.t3} bold={active("force")}> + {active("force") ? theme.rail : " "} Force delete + </Text> + <Text color={force ? theme.color.error : theme.color.t4}> + {" "}{force ? "[x]" : "[ ]"} skip safety checks + </Text> + </Box> + + <Box flexDirection="column" marginTop={1}> + <Text color={active("confirm") ? theme.color.violet : theme.color.t3} bold={active("confirm")}> + {active("confirm") ? theme.rail : " "} Type lane name + </Text> + <Text color={confirmMatch ? theme.color.error : theme.color.t1} wrap="truncate-end"> + {" "}{endTruncate(confirm || "required before delete", inner - 2)} + </Text> + </Box> + + <Box marginTop={1}> + <Text color={confirmMatch ? theme.color.error : theme.color.t4} dimColor={!confirmMatch}> + {confirmMatch ? "enter deletes this lane" : "↑↓ rows · ←→ scope · space force · esc cancel"} + </Text> + </Box> + </Box> + ); +} + // --------------------------------------------------------------------------- // Pane title resolution // --------------------------------------------------------------------------- @@ -769,7 +1060,7 @@ export function RightPane({ ) : null} {content.kind === "details" ? ( - <Text color={theme.color.t2}>{content.body}</Text> + <DetailsPane title={content.title} body={content.body} width={paneWidth} /> ) : null} {content.kind === "diff" ? ( @@ -818,9 +1109,11 @@ export function RightPane({ /> ) : null} - {content.kind === "new-chat-setup" ? ( + {content.kind === "new-chat-setup" || content.kind === "model-setup" ? ( <Box flexDirection="column"> - <Text color={theme.color.t4} dimColor>Lane: {content.laneLabel}</Text> + {content.kind === "new-chat-setup" ? ( + <Text color={theme.color.t4} dimColor>Lane: {content.laneLabel}</Text> + ) : null} <Box flexDirection="column" marginTop={1}> {content.rows.map((row, index) => { const selected = index === selectedIndex; @@ -839,12 +1132,32 @@ export function RightPane({ ); })} </Box> - <Text color={theme.color.t4} dimColor>↑↓ rows · ←→ change · ↵ prompt · cmd+↵ background</Text> + <Text color={theme.color.t4} dimColor> + {content.kind === "new-chat-setup" + ? "↑↓ rows · ←→ change · ↵ prompt · cmd+↵ background" + : "↑↓ rows · ←→ change · ↵ apply · esc close"} + </Text> </Box> ) : null} - {content.kind === "form" ? ( + {content.kind === "form" && content.command === "lane-delete" ? ( + <LaneDeleteFormPane + content={content as LaneDeleteFormContent} + formValues={formValues} + activeFormField={activeFormField} + width={paneWidth} + /> + ) : null} + + {content.kind === "form" && content.command !== "lane-delete" ? ( <Box flexDirection="column"> + {content.description ? ( + <Box marginBottom={1}> + <Text color={theme.color.t4} dimColor wrap="truncate-end"> + {endTruncate(content.description, Math.max(8, paneWidth - 4))} + </Text> + </Box> + ) : null} {content.fields.map((field, index) => { const value = formValues[field.name]?.trim(); const displayValue = endTruncate( diff --git a/apps/ade-cli/src/tuiClient/components/TerminalPane.tsx b/apps/ade-cli/src/tuiClient/components/TerminalPane.tsx index 1c68a067a..e40082716 100644 --- a/apps/ade-cli/src/tuiClient/components/TerminalPane.tsx +++ b/apps/ade-cli/src/tuiClient/components/TerminalPane.tsx @@ -103,6 +103,96 @@ function stripTerminalControls(value: string): string { .replace(/\r(?!\n)/g, "\n"); } +const CLAUDE_SPINNER_RE = /^[✻✶✳✢✽·◐◑◒◓⏺●○]\s*(?:\d+|[.·…-]+)?$/; +const CLAUDE_SPINNER_STATUS_WORDS = [ + "baked", + "churned", + "churning", + "cogitated", + "crunched", + "crunching", + "hatching", + "orbiting", + "pondered", + "thinking", + "tinkered", + "topsy", + "topy", + "working", +] as const; +const CLAUDE_SPINNER_STATUS_RE = new RegExp( + `^[✻✶✳✢✽·◐◑◒◓]\\s*(?:${CLAUDE_SPINNER_STATUS_WORDS.join("|")})\\b`, + "i", +); +const CLAUDE_USAGE_STATUS_RE = /^\(?\d+s\s+·\s+↓\s*[\d.]+[kKmM]?\s+tokens?\)?$/; +const TERMINAL_BOX_CHROME_RE = /^[\s╭╮╰╯│┃┌┐└┘├┤┬┴┼─━═║╔╗╚╝╟╢╤╧╪]+$/; +const CLAUDE_INPUT_CHROME_RE = /^│\s*(?:[>›?]\s*.*)?\s*│?$/; +const CLAUDE_PROMPT_CHROME_RE = /^[❯>›]\s*/; +const CLAUDE_FOOTER_CHROME_RE = /^(?:esc|ctrl|shift\+tab|tab|enter|return|auto-accept|bypass permissions|accept edits|edit mode)\b/i; +const CLAUDE_SESSION_CHROME_RE = /(?:Claude Code\s*v?\d|What's new|Welcome back|release-notes|Statusline JSON|Claude Max|Organization|MCP server failed|connector needs auth|Claude in Chrome enabled|Resume this session with:|claude --resume|for shortcuts|low\s*·\s*\/effort|Added\s*`?claude|agent_id|parent_agent_id|~\/.*(?:Projects|worktrees)|\/mcp|\/chrome)/i; +const CLAUDE_LOGO_RE = /[▐▛▜▝▘█]{2,}/; + +function normalizeClosedTerminalLine(line: string): string { + return line + .replace(/\u00a0/g, " ") + .replace(/^[⏺●]\s*/, ""); +} + +function isShortClaudeSpinnerResidue(line: string): boolean { + const trimmed = line.trim(); + if (!trimmed || trimmed.length > 10) return false; + if (!/^[✻✶✳✢✽·◐◑◒◓….\sA-Za-z]+$/.test(trimmed)) return false; + const letters = trimmed.replace(/[✻✶✳✢✽·◐◑◒◓….\s]/g, "").toLowerCase(); + if (!letters) return true; + if (letters === "ok" || letters === "yes" || letters === "no") return false; + return letters.length <= 1 + || (letters.length <= 4 && /^[a-z]+$/.test(letters)) + || CLAUDE_SPINNER_STATUS_WORDS.some((word) => word.includes(letters)); +} + +function isNoisyClosedTerminalLine(line: string): boolean { + const normalized = normalizeClosedTerminalLine(line); + const trimmed = normalized.trim(); + if (!trimmed) return false; + if (CLAUDE_SPINNER_RE.test(trimmed)) return true; + if (CLAUDE_SPINNER_STATUS_RE.test(trimmed)) return true; + if (CLAUDE_USAGE_STATUS_RE.test(trimmed)) return true; + if (TERMINAL_BOX_CHROME_RE.test(trimmed)) return true; + if (CLAUDE_INPUT_CHROME_RE.test(trimmed) && /[╭╮╰╯│┃─━═]/.test(normalized)) return true; + if (CLAUDE_PROMPT_CHROME_RE.test(trimmed)) return true; + if (CLAUDE_FOOTER_CHROME_RE.test(trimmed)) return true; + if (CLAUDE_SESSION_CHROME_RE.test(trimmed)) return true; + if (CLAUDE_LOGO_RE.test(trimmed)) return true; + if (/^·\s*esc to interrupt$/i.test(trimmed)) return true; + if (/^←\s*for agents$/i.test(trimmed)) return true; + return false; +} + +function compactClosedTerminalTranscript(value: string): string[] { + const lines = stripTerminalControls(value).split(/\r\n|\n|\r/); + const compacted: string[] = []; + let lastWasBlank = false; + for (const rawLine of lines) { + const line = normalizeClosedTerminalLine(rawLine).trimEnd(); + if (isNoisyClosedTerminalLine(line)) continue; + const blank = line.trim().length === 0; + if (blank && lastWasBlank) continue; + compacted.push(line); + lastWasBlank = blank; + } + while (compacted.length && compacted[0]!.trim().length === 0) compacted.shift(); + while (compacted.length && compacted[compacted.length - 1]!.trim().length === 0) compacted.pop(); + const numericResidueCount = compacted.filter((line) => /^\d{1,3}$/.test(line.trim())).length; + if (numericResidueCount >= 4) { + return compacted.filter((line) => !/^\d{1,3}$/.test(line.trim())); + } + const spinnerResidueCount = compacted.filter(isShortClaudeSpinnerResidue).length; + if (spinnerResidueCount >= 4) { + return compacted.filter((line) => !isShortClaudeSpinnerResidue(line)); + } + return compacted; +} + 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))); @@ -226,9 +316,11 @@ export function styledRowsFromSnapshotRows(rows: TerminalSnapshotRow[], maxRows: } function transcriptPreviewRows(transcript: string | null | undefined, maxRows: number): TerminalStyledRow[] { - const text = stripTerminalControls(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: {} }] })); + const lines = compactClosedTerminalTranscript(transcript ?? ""); + if (!lines.length) { + return [{ runs: [{ text: "Terminal closed. Resume the chat to view live output.", style: {} }] }]; + } + return lines.slice(-maxRows).map((line) => ({ runs: [{ text: line || " ", style: {} }] })); } function fallbackPreviewRows(preview: ChatTerminalPreviewResult | null, maxRows: number): TerminalStyledRow[] { diff --git a/apps/ade-cli/src/tuiClient/linearCommands.ts b/apps/ade-cli/src/tuiClient/linearCommands.ts index 02e5f9482..24d34158d 100644 --- a/apps/ade-cli/src/tuiClient/linearCommands.ts +++ b/apps/ade-cli/src/tuiClient/linearCommands.ts @@ -86,9 +86,13 @@ function tool(title: string, toolName: string, args: Record<string, unknown> = { export function buildLinearToolRequest(input: string): LinearToolRequest { const parsed = parseLinearArgs(input); - const [group = "workflows", modeArg, ...rest] = parsed.positionals; + const [group, modeArg, ...rest] = parsed.positionals; const options = parsed.options; + if (!group) { + return usage("Linear", "Usage: /linear <workflows|run|route|sync|ingress> ..."); + } + if (group === "workflows") { return tool("Linear workflows", "listLinearWorkflows"); } diff --git a/apps/ade-cli/src/tuiClient/rightPaneFormatters.ts b/apps/ade-cli/src/tuiClient/rightPaneFormatters.ts new file mode 100644 index 000000000..727be7940 --- /dev/null +++ b/apps/ade-cli/src/tuiClient/rightPaneFormatters.ts @@ -0,0 +1,269 @@ +type JsonRecord = Record<string, unknown>; + +function isRecord(value: unknown): value is JsonRecord { + return Boolean(value) && typeof value === "object" && !Array.isArray(value); +} + +function unwrapStructured(value: unknown): unknown { + if (isRecord(value) && isRecord(value.structuredContent)) return value.structuredContent; + return value; +} + +function asString(value: unknown): string | null { + if (typeof value === "string" && value.trim()) return value.trim(); + if (typeof value === "number" && Number.isFinite(value)) return String(value); + return null; +} + +function asBoolean(value: unknown): boolean | null { + return typeof value === "boolean" ? value : null; +} + +function pickString(record: JsonRecord, keys: string[]): string | null { + for (const key of keys) { + const value = asString(record[key]); + if (value) return value; + } + return null; +} + +function pickBoolean(record: JsonRecord, keys: string[]): boolean | null { + for (const key of keys) { + const value = asBoolean(record[key]); + if (value != null) return value; + } + return null; +} + +function firstRecordArray(value: unknown, keys: string[]): JsonRecord[] { + const root = unwrapStructured(value); + if (Array.isArray(root)) return root.filter(isRecord); + if (!isRecord(root)) return []; + for (const key of keys) { + const candidate = root[key]; + if (Array.isArray(candidate)) return candidate.filter(isRecord); + } + return []; +} + +function truncate(value: unknown, max = 96): string { + const text = (asString(value) ?? "") + .replace(/!\[[^\]]*]\([^)]+\)/g, " ") + .replace(/\[([^\]]+)]\([^)]+\)/g, "$1") + .replace(/<[^>]+>/g, " ") + .replace(/[`*_>#|]+/g, " ") + .replace(/\s+/g, " ") + .trim(); + if (text.length <= max) return text; + return `${text.slice(0, Math.max(0, max - 1))}…`; +} + +function shortIso(value: unknown): string | null { + const text = asString(value); + if (!text) return null; + const match = text.match(/^(\d{4}-\d{2}-\d{2})(?:T(\d{2}:\d{2}))?/); + return match ? `${match[1]}${match[2] ? ` ${match[2]}` : ""}` : text; +} + +function statusWord(status: unknown, conclusion?: unknown): "OK" | "FAIL" | "WAIT" | "SKIP" | string { + const rawConclusion = asString(conclusion)?.toLowerCase() ?? ""; + const rawStatus = asString(status)?.toLowerCase() ?? ""; + const raw = rawConclusion && rawConclusion !== "null" ? rawConclusion : rawStatus; + if (!raw) return "unknown"; + if (["success", "passed", "passing", "completed", "ready", "ok"].includes(raw)) return "OK"; + if (["failure", "failed", "failing", "error", "timed_out", "cancelled", "action_required", "changes_requested"].includes(raw)) return "FAIL"; + if (["pending", "running", "in_progress", "queued", "requested", "waiting"].includes(raw)) return "WAIT"; + if (["neutral", "skipped", "stale"].includes(raw)) return "SKIP"; + return raw.toUpperCase(); +} + +function formatCount(noun: string, count: number): string { + return `${count} ${noun}${count === 1 ? "" : "s"}`; +} + +function commentPreview(record: JsonRecord): string { + const author = pickString(record, ["author", "user", "reviewer", "login"]) ?? "unknown"; + const body = truncate(record.body ?? record.comment ?? record.summary, 84) || "(no body)"; + return `${author}: ${body}`; +} + +function threadLocation(thread: JsonRecord): string { + const path = pickString(thread, ["path", "filePath", "filename"]) ?? "conversation"; + const line = pickString(thread, ["line", "originalLine", "startLine"]); + return line ? `${path}:${line}` : path; +} + +export function formatSystemDetails(args: { + project: { projectRoot?: string; workspaceRoot?: string }; + pid: number; + mode?: string; +}): string { + const rows = [ + ["project", args.project.projectRoot ?? "unknown"], + ["workspace", args.project.workspaceRoot ?? "unknown"], + ["mode", args.mode ?? "ready"], + ["process", String(args.pid)], + ["node", process.version], + ["platform", `${process.platform} ${process.arch}`], + ]; + return rows.map(([key, value]) => `${key.padEnd(10)} ${value}`).join("\n"); +} + +export function formatPrSummary(value: unknown): string { + const pr = unwrapStructured(value); + if (!isRecord(pr)) return "No PR data."; + const number = pickString(pr, ["number", "githubPrNumber", "prNumber"]); + const state = pickString(pr, ["state", "status"]) ?? "unknown"; + const draft = pickBoolean(pr, ["isDraft", "draft"]) === true ? " · draft" : ""; + const title = pickString(pr, ["title", "name"]) ?? "Untitled PR"; + const id = pickString(pr, ["id", "prId"]); + const lane = pickString(pr, ["laneName", "laneId"]); + const head = pickString(pr, ["headBranch", "headRefName", "branchRef", "branch"]); + const base = pickString(pr, ["baseBranch", "baseRefName", "baseRef", "targetBranch"]); + const url = pickString(pr, ["htmlUrl", "url", "webUrl"]); + const mergeable = pickString(pr, ["mergeable", "mergeStateStatus"]); + const rows = [ + `#${number ?? id ?? "?"} · ${state}${draft}`, + title, + "", + id ? `id ${id}` : null, + lane ? `lane ${lane}` : null, + head || base ? `branch ${head ?? "unknown"}${base ? ` -> ${base}` : ""}` : null, + mergeable ? `merge ${mergeable}` : null, + url ? `url ${url}` : null, + ]; + return rows.filter((row): row is string => row != null).join("\n"); +} + +export function formatPrChecks(value: unknown): string { + const checks = firstRecordArray(value, ["checks", "items", "results"]); + if (!checks.length) return "No PR checks."; + let ok = 0; + let fail = 0; + let wait = 0; + for (const check of checks) { + const status = statusWord(check.status, check.conclusion); + if (status === "OK") ok += 1; + else if (status === "FAIL") fail += 1; + else if (status === "WAIT") wait += 1; + } + const summary = [ok ? `${ok} passing` : null, fail ? `${fail} failing` : null, wait ? `${wait} pending` : null] + .filter(Boolean) + .join(" · ") || `${checks.length} check${checks.length === 1 ? "" : "s"}`; + return [ + `PR checks · ${summary}`, + "", + ...checks.slice(0, 16).map((check) => { + const status = statusWord(check.status, check.conclusion).padEnd(4); + const name = truncate(pickString(check, ["name", "context", "workflowName"]) ?? "unnamed check", 72); + const when = shortIso(check.completedAt ?? check.startedAt); + return `${status} ${name}${when ? ` · ${when}` : ""}`; + }), + ].join("\n"); +} + +export function formatPrReview(value: unknown): string { + const root = unwrapStructured(value); + const reviews = firstRecordArray(root, ["reviews"]); + const threads = firstRecordArray(root, ["reviewThreads", "threads"]); + const comments = firstRecordArray(root, ["comments", "issueComments"]); + const lines = [ + `PR review · ${formatCount("review", reviews.length)} · ${formatCount("thread", threads.length)} · ${formatCount("comment", comments.length)}`, + ]; + if (reviews.length) { + lines.push("", "Reviews"); + for (const review of reviews.slice(0, 8)) { + const state = statusWord(review.state, review.conclusion); + const who = pickString(review, ["reviewer", "author", "user"]) ?? "unknown"; + const body = truncate(review.body, 76); + lines.push(`- ${state} ${who}${body ? `: ${body}` : ""}`); + } + } + if (threads.length) { + lines.push("", "Review threads"); + for (const thread of threads.slice(0, 8)) { + const state = pickBoolean(thread, ["isResolved", "resolved"]) ? "resolved" : "open"; + const commentsInThread = Array.isArray(thread.comments) ? thread.comments.filter(isRecord) : []; + const firstComment = commentsInThread[0] ?? thread; + lines.push(`- ${state} ${threadLocation(thread)} · ${commentPreview(firstComment)}`); + } + } + if (comments.length) { + lines.push("", "Issue comments"); + for (const comment of comments.slice(0, 8)) { + lines.push(`- ${commentPreview(comment)}`); + } + } + if (!reviews.length && !threads.length && !comments.length) lines.push("", "No PR reviews or comments."); + return lines.join("\n"); +} + +export function formatPrComments(value: unknown): string { + const root = unwrapStructured(value); + const summary = isRecord(root) && isRecord(root.summary) ? root.summary : null; + const threads = firstRecordArray(root, ["reviewThreads", "threads"]); + const comments = firstRecordArray(root, ["comments", "issueComments"]); + const headerParts = [ + summary ? pickString(summary, ["checksStatus"]) : null, + summary ? `${asString(summary.actionableComments) ?? "0"} actionable` : null, + ].filter(Boolean); + const lines = [`PR comments${headerParts.length ? ` · ${headerParts.join(" · ")}` : ""}`]; + if (threads.length) { + lines.push("", "Review threads"); + for (const thread of threads.slice(0, 10)) { + const state = pickBoolean(thread, ["isResolved", "resolved"]) ? "resolved" : "open"; + const commentsInThread = Array.isArray(thread.comments) ? thread.comments.filter(isRecord) : []; + const firstComment = commentsInThread[0] ?? thread; + lines.push(`- ${state} ${threadLocation(thread)} · ${commentPreview(firstComment)}`); + } + } + if (comments.length) { + lines.push("", "Issue comments"); + for (const comment of comments.slice(0, 10)) { + lines.push(`- ${commentPreview(comment)}`); + } + } + if (!threads.length && !comments.length) lines.push("", "No actionable PR comments."); + return lines.join("\n"); +} + +export function formatMemorySearch(value: unknown): string { + const root = unwrapStructured(value); + const record = isRecord(root) ? root : {}; + const memories = firstRecordArray(record, ["memories", "items", "results"]); + const query = pickString(record, ["query"]) ?? "project"; + const scope = pickString(record, ["scope"]) ?? "project"; + const status = pickString(record, ["status"]) ?? "all"; + if (!memories.length) return `No ${scope} memories matched "${query}" (${status}).`; + const lines = [`Memory · ${scope} · ${status} · ${formatCount("match", memories.length)}`, ""]; + for (const memory of memories.slice(0, 10)) { + const meta = [ + pickString(memory, ["status"]), + pickString(memory, ["category"]), + pickString(memory, ["importance"]), + ].filter(Boolean).join("/"); + const id = pickString(memory, ["id"]); + lines.push(`- ${meta || "memory"}${id ? ` ${id}` : ""}`); + lines.push(` ${truncate(memory.content, 96) || "(empty)"}`); + } + return lines.join("\n"); +} + +export function formatLinearStatus(value: unknown): string { + const root = unwrapStructured(value); + if (!isRecord(root)) return "Linear status is not available."; + const connected = pickBoolean(root, ["connected"]); + const tokenStored = pickBoolean(root, ["tokenStored"]); + const oauthAvailable = pickBoolean(root, ["oauthAvailable"]); + const rows = [ + ["connected", connected == null ? "unknown" : connected ? "yes" : "no"], + ["token", tokenStored == null ? "unknown" : tokenStored ? "stored" : "missing"], + ["auth", pickString(root, ["authMode"]) ?? "unknown"], + ["oauth", oauthAvailable == null ? "unknown" : oauthAvailable ? "available" : "unavailable"], + ["viewer", pickString(root, ["viewerName", "viewerId"]) ?? "not signed in"], + ["org", pickString(root, ["organizationName", "organizationUrlKey", "organizationId"]) ?? "unknown"], + ["expires", shortIso(root.tokenExpiresAt) ?? "unknown"], + ["checked", shortIso(root.checkedAt) ?? "unknown"], + ]; + return rows.map(([key, rowValue]) => `${key.padEnd(10)} ${rowValue}`).join("\n"); +} diff --git a/apps/ade-cli/src/tuiClient/types.ts b/apps/ade-cli/src/tuiClient/types.ts index b8ae77f70..21096dda4 100644 --- a/apps/ade-cli/src/tuiClient/types.ts +++ b/apps/ade-cli/src/tuiClient/types.ts @@ -170,7 +170,15 @@ export type RightPaneContent = | { kind: "form"; title: string; - command: "new-lane" | "rename" | "pr-open" | "feedback"; + command: "new-lane" | "rename" | "pr-open" | "feedback" | "lane-delete" | "new-lane-from-unstaged"; + description?: string; + laneId?: string; + laneDelete?: { + laneId: string; + laneName: string; + branchRef: string | null; + dirty: boolean; + }; fields: Array<{ name: string; label: string; diff --git a/apps/desktop/resources/agent-skills/ade-browser/SKILL.md b/apps/desktop/resources/agent-skills/ade-browser/SKILL.md index fb72ef03d..d0005a495 100644 --- a/apps/desktop/resources/agent-skills/ade-browser/SKILL.md +++ b/apps/desktop/resources/agent-skills/ade-browser/SKILL.md @@ -9,6 +9,16 @@ description: Use this skill when using ADE's built-in browser pane, shared brows The ADE browser is global, not lane-scoped. Use socket mode so CLI calls and the Work sidebar share the same tabs. +## How `ade browser` reaches the desktop + +The CLI does not own the browser pane. `BuiltInBrowserService` lives in Electron main because it owns a `WebContentsView`, so the runtime daemon (`ade serve`, which runs under `ELECTRON_RUN_AS_NODE=1` with no Electron APIs) can't host it directly. + +Calls travel: CLI → runtime daemon (`~/.ade/sock/ade.sock`) → desktop bridge socket (`<adeHome>/sock/desktop-bridge.sock`) → real `BuiltInBrowserService` in Electron main → response back. The runtime registers a lazy JSON-RPC proxy whose allowlisted methods (`getStatus`, `showPanel`, `setBounds`, `navigate`, `createTab`, `switchTab`, `closeTab`, `reload`, `goBack`, `goForward`, `stop`, `startInspect`, `stopInspect`, `captureScreenshot`, `selectPoint`, `selectCurrent`, `clearSelection`) forward over the bridge. + +Requirement: ADE Desktop must be running with a project open. Without it, calls fail with `Desktop browser bridge not running at <path>. Open ADE Desktop with a project to enable \`ade browser\` commands.` — that's the headless case, not a bug. Other runtime domains keep working. + +Override the bridge socket path with `ADE_DESKTOP_BRIDGE_SOCKET_PATH` for dev launches. + ## Common commands ```bash diff --git a/apps/desktop/resources/agent-skills/ade-cli-control-plane/SKILL.md b/apps/desktop/resources/agent-skills/ade-cli-control-plane/SKILL.md index 43c0feff4..9f35468dd 100644 --- a/apps/desktop/resources/agent-skills/ade-cli-control-plane/SKILL.md +++ b/apps/desktop/resources/agent-skills/ade-cli-control-plane/SKILL.md @@ -20,6 +20,14 @@ Use normal shell commands for local repo edits, tests, and Git inspection. Use ` Use `--socket` when the CLI and ADE desktop drawer must share live state. This matters for App Control, iOS Simulator, Preview Lab, browser tabs, terminal logs, context selection, and proof drawer updates. +## Runtime daemon vs. desktop bridge + +Most domains (`lane`, `git`, `chat`, `app_control`, `ios_simulator`, `macos_vm`, etc.) run **inside the runtime daemon** at `~/.ade/sock/ade.sock` and work whether or not the desktop is open. + +A small set of domains require the **desktop bridge** because the underlying service needs real Electron APIs. Today that is just `built_in_browser` (it owns a `WebContentsView`), but expect the list to grow if more Electron-only services get exposed to the CLI. The runtime forwards these calls over `<adeHome>/sock/desktop-bridge.sock` (override with `ADE_DESKTOP_BRIDGE_SOCKET_PATH`). + +When no desktop is running, calls into a bridge-backed domain surface as `Domain unavailable` or `Desktop browser bridge not running at <path>. Open ADE Desktop with a project to enable \`ade browser\` commands.` — report the blocker and continue with the rest of the control plane, which is unaffected. + ## Fallback path If `command -v ade` fails: diff --git a/apps/desktop/scripts/dev.cjs b/apps/desktop/scripts/dev.cjs index 0b83b97c4..fe29f5a39 100644 --- a/apps/desktop/scripts/dev.cjs +++ b/apps/desktop/scripts/dev.cjs @@ -8,6 +8,14 @@ const path = require("node:path"); const projectRoot = path.resolve(__dirname, ".."); const distMainFile = path.join(projectRoot, "dist", "main", "main.cjs"); const npxCommand = "npx"; + +// ADE chat shells export ELECTRON_RUN_AS_NODE=1 so the `ade` shim can run +// cli.cjs through the bundled Electron binary as Node. If that leaks into +// `npm run dev:desktop`, npx-launched Electron starts in Node mode: +// Chromium flags become "bad option" errors and `require("electron")` returns +// a string (so `app.isPackaged` crashes in main.ts). Strip it once so every +// child inherits a clean env. +delete process.env.ELECTRON_RUN_AS_NODE; const forceViteOptimize = process.argv.includes("--force-vite") || process.env.ADE_VITE_FORCE_OPTIMIZE === "1"; diff --git a/apps/desktop/src/main/main.ts b/apps/desktop/src/main/main.ts index 83d5272d9..ca4e36f1e 100644 --- a/apps/desktop/src/main/main.ts +++ b/apps/desktop/src/main/main.ts @@ -172,6 +172,7 @@ import { createComputerUseArtifactBrokerService } from "./services/computerUse/c import { createIosSimulatorService } from "./services/ios/iosSimulatorService"; import { createAppControlService } from "./services/appControl/appControlService"; import { createBuiltInBrowserService } from "./services/builtInBrowser/builtInBrowserService"; +import { startBuiltInBrowserDesktopBridgeServer } from "./services/builtInBrowser/desktopBridgeServer"; import { createMacosVmService } from "./services/macosVm/macosVmService"; import { configureBuiltInBrowserWebAuthn } from "./services/builtInBrowser/builtInBrowserWebAuthn"; import { LocalRuntimeConnectionPool } from "./services/localRuntime/localRuntimeConnectionPool"; @@ -1078,6 +1079,32 @@ app.whenReady().then(async () => { onEvent: (payload) => broadcast(IPC.builtInBrowserEvent, payload), }); + // Side-channel JSON-RPC server that lets the runtime daemon proxy + // `ade browser …` CLI calls into this Electron main process. + // The daemon runs under ELECTRON_RUN_AS_NODE and can't host the browser + // service itself (it needs WebContentsView). The bridge socket lives under + // `<adeHome>/sock/desktop-bridge.sock`; the daemon discovers it via + // resolveMachineAdeLayout() or ADE_DESKTOP_BRIDGE_SOCKET_PATH. + const builtInBrowserBridgeLogger = createFileLogger( + path.join(app.getPath("userData"), "desktop-bridge.jsonl"), + ); + const builtInBrowserBridgeSocketPath = + process.env.ADE_DESKTOP_BRIDGE_SOCKET_PATH?.trim() + || machineAdeLayout.desktopBridgeSocketPath; + let builtInBrowserBridgeServer: ReturnType<typeof startBuiltInBrowserDesktopBridgeServer> | null = null; + try { + builtInBrowserBridgeServer = startBuiltInBrowserDesktopBridgeServer({ + socketPath: builtInBrowserBridgeSocketPath, + service: builtInBrowserService, + logger: builtInBrowserBridgeLogger, + }); + } catch (error) { + builtInBrowserBridgeLogger.warn("built_in_browser_bridge.start_failed", { + socketPath: builtInBrowserBridgeSocketPath, + error: error instanceof Error ? error.message : String(error), + }); + } + const loadPty = () => { // node-pty is a native dependency; keep the require inside the main process runtime. // eslint-disable-next-line @typescript-eslint/no-var-requires @@ -5505,6 +5532,11 @@ app.whenReady().then(async () => { } catch { // ignore } + try { + builtInBrowserBridgeServer?.dispose(); + } catch { + // ignore + } setForegroundProject(null); dormantContext = createDormantProjectContext(previousRoot); diff --git a/apps/desktop/src/main/services/ai/tools/ctoOperatorTools.ts b/apps/desktop/src/main/services/ai/tools/ctoOperatorTools.ts index 2937e2f2e..76aa86c92 100644 --- a/apps/desktop/src/main/services/ai/tools/ctoOperatorTools.ts +++ b/apps/desktop/src/main/services/ai/tools/ctoOperatorTools.ts @@ -2628,7 +2628,7 @@ export function createCtoOperatorTools(deps: CtoOperatorToolDeps): Record<string }); tools.gitStashPop = tool({ - description: "Pop a stash saved for a lane branch. Defaults to the latest branch-matching stash.", + description: "Pop a stash saved for a lane branch. Defaults to the latest branch-matching stash; call gitStashList to inspect refs.", inputSchema: z.object({ laneId: z.string().optional(), stashRef: z.string().optional() }), execute: ({ laneId, stashRef }) => gitGuard(async () => { const resolvedLaneId = resolveLaneId(laneId); diff --git a/apps/desktop/src/main/services/builtInBrowser/desktopBridgeServer.ts b/apps/desktop/src/main/services/builtInBrowser/desktopBridgeServer.ts new file mode 100644 index 000000000..42388c701 --- /dev/null +++ b/apps/desktop/src/main/services/builtInBrowser/desktopBridgeServer.ts @@ -0,0 +1,184 @@ +import fs from "node:fs"; +import net from "node:net"; +import path from "node:path"; +import { + JsonRpcError, + JsonRpcErrorCode, + startJsonRpcServer, + type JsonRpcRequest, + type JsonRpcTransport, +} from "../../../../../ade-cli/src/jsonrpc"; +import type { Logger } from "../logging/logger"; +import type { BuiltInBrowserService } from "./builtInBrowserService"; + +/** + * Side-channel JSON-RPC server that exposes the desktop's + * `BuiltInBrowserService` to the runtime daemon. The daemon proxies + * `ade browser …` CLI calls through this socket because it cannot host + * `BuiltInBrowserService` itself (Electron-only APIs). + * + * Methods are addressed as `built_in_browser.<allowlistedName>`. Anything + * outside the allowlist returns `methodNotFound` so a daemon bug or + * out-of-date desktop doesn't accidentally expose private internals. + */ + +const ALLOWED_METHODS = new Set([ + "getStatus", + "showPanel", + "setBounds", + "navigate", + "createTab", + "switchTab", + "closeTab", + "reload", + "goBack", + "goForward", + "stop", + "startInspect", + "stopInspect", + "captureScreenshot", + "selectPoint", + "selectCurrent", + "clearSelection", +]); + +export type BuiltInBrowserDesktopBridgeServer = { + socketPath: string; + dispose: () => void; +}; + +export function startBuiltInBrowserDesktopBridgeServer(args: { + socketPath: string; + service: BuiltInBrowserService; + logger: Logger; +}): BuiltInBrowserDesktopBridgeServer { + const { socketPath, service, logger } = args; + const isNamedPipe = socketPath.startsWith("\\\\"); + + if (!isNamedPipe) { + try { + fs.mkdirSync(path.dirname(socketPath), { recursive: true }); + } catch (error) { + logger.warn("built_in_browser_bridge.sockdir_create_failed", { + socketPath, + reason: error instanceof Error ? error.message : String(error), + }); + } + try { + fs.unlinkSync(socketPath); + } catch { + // ignore — only succeeds if a stale socket file exists + } + } + + const activeServerHandles = new Set<() => void>(); + const activeSockets = new Set<net.Socket>(); + + const server = net.createServer((conn) => { + activeSockets.add(conn); + const transport: JsonRpcTransport = { + onData(callback) { + conn.on("data", callback); + }, + write(data) { + conn.write(data); + }, + close() { + if (!conn.destroyed) conn.destroy(); + }, + }; + const stop = startJsonRpcServer(handleRequest, transport, { nonFatal: true }); + activeServerHandles.add(stop); + conn.on("close", () => { + activeSockets.delete(conn); + activeServerHandles.delete(stop); + stop(); + }); + conn.on("error", () => { + // ignore per-connection errors; they are surfaced via the JSON-RPC frame. + }); + }); + + server.on("error", (error) => { + logger.error("built_in_browser_bridge.server_error", { + socketPath, + reason: error instanceof Error ? error.message : String(error), + }); + }); + + server.listen(socketPath, () => { + logger.info("built_in_browser_bridge.listening", { socketPath }); + }); + + async function handleRequest(request: JsonRpcRequest): Promise<unknown> { + const method = request.method ?? ""; + if (!method.startsWith("built_in_browser.")) { + throw new JsonRpcError( + JsonRpcErrorCode.methodNotFound, + `Unsupported method '${method}'. Desktop bridge only handles built_in_browser.*`, + ); + } + const name = method.slice("built_in_browser.".length); + if (!ALLOWED_METHODS.has(name)) { + throw new JsonRpcError( + JsonRpcErrorCode.methodNotFound, + `Action 'built_in_browser.${name}' is not exposed by the desktop bridge.`, + ); + } + const callable = (service as unknown as Record<string, unknown>)[name]; + if (typeof callable !== "function") { + throw new JsonRpcError( + JsonRpcErrorCode.methodNotFound, + `Desktop bridge cannot dispatch built_in_browser.${name}.`, + ); + } + const params = request.params; + try { + // The real service methods accept either an args object or no args at all. + if (params === undefined) { + return await (callable as () => Promise<unknown>).call(service); + } + return await (callable as (input: unknown) => Promise<unknown>).call(service, params); + } catch (error) { + if (error instanceof JsonRpcError) throw error; + throw new JsonRpcError( + JsonRpcErrorCode.internalError, + error instanceof Error ? error.message : String(error), + ); + } + } + + return { + socketPath, + dispose: () => { + for (const stop of activeServerHandles) { + try { + stop(); + } catch { + // ignore + } + } + activeServerHandles.clear(); + for (const sock of activeSockets) { + try { + sock.destroy(); + } catch { + // ignore + } + } + activeSockets.clear(); + try { + server.close(); + } catch { + // ignore + } + if (!isNamedPipe) { + try { + fs.unlinkSync(socketPath); + } catch { + // ignore + } + } + }, + }; +} diff --git a/apps/desktop/src/main/services/chat/agentChatService.test.ts b/apps/desktop/src/main/services/chat/agentChatService.test.ts index d7ff9f1d0..0fc896e54 100644 --- a/apps/desktop/src/main/services/chat/agentChatService.test.ts +++ b/apps/desktop/src/main/services/chat/agentChatService.test.ts @@ -4399,6 +4399,174 @@ describe("createAgentChatService", () => { turnDone!(); await expect(sendPromise).resolves.toBeUndefined(); }); + + it("flags task_type=background as background and propagates taskType through the lifecycle", async () => { + const events: AgentChatEventEnvelope[] = []; + let streamCall = 0; + let warmupComplete = false; + let turnDone: (() => void) | null = null; + const turnDonePromise = new Promise<void>((resolve) => { turnDone = resolve; }); + const send = vi.fn().mockResolvedValue(undefined); + const setPermissionMode = vi.fn().mockResolvedValue(undefined); + const stream = vi.fn(() => (async function* () { + streamCall += 1; + if (streamCall === 1) { + yield { type: "system", subtype: "init", session_id: "sdk-bg-1", slash_commands: [] }; + warmupComplete = true; + yield { type: "result", usage: { input_tokens: 1, output_tokens: 1 } }; + return; + } + // Bash run_in_background-style task: no Task tool; SDK directly emits + // task_started with task_type: "background". + yield { + type: "system", + subtype: "task_started", + task_id: "task-bg-1", + description: "Launch dev desktop with desktop RPC socket enabled", + task_type: "background", + }; + yield { + type: "system", + subtype: "task_notification", + task_id: "task-bg-1", + status: "completed", + summary: "Process exited", + }; + await turnDonePromise; + yield { type: "result", usage: { input_tokens: 1, output_tokens: 1 } }; + })()); + vi.mocked(claudeSdkCreateSessionCompat).mockReturnValue({ + send, + stream, + close: vi.fn(), + sessionId: "sdk-bg-1", + setPermissionMode, + } as any); + + const { service } = createService({ + onEvent: (event: AgentChatEventEnvelope) => events.push(event), + }); + + const session = await service.createSession({ + laneId: "lane-1", + provider: "claude", + model: "sonnet", + }); + + await vi.waitFor(() => { + expect(warmupComplete).toBe(true); + }); + + const sendPromise = service.sendMessage({ + sessionId: session.id, + text: "Kick off a background task.", + }); + + await waitForEvent( + events, + (e): e is AgentChatEventEnvelope => + e.event.type === "subagent_result" && (e.event as any).taskId === "task-bg-1", + ); + + const startEnvelope = events.find( + (e) => e.event.type === "subagent_started" && (e.event as any).taskId === "task-bg-1", + ); + const resultEnvelope = events.find( + (e) => e.event.type === "subagent_result" && (e.event as any).taskId === "task-bg-1", + ); + + expect((startEnvelope?.event as any)?.background).toBe(true); + expect((startEnvelope?.event as any)?.taskType).toBe("background"); + expect((resultEnvelope?.event as any)?.taskType).toBe("background"); + + turnDone!(); + await expect(sendPromise).resolves.toBeUndefined(); + }); + + it("suppresses task_* events entirely when the SDK marks them skip_transcript", async () => { + const events: AgentChatEventEnvelope[] = []; + let streamCall = 0; + let warmupComplete = false; + let turnDone: (() => void) | null = null; + const turnDonePromise = new Promise<void>((resolve) => { turnDone = resolve; }); + const send = vi.fn().mockResolvedValue(undefined); + const setPermissionMode = vi.fn().mockResolvedValue(undefined); + const stream = vi.fn(() => (async function* () { + streamCall += 1; + if (streamCall === 1) { + yield { type: "system", subtype: "init", session_id: "sdk-skip-1", slash_commands: [] }; + warmupComplete = true; + yield { type: "result", usage: { input_tokens: 1, output_tokens: 1 } }; + return; + } + // Ambient task — session title generator. Must not surface anywhere. + yield { + type: "system", + subtype: "task_started", + task_id: "task-ambient-1", + description: "Generate session title", + task_type: "other", + skip_transcript: true, + }; + yield { + type: "system", + subtype: "task_progress", + task_id: "task-ambient-1", + summary: "thinking…", + }; + yield { + type: "system", + subtype: "task_notification", + task_id: "task-ambient-1", + status: "completed", + summary: "Done", + }; + await turnDonePromise; + yield { type: "result", usage: { input_tokens: 1, output_tokens: 1 } }; + })()); + vi.mocked(claudeSdkCreateSessionCompat).mockReturnValue({ + send, + stream, + close: vi.fn(), + sessionId: "sdk-skip-1", + setPermissionMode, + } as any); + + const { service } = createService({ + onEvent: (event: AgentChatEventEnvelope) => events.push(event), + }); + + const session = await service.createSession({ + laneId: "lane-1", + provider: "claude", + model: "sonnet", + }); + + await vi.waitFor(() => { + expect(warmupComplete).toBe(true); + }); + + const sendPromise = service.sendMessage({ + sessionId: session.id, + text: "Drive an ambient task.", + }); + + // Give the runtime a beat to flush events for the ambient task. + await vi.waitFor(() => { + expect(events.some((e) => e.event.type === "status")).toBe(true); + }); + + const subagentEvents = events.filter((e) => + e.event.type === "subagent_started" + || e.event.type === "subagent_progress" + || e.event.type === "subagent_result" + ); + + expect(subagentEvents).toEqual([]); + + turnDone!(); + await expect(sendPromise).resolves.toBeUndefined(); + }); }); // -------------------------------------------------------------------------- diff --git a/apps/desktop/src/main/services/chat/agentChatService.ts b/apps/desktop/src/main/services/chat/agentChatService.ts index 9e168b275..70950d172 100644 --- a/apps/desktop/src/main/services/chat/agentChatService.ts +++ b/apps/desktop/src/main/services/chat/agentChatService.ts @@ -551,6 +551,18 @@ type ClaudeRuntime = { finalSummary?: string; agentType?: string; agentId?: string; + /** + * Cached at spawn time so task_progress / task_notification handlers don't + * have to re-derive them from the message. Set for Claude SDK runs only. + */ + taskType?: "subagent" | "background" | "local_workflow" | "cron" | "other"; + workflowName?: string; + /** + * SDK marks ambient/housekeeping tasks (e.g. session-title generation) with + * skip_transcript=true. Stashed here so completion events can be suppressed + * symmetrically with the spawn. + */ + skipTranscript?: boolean; }>; /** * Stash for Task-tool inputs captured at the assistant tool_use boundary, @@ -2535,6 +2547,22 @@ function isBackgroundTask(item: Record<string, unknown>): boolean { return !!(item.run_in_background || item.background); } +const CLAUDE_TASK_TYPES = ["subagent", "background", "local_workflow", "cron", "other"] as const; +type ClaudeTaskType = (typeof CLAUDE_TASK_TYPES)[number]; +const CLAUDE_TASK_TYPE_SET = new Set<ClaudeTaskType>(CLAUDE_TASK_TYPES); + +function normalizeClaudeTaskType(value: unknown): ClaudeTaskType | undefined { + if (typeof value !== "string") return undefined; + const trimmed = value.trim(); + return CLAUDE_TASK_TYPE_SET.has(trimmed as ClaudeTaskType) ? (trimmed as ClaudeTaskType) : undefined; +} + +function normalizeClaudeWorkflowName(value: unknown): string | undefined { + if (typeof value !== "string") return undefined; + const trimmed = value.trim(); + return trimmed.length ? trimmed : undefined; +} + function taskParentToolUseId(item: Record<string, unknown>): string | null { const parentToolUseId = item.parent_tool_use_id ?? item.tool_use_id; return typeof parentToolUseId === "string" && parentToolUseId.trim().length @@ -9762,18 +9790,23 @@ export function createAgentChatService(args: { continue; } - // system:task_progress — running subagent summary/usage + // system:task_progress — running task summary/usage if (msg.type === "system" && (msg as any).subtype === "task_progress") { const taskMsg = msg as any; const taskId = String(taskMsg.task_id ?? ""); if (!taskId) continue; const existing = runtime.activeSubagents.get(taskId); + // If the spawn was filtered as ambient/housekeeping, drop progress + // notifications too so the panel stays symmetrical. + if (existing?.skipTranscript) continue; const description = String(taskMsg.description ?? existing?.description ?? ""); const parentToolUseId = taskParentToolUseId(taskMsg as Record<string, unknown>) ?? existing?.parentToolUseId ?? null; const stashed = parentToolUseId ? runtime.taskToolInputByToolUseId.get(parentToolUseId) : undefined; const agentType = existing?.agentType ?? stashed?.subagentType ?? stashed?.name; const agentId = existing?.agentId ?? (typeof taskMsg.agent_id === "string" && taskMsg.agent_id.trim().length ? taskMsg.agent_id.trim() : undefined); + const taskType = existing?.taskType ?? normalizeClaudeTaskType(taskMsg.task_type); + const workflowName = existing?.workflowName ?? normalizeClaudeWorkflowName(taskMsg.workflow_name); runtime.activeSubagents.set(taskId, { taskId, description, @@ -9782,6 +9815,8 @@ export function createAgentChatService(args: { finalSummary: existing?.finalSummary, ...(agentType ? { agentType } : {}), ...(agentId ? { agentId } : {}), + ...(taskType ? { taskType } : {}), + ...(workflowName ? { workflowName } : {}), }); emitChatEvent(managed, { type: "subagent_progress", @@ -9797,14 +9832,34 @@ export function createAgentChatService(args: { durationMs: typeof taskMsg.usage.duration_ms === "number" ? taskMsg.usage.duration_ms : undefined, } : undefined, lastToolName: typeof taskMsg.last_tool_name === "string" ? taskMsg.last_tool_name : undefined, + ...(taskType ? { taskType } : {}), + ...(workflowName ? { workflowName } : {}), turnId, }); continue; } - // system:task_started — subagent spawn + // system:task_started — a task in the SDK started. task_type tells us + // what kind: "subagent" (Task tool), "background" (Bash run_in_background + // and similar), "local_workflow" (e.g. /spec), "cron" (scheduled), or + // "other". The SDK also marks ambient tasks (session-title generator, + // summary writer) with skip_transcript=true; those must not surface. if (msg.type === "system" && (msg as any).subtype === "task_started") { const taskMsg = msg as any; + if (taskMsg.skip_transcript === true) { + const skippedId = typeof taskMsg.task_id === "string" && taskMsg.task_id.trim().length + ? taskMsg.task_id.trim() + : null; + if (skippedId) { + runtime.activeSubagents.set(skippedId, { + taskId: skippedId, + description: typeof taskMsg.description === "string" ? taskMsg.description : "", + parentToolUseId: taskParentToolUseId(taskMsg as Record<string, unknown>), + skipTranscript: true, + }); + } + continue; + } const taskId = String(taskMsg.task_id ?? randomUUID()); const parentToolUseId = taskParentToolUseId(taskMsg as Record<string, unknown>); const stashed = parentToolUseId ? runtime.taskToolInputByToolUseId.get(parentToolUseId) : undefined; @@ -9817,7 +9872,16 @@ export function createAgentChatService(args: { const agentId = typeof taskMsg.agent_id === "string" && taskMsg.agent_id.trim().length ? taskMsg.agent_id.trim() : undefined; - const background = isBackgroundTask(taskMsg as Record<string, unknown>) || stashed?.isBackground === true; + const taskType = normalizeClaudeTaskType(taskMsg.task_type); + const workflowName = normalizeClaudeWorkflowName(taskMsg.workflow_name); + // The SDK sets task_type explicitly. When absent, fall back to the + // legacy hints (run_in_background field, Task-tool stash) so older + // SDK versions keep behaving as before. + const background = taskType === "background" + || taskType === "cron" + || taskType === "local_workflow" + || isBackgroundTask(taskMsg as Record<string, unknown>) + || stashed?.isBackground === true; runtime.activeSubagents.set(taskId, { taskId, description, @@ -9825,6 +9889,8 @@ export function createAgentChatService(args: { background, ...(agentType ? { agentType } : {}), ...(agentId ? { agentId } : {}), + ...(taskType ? { taskType } : {}), + ...(workflowName ? { workflowName } : {}), }); emitChatEvent(managed, { type: "subagent_started", @@ -9834,23 +9900,32 @@ export function createAgentChatService(args: { parentToolUseId, description, background, + ...(taskType ? { taskType } : {}), + ...(workflowName ? { workflowName } : {}), turnId, }); continue; } - // system:task_notification — subagent completed + // system:task_notification — task completed (subagent, background, or + // workflow). Suppress when the matching spawn was filtered as ambient. if (msg.type === "system" && (msg as any).subtype === "task_notification") { const taskMsg = msg as any; const taskId = String(taskMsg.task_id ?? ""); if (!taskId) continue; const existing = runtime.activeSubagents.get(taskId); + if (existing?.skipTranscript) { + runtime.activeSubagents.delete(taskId); + continue; + } const parentToolUseId = taskParentToolUseId(taskMsg as Record<string, unknown>) ?? existing?.parentToolUseId ?? null; const summary = String(taskMsg.summary ?? existing?.finalSummary ?? ""); const stashed = parentToolUseId ? runtime.taskToolInputByToolUseId.get(parentToolUseId) : undefined; const agentType = existing?.agentType ?? stashed?.subagentType ?? stashed?.name; const agentId = existing?.agentId ?? (typeof taskMsg.agent_id === "string" && taskMsg.agent_id.trim().length ? taskMsg.agent_id.trim() : undefined); + const taskType = existing?.taskType ?? normalizeClaudeTaskType(taskMsg.task_type); + const workflowName = existing?.workflowName ?? normalizeClaudeWorkflowName(taskMsg.workflow_name); runtime.activeSubagents.delete(taskId); if (parentToolUseId) runtime.taskToolInputByToolUseId.delete(parentToolUseId); emitChatEvent(managed, { @@ -9867,6 +9942,8 @@ export function createAgentChatService(args: { toolUses: typeof taskMsg.usage.tool_uses === "number" ? taskMsg.usage.tool_uses : undefined, durationMs: typeof taskMsg.usage.duration_ms === "number" ? taskMsg.usage.duration_ms : undefined, } : undefined, + ...(taskType ? { taskType } : {}), + ...(workflowName ? { workflowName } : {}), turnId, }); continue; @@ -20309,9 +20386,21 @@ export function createAgentChatService(args: { } }; + const modelCatalogContainsRefreshProvider = ( + catalog: AgentChatModelCatalog, + provider: AgentChatModelCatalogRefreshProvider, + ): boolean => { + return (catalog.groups ?? []).some((group) => { + const groupMatches = group.key === provider; + if (!groupMatches) return false; + return (group.providers ?? []).some((entry) => entry.modelCount > 0); + }); + }; + const isModelCatalogRefreshStale = (refreshProvider?: AgentChatModelCatalogRefreshProvider): boolean => { if (!modelCatalogCache) return true; if (refreshProvider) { + if (refreshProvider === "cursor" && !modelCatalogContainsRefreshProvider(modelCatalogCache, refreshProvider)) return true; const refreshedAt = modelCatalogProviderRefreshedAt.get(refreshProvider); return !refreshedAt || Date.now() - refreshedAt > modelCatalogRefreshTtlMs(refreshProvider); } @@ -20327,6 +20416,15 @@ export function createAgentChatService(args: { stale, }); + const shouldMarkModelCatalogProviderFresh = ( + catalog: AgentChatModelCatalog, + refreshProvider: AgentChatModelCatalogRefreshProvider | undefined, + ): boolean => { + if (!refreshProvider) return true; + if (refreshProvider !== "cursor") return true; + return modelCatalogContainsRefreshProvider(catalog, refreshProvider); + }; + const discoverOpenCodeLocalModels = async (): Promise<DiscoveredLocalModelEntry[]> => { const auth = await detectAuth(); const snapshot = projectConfigService.get(); @@ -20746,7 +20844,7 @@ export function createAgentChatService(args: { })), }; modelCatalogCache = catalog; - if (mode !== "cached") { + if (mode !== "cached" && shouldMarkModelCatalogProviderFresh(catalog, refreshProvider)) { markModelCatalogProviderFresh(refreshProvider, Date.now()); } return catalog; diff --git a/apps/desktop/src/main/services/remoteRuntime/remoteRuntime.offlineRpc.integration.test.ts b/apps/desktop/src/main/services/remoteRuntime/remoteRuntime.offlineRpc.integration.test.ts index fa98421a6..8705b8730 100644 --- a/apps/desktop/src/main/services/remoteRuntime/remoteRuntime.offlineRpc.integration.test.ts +++ b/apps/desktop/src/main/services/remoteRuntime/remoteRuntime.offlineRpc.integration.test.ts @@ -146,6 +146,7 @@ function createRegistry() { secretsDir: path.join(root, "home", "secrets"), sockDir: path.join(root, "home", "sock"), socketPath: path.join(root, "home", "sock", "ade.sock"), + desktopBridgeSocketPath: path.join(root, "home", "sock", "desktop-bridge.sock"), binDir: path.join(root, "home", "bin"), runtimeDir: path.join(root, "home", "runtime"), }); diff --git a/apps/desktop/src/main/services/runtime/machineStateMigration.test.ts b/apps/desktop/src/main/services/runtime/machineStateMigration.test.ts index c6d724e9e..0ae85fbd7 100644 --- a/apps/desktop/src/main/services/runtime/machineStateMigration.test.ts +++ b/apps/desktop/src/main/services/runtime/machineStateMigration.test.ts @@ -19,6 +19,7 @@ function makeLayout(root: string): MachineAdeLayout { secretsDir: path.join(root, "secrets"), sockDir: path.join(root, "sock"), socketPath: path.join(root, "sock", "ade.sock"), + desktopBridgeSocketPath: path.join(root, "sock", "desktop-bridge.sock"), binDir: path.join(root, "bin"), runtimeDir: path.join(root, "runtime"), }; diff --git a/apps/desktop/src/main/services/runtime/machineStateMigration.ts b/apps/desktop/src/main/services/runtime/machineStateMigration.ts index bad070e01..65aa74d14 100644 --- a/apps/desktop/src/main/services/runtime/machineStateMigration.ts +++ b/apps/desktop/src/main/services/runtime/machineStateMigration.ts @@ -28,6 +28,7 @@ function buildMachineLayout(adeDir: string): MachineAdeLayout { secretsDir, sockDir, socketPath: path.join(sockDir, "ade.sock"), + desktopBridgeSocketPath: path.join(sockDir, "desktop-bridge.sock"), binDir: path.join(adeDir, "bin"), runtimeDir: path.join(adeDir, "runtime"), }; diff --git a/apps/desktop/src/renderer/components/chat/AgentChatComposer.test.tsx b/apps/desktop/src/renderer/components/chat/AgentChatComposer.test.tsx index e4c5b052d..737de60bb 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatComposer.test.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatComposer.test.tsx @@ -330,6 +330,34 @@ describe("AgentChatComposer", () => { }); }); + it("moves from the prompt to image attachments and removes them from the keyboard", () => { + const props = renderComposer({ + turnActive: false, + draft: "", + attachments: [{ path: "/tmp/pasted-image.png", type: "image" }], + }); + + const textbox = screen.getByRole("textbox") as HTMLTextAreaElement; + textbox.focus(); + textbox.setSelectionRange(0, 0); + + fireEvent.keyDown(textbox, { key: "ArrowUp" }); + + const imageButton = screen.getByRole("button", { name: "Open pasted-image.png" }); + expect(document.activeElement).toBe(imageButton); + + fireEvent.keyDown(imageButton, { key: "ArrowDown" }); + expect(document.activeElement).toBe(textbox); + + textbox.focus(); + textbox.setSelectionRange(0, 0); + fireEvent.keyDown(textbox, { key: "ArrowUp" }); + fireEvent.keyDown(imageButton, { key: "Delete" }); + + expect(props.onRemoveAttachment).toHaveBeenCalledWith("/tmp/pasted-image.png"); + expect(document.activeElement).toBe(textbox); + }); + it("stop only interrupts the active turn", () => { const props = renderComposer(); diff --git a/apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx b/apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx index 249f336dd..011c7d299 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx @@ -33,14 +33,19 @@ import { buildChatContextAttachmentPrompt, makeLinearIssueContextAttachment, } from "../../../shared/chatContextAttachments"; -import { getModelById, modelSupportsFastMode } from "../../../shared/modelRegistry"; +import { getModelById, modelSupportsFastMode, type ProviderFamily } from "../../../shared/modelRegistry"; import { cn } from "../ui/cn"; import { ModelPicker } from "../shared/ModelPicker/ModelPicker"; +import type { AuthStatus } from "../shared/ModelPicker/ModelPickerRail"; import { resolveModelDescriptorWithRuntimeCatalog } from "../shared/ModelPicker/modelCatalog"; import { ReasoningEffortPicker } from "../shared/ModelPicker/ReasoningEffortPicker"; import { getPermissionOptions, safetyColors } from "../shared/permissionOptions"; import { CodexTokenInline } from "./codex/CodexTokenInline"; -import { ChatAttachmentTray, type ChatAttachmentPendingImage } from "./ChatAttachmentTray"; +import { + ChatAttachmentTray, + CHAT_IMAGE_ATTACHMENT_FOCUS_SELECTOR, + type ChatAttachmentPendingImage, +} from "./ChatAttachmentTray"; import { ChatComposerShell } from "./ChatComposerShell"; import { LaneDialogShell } from "../lanes/LaneDialogShell"; import { LinearIssueBrowser, linearBrowserIssueToLaneIssue } from "../app/LinearIssueBrowser"; @@ -715,6 +720,7 @@ export function AgentChatComposer({ sdkSlashCommands = [], modelId, availableModelIds, + providerAuthStatus, reasoningEffort, codexFastMode = false, codexTokenUsage = null, @@ -834,6 +840,7 @@ export function AgentChatComposer({ sdkSlashCommands?: AgentChatSlashCommand[]; modelId: string; availableModelIds?: string[]; + providerAuthStatus?: Partial<Record<ProviderFamily, AuthStatus>>; reasoningEffort: string | null; codexFastMode?: boolean; codexTokenUsage?: CodexThreadTokenUsage | null; @@ -993,6 +1000,7 @@ export function AgentChatComposer({ const attachmentInputRef = useRef<HTMLInputElement | null>(null); const uploadInputRef = useRef<HTMLInputElement | null>(null); + const attachmentTrayRef = useRef<HTMLDivElement | null>(null); const textareaRef = useRef<HTMLTextAreaElement | null>(null); const richEditorRef = useRef<HTMLDivElement | null>(null); const richSelectionRef = useRef<Range | null>(null); @@ -1162,6 +1170,21 @@ export function AgentChatComposer({ onRemoveAttachment(path); }, [clearPreviewForPath, onRemoveAttachment]); + const focusComposerInput = useCallback(() => { + const target = useRichComposer ? richEditorRef.current : textareaRef.current; + target?.focus({ preventScroll: true }); + }, [useRichComposer]); + + const focusLastImageAttachment = useCallback((): boolean => { + const targets = Array.from( + attachmentTrayRef.current?.querySelectorAll<HTMLElement>(CHAT_IMAGE_ATTACHMENT_FOCUS_SELECTOR) ?? [], + ); + const target = targets.at(-1); + if (!target) return false; + target.focus({ preventScroll: true }); + return true; + }, []); + useEffect(() => { const previous = previousImagePreviewUrlsRef.current; for (const [path, previousUrl] of Object.entries(previous)) { @@ -2424,6 +2447,17 @@ export function AgentChatComposer({ if (event.key === "Enter" || event.key === "Tab") { event.preventDefault(); commandMenuRef.current?.selectCurrent(); return; } } + if (event.key === "ArrowUp" && !commandModified && !event.shiftKey && !event.altKey) { + const target = event.currentTarget; + const atPromptStart = target instanceof HTMLTextAreaElement + ? target.selectionStart === 0 && target.selectionEnd === 0 + : getRichCursorTextOffset() === 0; + if (atPromptStart && focusLastImageAttachment()) { + event.preventDefault(); + return; + } + } + if (event.key === "@" && !commandModified && !event.altKey) { if (!canAttach) return; // Let @ be typed into textarea; onChange will detect the trigger @@ -3134,6 +3168,7 @@ export function AgentChatComposer({ </div> ) : null} <ChatAttachmentTray + ref={attachmentTrayRef} attachments={attachments} contextAttachments={contextAttachments} pendingImageAttachments={pendingImageAttachments} @@ -3142,6 +3177,7 @@ export function AgentChatComposer({ onRemove={handleRemoveAttachment} onRemoveContext={onRemoveContextAttachment} onRemovePendingImageAttachment={removePendingImageAttachment} + onFocusPrompt={focusComposerInput} className="px-3 py-0" /> </div> @@ -3373,6 +3409,7 @@ export function AgentChatComposer({ onChange={(next) => onParallelSlotModelChange?.(parallelConfiguringIndex, next)} surfaceKey={`chat-composer-parallel-${parallelConfiguringIndex}`} {...(availableModelIds ? { availableModelIds } : {})} + {...(providerAuthStatus ? { providerAuthStatus } : {})} {...(onOpenAiSettings ? { onOpenSignIn: onOpenAiSettings } : {})} disabled={parallelLaunchBusy} compact @@ -3396,6 +3433,7 @@ export function AgentChatComposer({ onChange={onModelChange} surfaceKey="chat-composer" {...(availableModelIds ? { availableModelIds } : {})} + {...(providerAuthStatus ? { providerAuthStatus } : {})} {...(onOpenAiSettings ? { onOpenSignIn: onOpenAiSettings } : {})} disabled={modelSelectionLocked} compact 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 27c914e5a..4f8c1c167 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatPane.submit.test.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatPane.submit.test.tsx @@ -763,6 +763,127 @@ describe("AgentChatPane companion drawers", () => { }); describe("AgentChatPane submit recovery", () => { + it("hydrates a draft chat from the last launched config before first send", async () => { + const { create } = installAdeMocks({ sessions: [] }); + const launchConfigKey = [ + "ade.chat.lastLaunchConfig.v1", + "/tmp/project-under-test", + "lane-1", + "standard", + "chat", + ].map(encodeURIComponent).join(":"); + window.localStorage.setItem(launchConfigKey, JSON.stringify({ + version: 1, + modelId: "openai/gpt-5.4", + reasoningEffort: "xhigh", + codexFastMode: true, + executionMode: "focused", + updatedAt: "2026-05-20T12:00:00.000Z", + controls: { + interactionMode: "default", + claudePermissionMode: "default", + codexApprovalPolicy: "never", + codexSandbox: "danger-full-access", + codexConfigSource: "flags", + opencodePermissionMode: "edit", + droidPermissionMode: "auto-low", + cursorModeId: "agent", + cursorConfigValues: {}, + }, + })); + + renderParallelDraftPane({ + availableModelIdsOverride: ["openai/gpt-5.4"], + }); + + const modelLabel = getModelById("openai/gpt-5.4")?.displayName ?? "GPT-5.4"; + expect(await screen.findByRole("button", { name: new RegExp(`current: ${escapeRegExp(modelLabel)}`, "i") })).toBeTruthy(); + expect((screen.getByRole("button", { name: "Fast mode" })).getAttribute("aria-pressed")).toBe("true"); + expect(screen.getByLabelText("Reasoning effort").textContent).toContain("XH"); + expect(screen.getByRole("button", { name: "Codex approval preset" }).textContent).toContain("Full access"); + + const textbox = await screen.findByRole("textbox"); + fireEvent.change(textbox, { target: { value: "Launch with the restored config." } }); + fireEvent.click(await screen.findByRole("button", { name: "Send" })); + + await waitFor(() => { + expect(create).toHaveBeenCalledWith(expect.objectContaining({ + modelId: "openai/gpt-5.4", + reasoningEffort: "xhigh", + codexFastMode: true, + permissionMode: "full-auto", + codexApprovalPolicy: "never", + codexSandbox: "danger-full-access", + codexConfigSource: "flags", + })); + }); + }); + + it("prefers the newest session config over a stored launch snapshot", async () => { + const previous = buildSession("previous-session", { + status: "idle", + reasoningEffort: "high", + codexFastMode: true, + permissionMode: "full-auto", + codexApprovalPolicy: "never", + codexSandbox: "danger-full-access", + codexConfigSource: "flags", + }); + const { create } = installAdeMocks({ sessions: [previous] }); + const launchConfigKey = [ + "ade.chat.lastLaunchConfig.v1", + "/tmp/project-under-test", + "lane-1", + "standard", + "chat", + ].map(encodeURIComponent).join(":"); + window.localStorage.setItem(launchConfigKey, JSON.stringify({ + version: 1, + modelId: "openai/gpt-5.4", + reasoningEffort: "xhigh", + codexFastMode: false, + executionMode: "focused", + updatedAt: "2026-05-20T12:00:00.000Z", + controls: { + interactionMode: "default", + claudePermissionMode: "default", + codexApprovalPolicy: "on-request", + codexSandbox: "workspace-write", + codexConfigSource: "flags", + opencodePermissionMode: "edit", + droidPermissionMode: "auto-low", + cursorModeId: "agent", + cursorConfigValues: {}, + }, + })); + + renderParallelDraftPane({ + availableModelIdsOverride: ["openai/gpt-5.4"], + }); + + const approvalButton = await screen.findByRole("button", { name: "Codex approval preset" }); + await waitFor(() => { + expect(approvalButton.textContent).toContain("Full access"); + expect((screen.getByRole("button", { name: "Fast mode" })).getAttribute("aria-pressed")).toBe("true"); + expect(screen.getByLabelText("Reasoning effort").textContent).toContain("HI"); + }); + + const textbox = await screen.findByRole("textbox"); + fireEvent.change(textbox, { target: { value: "Use the newest session settings." } }); + fireEvent.click(await screen.findByRole("button", { name: "Send" })); + + await waitFor(() => { + expect(create).toHaveBeenCalledWith(expect.objectContaining({ + modelId: "openai/gpt-5.4", + reasoningEffort: "high", + codexFastMode: true, + permissionMode: "full-auto", + codexApprovalPolicy: "never", + codexSandbox: "danger-full-access", + })); + }); + }); + it("loads Claude slash commands for a draft chat before session creation", async () => { installAdeMocks({ sessions: [], includeClaudeModel: true }); vi.mocked(window.ade.agentChat.slashCommands).mockImplementation(async (args) => { diff --git a/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx b/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx index 894094142..52254944e 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx @@ -73,6 +73,7 @@ import { CURSOR_AVAILABLE_MODE_IDS } from "../../../shared/cursorModes"; import { cn } from "../ui/cn"; import { AgentChatComposer, type ParallelComposerControlSlot } from "./AgentChatComposer"; import { resolveModelDescriptorWithRuntimeCatalog } from "../shared/ModelPicker/modelCatalog"; +import { familiesFromStatus } from "../shared/ModelPicker/useProviderAuthStatus"; import { AgentChatMessageList } from "./AgentChatMessageList"; import { ChatStatusGlyph } from "./chatStatusVisuals"; import { isChatToolType } from "../../lib/sessions"; @@ -147,6 +148,7 @@ import { playAgentTurnCompletionSound } from "../../lib/agentTurnCompletionSound const LAST_MODEL_ID_KEY = "ade.chat.lastModelId"; const LAST_REASONING_KEY_PREFIX = "ade.chat.lastReasoningEffort"; +const LAST_LAUNCH_CONFIG_KEY_PREFIX = "ade.chat.lastLaunchConfig.v1"; const SUBAGENT_AUTOOPEN_FIRED_KEY_PREFIX = "ade.chat.subagentAutoOpenFired"; const SUBAGENT_AUTOOPEN_FIRED_TTL_MS = 7 * 24 * 60 * 60 * 1000; export const DEFAULT_PARALLEL_ATTACHMENT_REQUEST = "Please review the attached files."; @@ -477,6 +479,16 @@ type NativeControlState = { cursorConfigValues: Record<string, AgentChatCursorConfigValue>; }; +type LastLaunchConfig = { + version: 1; + modelId: string; + reasoningEffort: string | null; + codexFastMode: boolean; + executionMode: AgentChatExecutionMode; + controls: NativeControlState; + updatedAt: string; +}; + type ParallelModelRowState = NativeControlState & { modelId: string; reasoningEffort: string | null; @@ -484,6 +496,21 @@ type ParallelModelRowState = NativeControlState & { executionMode: AgentChatExecutionMode; }; +function launchConfigStorageKey(scope: { + projectRoot: string | null | undefined; + laneId: string | null | undefined; + surfaceProfile: ChatSurfaceProfile; + workDraftKind: "chat" | "cli"; +}): string { + return [ + LAST_LAUNCH_CONFIG_KEY_PREFIX, + scope.projectRoot?.trim() || "project", + scope.laneId?.trim() || "no-lane", + scope.surfaceProfile, + scope.workDraftKind, + ].map(encodeURIComponent).join(":"); +} + function defaultNativeControls(profile: ChatSurfaceProfile): NativeControlState { if (profile === "persistent_identity") { return { @@ -1073,6 +1100,236 @@ function resolveRegistryModelId(value: string | null | undefined): string | null return match?.id ?? null; } +const INTERACTION_MODES: readonly AgentChatInteractionMode[] = ["default", "plan"]; +const CLAUDE_PERMISSION_MODES: readonly AgentChatClaudePermissionMode[] = ["default", "auto", "plan", "acceptEdits", "bypassPermissions"]; +const CODEX_APPROVAL_POLICIES: readonly AgentChatCodexApprovalPolicy[] = ["untrusted", "on-request", "on-failure", "never"]; +const CODEX_SANDBOXES: readonly AgentChatCodexSandbox[] = ["read-only", "workspace-write", "danger-full-access"]; +const CODEX_CONFIG_SOURCES: readonly AgentChatCodexConfigSource[] = ["flags", "config-toml"]; +const OPENCODE_PERMISSION_MODES: readonly AgentChatOpenCodePermissionMode[] = ["plan", "edit", "full-auto"]; +const DROID_PERMISSION_MODES: readonly AgentChatDroidPermissionMode[] = ["read-only", "auto-low", "auto-medium", "auto-high"]; +const EXECUTION_MODES: readonly AgentChatExecutionMode[] = ["focused", "parallel", "subagents", "teams"]; + +function isRecord(value: unknown): value is Record<string, unknown> { + return value != null && typeof value === "object" && !Array.isArray(value); +} + +function pickStringEnum<T extends string>( + value: unknown, + allowed: readonly T[], + fallback: T, +): T { + return typeof value === "string" && allowed.includes(value as T) ? value as T : fallback; +} + +function legacyPermissionModeToClaudePermissionMode( + mode: AgentChatPermissionMode | undefined, +): AgentChatClaudePermissionMode | undefined { + if (mode === "full-auto") return "bypassPermissions"; + if (mode === "edit") return "acceptEdits"; + if (mode === "auto") return "auto"; + if (mode === "plan") return "plan"; + if (mode === "default") return "default"; + return undefined; +} + +function codexControlsFromPermissionMode( + mode: AgentChatPermissionMode | undefined, + fallbacks: NativeControlState, +): Pick<NativeControlState, "codexApprovalPolicy" | "codexSandbox" | "codexConfigSource"> { + if (mode === "config-toml") { + return { + codexApprovalPolicy: fallbacks.codexApprovalPolicy, + codexSandbox: fallbacks.codexSandbox, + codexConfigSource: "config-toml", + }; + } + if (mode === "full-auto") { + return { + codexApprovalPolicy: "never", + codexSandbox: "danger-full-access", + codexConfigSource: "flags", + }; + } + if (mode === "plan") { + return { + codexApprovalPolicy: "on-request", + codexSandbox: "read-only", + codexConfigSource: "flags", + }; + } + if (mode === "edit") { + return { + codexApprovalPolicy: "untrusted", + codexSandbox: "workspace-write", + codexConfigSource: "flags", + }; + } + return { + codexApprovalPolicy: "on-request", + codexSandbox: "workspace-write", + codexConfigSource: "flags", + }; +} + +function cursorConfigValuesFromSnapshot( + snapshot: AgentChatSessionSummary["cursorModeSnapshot"] | AgentChatSession["cursorModeSnapshot"] | undefined, +): Record<string, AgentChatCursorConfigValue> { + return Object.fromEntries( + (snapshot?.configOptions ?? []) + .filter((option) => option.id !== snapshot?.modeConfigId) + .flatMap((option) => option.currentValue == null ? [] : [[option.id, option.currentValue]]), + ); +} + +function normalizeCursorConfigValues(value: unknown): Record<string, AgentChatCursorConfigValue> { + if (!isRecord(value)) return {}; + return { ...value } as Record<string, AgentChatCursorConfigValue>; +} + +type LaunchConfigSessionSource = Pick< + AgentChatSessionSummary, + | "model" + | "modelId" + | "reasoningEffort" + | "codexFastMode" + | "executionMode" + | "permissionMode" + | "interactionMode" + | "claudePermissionMode" + | "codexApprovalPolicy" + | "codexSandbox" + | "codexConfigSource" + | "opencodePermissionMode" + | "droidPermissionMode" + | "cursorModeSnapshot" + | "cursorModeId" + | "cursorConfigValues" +>; + +function nativeControlsFromLaunchSource( + source: Partial<LaunchConfigSessionSource>, + defaults: NativeControlState, +): NativeControlState { + const codexFallbacks = codexControlsFromPermissionMode(source.permissionMode, defaults); + const cursorSnapshotValues = cursorConfigValuesFromSnapshot(source.cursorModeSnapshot); + return { + interactionMode: pickStringEnum( + source.interactionMode, + INTERACTION_MODES, + source.permissionMode === "plan" ? "plan" : defaults.interactionMode, + ), + claudePermissionMode: pickStringEnum( + source.claudePermissionMode, + CLAUDE_PERMISSION_MODES, + legacyPermissionModeToClaudePermissionMode(source.permissionMode) ?? defaults.claudePermissionMode, + ), + codexApprovalPolicy: pickStringEnum( + source.codexApprovalPolicy, + CODEX_APPROVAL_POLICIES, + codexFallbacks.codexApprovalPolicy, + ), + codexSandbox: pickStringEnum( + source.codexSandbox, + CODEX_SANDBOXES, + codexFallbacks.codexSandbox, + ), + codexConfigSource: pickStringEnum( + source.codexConfigSource, + CODEX_CONFIG_SOURCES, + codexFallbacks.codexConfigSource, + ), + opencodePermissionMode: pickStringEnum( + source.opencodePermissionMode, + OPENCODE_PERMISSION_MODES, + OPENCODE_PERMISSION_MODES.includes(source.permissionMode as AgentChatOpenCodePermissionMode) + ? source.permissionMode as AgentChatOpenCodePermissionMode + : defaults.opencodePermissionMode, + ), + droidPermissionMode: pickStringEnum( + source.droidPermissionMode, + DROID_PERMISSION_MODES, + legacyPermissionModeToDroidPermissionMode(source.permissionMode) ?? defaults.droidPermissionMode, + ), + cursorModeId: typeof source.cursorModeId === "string" + ? source.cursorModeId + : source.cursorModeSnapshot?.currentModeId ?? defaults.cursorModeId, + cursorConfigValues: { + ...defaults.cursorConfigValues, + ...cursorSnapshotValues, + ...normalizeCursorConfigValues(source.cursorConfigValues), + }, + }; +} + +function buildLastLaunchConfig( + source: Partial<LaunchConfigSessionSource>, + defaults: NativeControlState, + updatedAt = new Date().toISOString(), +): LastLaunchConfig | null { + const modelId = source.modelId ?? resolveRegistryModelId(source.model); + if (!modelId) return null; + const desc = getModelById(modelId); + return { + version: 1, + modelId, + reasoningEffort: source.reasoningEffort ?? null, + codexFastMode: modelSupportsFastMode(desc) && source.codexFastMode === true, + executionMode: pickStringEnum(source.executionMode, EXECUTION_MODES, "focused"), + controls: nativeControlsFromLaunchSource(source, defaults), + updatedAt, + }; +} + +function normalizeStoredLaunchConfig( + value: unknown, + defaults: NativeControlState, +): LastLaunchConfig | null { + if (!isRecord(value)) return null; + const modelId = typeof value.modelId === "string" ? value.modelId.trim() : ""; + if (!modelId) return null; + const desc = getModelById(modelId); + const controls = nativeControlsFromLaunchSource( + isRecord(value.controls) ? value.controls : {}, + defaults, + ); + return { + version: 1, + modelId, + reasoningEffort: typeof value.reasoningEffort === "string" && value.reasoningEffort.trim().length + ? value.reasoningEffort.trim() + : null, + codexFastMode: modelSupportsFastMode(desc) && value.codexFastMode === true, + executionMode: pickStringEnum(value.executionMode, EXECUTION_MODES, "focused"), + controls, + updatedAt: typeof value.updatedAt === "string" && value.updatedAt.trim().length + ? value.updatedAt.trim() + : new Date(0).toISOString(), + }; +} + +function readLastLaunchConfig(storageKey: string, defaults: NativeControlState): LastLaunchConfig | null { + try { + const raw = window.localStorage.getItem(storageKey); + if (raw) { + const parsed = normalizeStoredLaunchConfig(JSON.parse(raw), defaults); + if (parsed) return parsed; + } + } catch { + // ignore + } + + return null; +} + +function writeLastLaunchConfig(storageKey: string, config: LastLaunchConfig): void { + try { + window.localStorage.setItem(storageKey, JSON.stringify(config)); + window.localStorage.setItem(LAST_MODEL_ID_KEY, config.modelId); + } catch { + // ignore + } +} + function resolveCliRegistryModelId(provider: "codex" | "claude" | "cursor" | "droid", value: string | null | undefined): string | null { const normalized = (value ?? "").trim().toLowerCase(); if (!normalized.length) return null; @@ -1530,6 +1787,12 @@ export function AgentChatPane({ const showWorkspaceChrome = !hideWorkspaceChrome; const modelSwitchPolicy = presentation?.modelSwitchPolicy ?? "same-family-after-launch"; const initialNativeControls = useMemo(() => defaultNativeControls(surfaceProfile), [surfaceProfile]); + const lastLaunchConfigStorageKey = useMemo(() => launchConfigStorageKey({ + projectRoot, + laneId, + surfaceProfile, + workDraftKind, + }), [laneId, projectRoot, surfaceProfile, workDraftKind]); const initialCompanionStateKey = lockSessionId ?? initialSessionId ?? (laneId ? `draft:${laneId}` : "draft"); const [sessions, setSessions] = useState<AgentChatSessionSummary[]>([]); const [archivedSessions, setArchivedSessions] = useState<AgentChatSessionSummary[]>([]); @@ -1785,6 +2048,7 @@ export function AgentChatPane({ const handoffRef = useRef<HTMLDivElement | null>(null); const localTouchBySessionRef = useRef<Map<string, string>>(new Map()); const cursorWarmupKeyRef = useRef<string | null>(null); + const draftLaunchConfigHydratedRef = useRef<string | null>(null); const recoveredParallelLaunchKeyRef = useRef<string | null>(null); const selectedSession = useMemo( () => (selectedSessionId ? sessions.find((session) => session.sessionId === selectedSessionId) ?? null : null), @@ -2591,8 +2855,34 @@ export function AgentChatPane({ [parallelConfiguringRow], ); + const applyLaunchConfigToComposer = useCallback((config: LastLaunchConfig) => { + const desc = resolveModelDescriptorWithRuntimeCatalog(config.modelId) ?? getModelById(config.modelId); + const tiers = desc?.reasoningTiers ?? []; + setModelId(config.modelId); + setReasoningEffort(selectReasoningEffort({ + tiers, + preferred: config.reasoningEffort, + })); + setCodexFastMode(modelSupportsFastMode(desc) && config.codexFastMode); + setExecutionMode(config.executionMode); + setInteractionMode(config.controls.interactionMode); + setClaudePermissionMode(config.controls.claudePermissionMode); + setCodexApprovalPolicy(config.controls.codexApprovalPolicy); + setCodexSandbox(config.controls.codexSandbox); + setCodexConfigSource(config.controls.codexConfigSource); + setOpenCodePermissionMode(config.controls.opencodePermissionMode); + setDroidPermissionMode(config.controls.droidPermissionMode); + setCursorModeId(config.controls.cursorModeId); + setCursorConfigValues({ ...config.controls.cursorConfigValues }); + }, []); + const syncComposerToSession = useCallback((session: AgentChatSessionSummary | null) => { if (!session) { + const lastLaunchConfig = readLastLaunchConfig(lastLaunchConfigStorageKey, initialNativeControls); + if (lastLaunchConfig) { + applyLaunchConfigToComposer(lastLaunchConfig); + return; + } setInteractionMode(initialNativeControls.interactionMode); setClaudePermissionMode(initialNativeControls.claudePermissionMode); setCodexApprovalPolicy(initialNativeControls.codexApprovalPolicy); @@ -2631,7 +2921,7 @@ export function AgentChatPane({ .flatMap((option) => option.currentValue == null ? [] : [[option.id, option.currentValue]]), ), ); - }, [initialNativeControls]); + }, [applyLaunchConfigToComposer, initialNativeControls, lastLaunchConfigStorageKey]); const executionModeOptions = useMemo( () => getExecutionModeOptions(selectedModelDesc), [selectedModelDesc], @@ -2663,6 +2953,10 @@ export function AgentChatPane({ policy: modelSwitchPolicy, }); }, [availableModelIds, modelSwitchPolicy, selectedSessionModelId, selectedEvents.length]); + const modelPickerProviderAuthStatus = useMemo( + () => (aiStatus ? familiesFromStatus(aiStatus) : undefined), + [aiStatus], + ); const cursorCloudModelIds = useMemo( () => effectiveAvailableModelIds.filter((id) => id.startsWith("cursor/")), [effectiveAvailableModelIds], @@ -3291,6 +3585,39 @@ export function AgentChatPane({ syncComposerToSession, ]); + useEffect(() => { + if (selectedSessionId || lockSessionId) return; + const draftKey = `${projectRoot ?? "project"}:${laneId ?? "no-lane"}:${surfaceProfile}:${workDraftKind}`; + const latestSessionConfig = sessions[0] + ? buildLastLaunchConfig(sessions[0], initialNativeControls) + : null; + const sessionHydrationKey = `${draftKey}:session`; + if (latestSessionConfig) { + if (draftLaunchConfigHydratedRef.current === sessionHydrationKey) return; + applyLaunchConfigToComposer(latestSessionConfig); + draftLaunchConfigHydratedRef.current = sessionHydrationKey; + return; + } + + const storageHydrationKey = `${draftKey}:storage`; + if (draftLaunchConfigHydratedRef.current === storageHydrationKey) return; + const storedConfig = readLastLaunchConfig(lastLaunchConfigStorageKey, initialNativeControls); + if (!storedConfig) return; + applyLaunchConfigToComposer(storedConfig); + draftLaunchConfigHydratedRef.current = storageHydrationKey; + }, [ + applyLaunchConfigToComposer, + initialNativeControls, + laneId, + lastLaunchConfigStorageKey, + lockSessionId, + projectRoot, + selectedSessionId, + sessions, + surfaceProfile, + workDraftKind, + ]); + useEffect(() => { if (!isTileActive || !selectedSessionId || !selectedSessionModelId || turnActive) return; const desc = getModelById(selectedSessionModelId); @@ -4246,13 +4573,18 @@ export function AgentChatPane({ }, [initialNativeControls.opencodePermissionMode]); const notifySessionCreated = useCallback((session: AgentChatSession, options?: AgentChatSessionCreatedOptions) => { if (!onSessionCreated) return; - void Promise.resolve() - .then(() => ( - options === undefined - ? onSessionCreated(session) - : onSessionCreated(session, options) - )) - .catch((err) => { console.error("notifySessionCreated failed:", err); }); + // Call synchronously so the parent's lane/session focus state setters land + // in the same React commit as the submit handler's optimistic-message setters. + // Deferring through a microtask races with batching and leaves the new chat + // launched-but-not-visible until the user manually navigates. + try { + const result = options === undefined ? onSessionCreated(session) : onSessionCreated(session, options); + if (result && typeof (result as Promise<void>).catch === "function") { + (result as Promise<void>).catch((err) => { console.error("notifySessionCreated failed:", err); }); + } + } catch (err) { + console.error("notifySessionCreated failed:", err); + } }, [onSessionCreated]); const draftLaunchTargetIsAutoCreate = draftLaunchTargetId === AUTO_CREATE_LANE_OPTION_ID; const launchShellForDraftLane = useCallback(async () => { @@ -4280,12 +4612,15 @@ export function AgentChatPane({ const harnessPermissionMode = provider === "opencode" ? recommendedOpenCodePermissionModeForModel(permissionDesc) : null; + const launchControls = harnessPermissionMode + ? { + ...currentNativeControls, + opencodePermissionMode: harnessPermissionMode, + } + : currentNativeControls; const nativeControlPayload = harnessPermissionMode ? { - ...summarizeNativeControls(provider, { - ...currentNativeControls, - opencodePermissionMode: harnessPermissionMode, - }), + ...summarizeNativeControls(provider, launchControls), ...(provider === "cursor" ? { cursorConfigValues: currentNativeControls.cursorConfigValues } : {}), } : buildNativeControlPayload(provider); @@ -4299,6 +4634,24 @@ export function AgentChatPane({ ...(modelSupportsFastMode(desc) ? { codexFastMode } : {}), ...nativeControlPayload, }); + const launchConfig = buildLastLaunchConfig({ + model: created.model, + modelId: created.modelId ?? modelId, + reasoningEffort, + codexFastMode: modelSupportsFastMode(desc) && codexFastMode, + executionMode, + permissionMode: nativeControlPayload.permissionMode, + interactionMode: launchControls.interactionMode, + claudePermissionMode: launchControls.claudePermissionMode, + codexApprovalPolicy: launchControls.codexApprovalPolicy, + codexSandbox: launchControls.codexSandbox, + codexConfigSource: launchControls.codexConfigSource, + opencodePermissionMode: launchControls.opencodePermissionMode, + droidPermissionMode: launchControls.droidPermissionMode, + cursorModeId: launchControls.cursorModeId, + cursorConfigValues: launchControls.cursorConfigValues, + }, initialNativeControls); + if (launchConfig) writeLastLaunchConfig(lastLaunchConfigStorageKey, launchConfig); loadedHistoryRef.current.delete(created.id); optimisticSessionIdsRef.current.add(created.id); knownSessionIdsRef.current.add(created.id); @@ -4331,7 +4684,7 @@ export function AgentChatPane({ if (options.notify) notifySessionCreated(created, options.notifyOptions); if (targetLaneId === laneId) void refreshSessions().catch(() => {}); return created; - }, [buildNativeControlPayload, codexFastMode, currentNativeControls, iosSimulatorOpen, laneId, modelId, notifySessionCreated, reasoningEffort, refreshSessions, touchSession]); + }, [buildNativeControlPayload, codexFastMode, currentNativeControls, executionMode, initialNativeControls, iosSimulatorOpen, laneId, modelId, notifySessionCreated, reasoningEffort, refreshSessions, touchSession]); const createSession = useCallback(async (): Promise<string | null> => { if (createSessionPromiseRef.current) { @@ -5341,6 +5694,7 @@ export function AgentChatPane({ initialSessionId, forceDraft, embeddedWorkLayout, + lastLaunchConfigStorageKey, projectRoot, activeLaneWorktreePath, navigate, @@ -6433,6 +6787,7 @@ export function AgentChatPane({ sdkSlashCommands={sdkSlashCommands} modelId={modelId} availableModelIds={effectiveAvailableModelIds} + providerAuthStatus={modelPickerProviderAuthStatus} reasoningEffort={reasoningEffort} codexFastMode={codexFastMode} codexTokenUsage={selectedCodexTokenUsage} diff --git a/apps/desktop/src/renderer/components/chat/ChatAttachmentTray.test.tsx b/apps/desktop/src/renderer/components/chat/ChatAttachmentTray.test.tsx index c87387c13..761256469 100644 --- a/apps/desktop/src/renderer/components/chat/ChatAttachmentTray.test.tsx +++ b/apps/desktop/src/renderer/components/chat/ChatAttachmentTray.test.tsx @@ -133,6 +133,30 @@ describe("ChatAttachmentTray", () => { expect(onRemove).toHaveBeenCalledWith("/tmp/pasted-image.png"); }); + it("removes focused image attachments with delete keys and can return focus to the prompt", () => { + const onRemove = vi.fn(); + const onFocusPrompt = vi.fn(); + + render( + <ChatAttachmentTray + attachments={[{ path: "/tmp/pasted-image.png", type: "image" }]} + mode="standard" + onRemove={onRemove} + onFocusPrompt={onFocusPrompt} + />, + ); + + const openButton = screen.getByRole("button", { name: "Open pasted-image.png" }); + openButton.focus(); + + fireEvent.keyDown(openButton, { key: "ArrowDown" }); + expect(onFocusPrompt).toHaveBeenCalledTimes(1); + + fireEvent.keyDown(openButton, { key: "Backspace" }); + expect(onRemove).toHaveBeenCalledWith("/tmp/pasted-image.png"); + expect(onFocusPrompt).toHaveBeenCalledTimes(2); + }); + it("keeps non-image attachments as filename chips", () => { render( <ChatAttachmentTray diff --git a/apps/desktop/src/renderer/components/chat/ChatAttachmentTray.tsx b/apps/desktop/src/renderer/components/chat/ChatAttachmentTray.tsx index f71f96c6f..c11e07905 100644 --- a/apps/desktop/src/renderer/components/chat/ChatAttachmentTray.tsx +++ b/apps/desktop/src/renderer/components/chat/ChatAttachmentTray.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef, useState, type KeyboardEvent, type MouseEvent } from "react"; +import { forwardRef, useEffect, useRef, useState, type KeyboardEvent, type MouseEvent } from "react"; import { createPortal } from "react-dom"; import { Copy, File, Globe, Image, X } from "@phosphor-icons/react"; import type { AgentChatContextAttachment, AgentChatFileRef, ChatSurfaceMode } from "../../../shared/types"; @@ -20,6 +20,48 @@ export type ChatAttachmentPendingImage = { previewUrl?: string | null; }; +export const CHAT_IMAGE_ATTACHMENT_FOCUS_SELECTOR = "[data-chat-image-attachment-focus-target='true']"; + +function focusAdjacentImageAttachment(currentTarget: HTMLElement, delta: -1 | 1): boolean { + const root = currentTarget.closest("[data-chat-attachment-tray='true']"); + if (!(root instanceof HTMLElement)) return false; + const targets = Array.from(root.querySelectorAll<HTMLElement>(CHAT_IMAGE_ATTACHMENT_FOCUS_SELECTOR)); + const currentIndex = targets.findIndex((target) => target === currentTarget || target.contains(currentTarget)); + if (currentIndex < 0) return false; + const next = targets[currentIndex + delta]; + if (!next) return false; + next.focus({ preventScroll: true }); + return true; +} + +function handleImageAttachmentKeyDown( + event: KeyboardEvent<HTMLElement>, + args: { + onRemove?: () => void; + onFocusPrompt?: () => void; + }, +): void { + if (event.key === "ArrowDown") { + if (!args.onFocusPrompt) return; + event.preventDefault(); + event.stopPropagation(); + args.onFocusPrompt(); + return; + } + if (event.key === "ArrowLeft" || event.key === "ArrowRight") { + if (!focusAdjacentImageAttachment(event.currentTarget, event.key === "ArrowLeft" ? -1 : 1)) return; + event.preventDefault(); + event.stopPropagation(); + return; + } + if (event.key !== "Backspace" && event.key !== "Delete") return; + if (!args.onRemove) return; + event.preventDefault(); + event.stopPropagation(); + args.onRemove(); + args.onFocusPrompt?.(); +} + function LinearIssueContextChip({ attachment, onRemove, @@ -92,11 +134,13 @@ function ImageAttachmentPreview({ toneClassName, initialPreviewUrl, onRemove, + onFocusPrompt, }: { attachment: AgentChatFileRef; toneClassName: string; initialPreviewUrl?: string | null; onRemove?: (path: string) => void; + onFocusPrompt?: () => void; }) { const [dataUrl, setDataUrl] = useState<string | null>(initialPreviewUrl ?? null); const [previewFailed, setPreviewFailed] = useState(false); @@ -168,9 +212,14 @@ function ImageAttachmentPreview({ > <button type="button" - className="block h-full w-full p-0" + className="block h-full w-full p-0 focus:outline-none focus:ring-1 focus:ring-white/30" title={`Open ${name}`} aria-label={`Open ${name}`} + data-chat-image-attachment-focus-target="true" + onKeyDown={(event) => handleImageAttachmentKeyDown(event, { + onRemove: onRemove ? () => onRemove(attachment.path) : undefined, + onFocusPrompt, + })} onClick={() => { if (dataUrl) setExpanded(true); }} @@ -229,20 +278,28 @@ function PendingImageAttachmentPreview({ attachment, toneClassName, onRemove, + onFocusPrompt, }: { attachment: ChatAttachmentPendingImage; toneClassName: string; onRemove?: (id: string) => void; + onFocusPrompt?: () => void; }) { return ( <div className={cn( - "group/image relative h-14 w-14 shrink-0 overflow-hidden rounded-md border p-0 text-left transition-colors", + "group/image relative h-14 w-14 shrink-0 overflow-hidden rounded-md border p-0 text-left transition-colors focus:outline-none focus:ring-1 focus:ring-white/25", toneClassName, )} title={`Attaching ${attachment.name}`} aria-label={`Attaching ${attachment.name}`} role="status" + tabIndex={0} + data-chat-image-attachment-focus-target="true" + onKeyDown={(event) => handleImageAttachmentKeyDown(event, { + onRemove: onRemove ? () => onRemove(attachment.id) : undefined, + onFocusPrompt, + })} > {attachment.previewUrl ? ( <img @@ -280,21 +337,29 @@ function ImageUrlAttachmentChip({ label, toneClassName, onRemove, + onFocusPrompt, }: { path: string; url: string; label: string; toneClassName: string; onRemove?: (path: string) => void; + onFocusPrompt?: () => void; }) { const [imageFailed, setImageFailed] = useState(false); return ( <span className={cn( - "ade-liquid-glass-pill group inline-flex max-w-full items-center gap-2 rounded-[var(--chat-radius-pill)] px-2 py-1 transition-colors", + "ade-liquid-glass-pill group inline-flex max-w-full items-center gap-2 rounded-[var(--chat-radius-pill)] px-2 py-1 transition-colors focus:outline-none focus:ring-1 focus:ring-white/25", toneClassName, )} title={url} + tabIndex={0} + data-chat-image-attachment-focus-target="true" + onKeyDown={(event) => handleImageAttachmentKeyDown(event, { + onRemove: onRemove ? () => onRemove(path) : undefined, + onFocusPrompt, + })} > {imageFailed ? ( <Globe size={12} weight="bold" /> @@ -425,17 +490,7 @@ function ImageLightbox({ ); } -export function ChatAttachmentTray({ - attachments, - contextAttachments = [], - pendingImageAttachments = [], - imagePreviewUrls = {}, - mode, - onRemove, - onRemoveContext, - onRemovePendingImageAttachment, - className, -}: { +type ChatAttachmentTrayProps = { attachments: AgentChatFileRef[]; contextAttachments?: AgentChatContextAttachment[]; pendingImageAttachments?: ChatAttachmentPendingImage[]; @@ -444,8 +499,22 @@ export function ChatAttachmentTray({ onRemove?: (path: string) => void; onRemoveContext?: (key: string) => void; onRemovePendingImageAttachment?: (id: string) => void; + onFocusPrompt?: () => void; className?: string; -}) { +}; + +export const ChatAttachmentTray = forwardRef<HTMLDivElement, ChatAttachmentTrayProps>(function ChatAttachmentTray({ + attachments, + contextAttachments = [], + pendingImageAttachments = [], + imagePreviewUrls = {}, + mode, + onRemove, + onRemoveContext, + onRemovePendingImageAttachment, + onFocusPrompt, + className, +}, ref) { if (!attachments.length && !contextAttachments.length && !pendingImageAttachments.length) return null; let chipTone: string; @@ -465,7 +534,11 @@ export function ChatAttachmentTray({ } return ( - <div className={cn("flex flex-wrap items-center gap-2 px-4 py-3", className)}> + <div + ref={ref} + className={cn("flex flex-wrap items-center gap-2 px-4 py-3", className)} + data-chat-attachment-tray="true" + > {contextAttachments.map((attachment) => ( <LinearIssueContextChip key={chatContextAttachmentKey(attachment)} @@ -479,6 +552,7 @@ export function ChatAttachmentTray({ attachment={attachment} toneClassName={chipTone} onRemove={onRemovePendingImageAttachment} + onFocusPrompt={onFocusPrompt} /> ))} {attachments.map((attachment) => { @@ -499,6 +573,7 @@ export function ChatAttachmentTray({ label={label} toneClassName={chipTone} onRemove={onRemove} + onFocusPrompt={onFocusPrompt} /> ); } @@ -510,6 +585,7 @@ export function ChatAttachmentTray({ toneClassName={chipTone} initialPreviewUrl={imagePreviewUrls[attachment.path]} onRemove={onRemove} + onFocusPrompt={onFocusPrompt} /> ); } @@ -539,4 +615,4 @@ export function ChatAttachmentTray({ })} </div> ); -} +}); diff --git a/apps/desktop/src/renderer/components/chat/ChatSubagentsPanel.test.tsx b/apps/desktop/src/renderer/components/chat/ChatSubagentsPanel.test.tsx index 4dfe7c31d..47fd9fb90 100644 --- a/apps/desktop/src/renderer/components/chat/ChatSubagentsPanel.test.tsx +++ b/apps/desktop/src/renderer/components/chat/ChatSubagentsPanel.test.tsx @@ -84,10 +84,9 @@ describe("ChatSubagentsPanel (pane variant)", () => { ); expect(screen.getByText("Subagents")).toBeTruthy(); - expect(screen.getByText("Background tasks")).toBeTruthy(); + expect(screen.getByText("Background")).toBeTruthy(); expect(screen.getByText("Explore")).toBeTruthy(); - // Background row gets a "bg" suffix (no parentheses in the redesign). - expect(screen.getByText("bg")).toBeTruthy(); + expect(screen.getByTitle("Audit chat renderer")).toBeTruthy(); }); it("calls onSelectSubagent with the snapshot identity when a row is clicked", () => { diff --git a/apps/desktop/src/renderer/components/chat/ChatSubagentsPanel.tsx b/apps/desktop/src/renderer/components/chat/ChatSubagentsPanel.tsx index cccd06fea..28f910238 100644 --- a/apps/desktop/src/renderer/components/chat/ChatSubagentsPanel.tsx +++ b/apps/desktop/src/renderer/components/chat/ChatSubagentsPanel.tsx @@ -4,6 +4,7 @@ import { Circle, CircleHalf, CircleNotch, + MinusCircle, StopCircle, TreeStructure, X, @@ -40,38 +41,51 @@ function runtimeText(snapshot: ChatSubagentSnapshot): string | null { return parts.length ? parts.join(" · ") : null; } -/* ── Glyphs (12 px monoline, single visual family) ── +/* ── Glyphs (14 px monoline, single visual family) ── * - * One stroke weight, one diameter, one baseline. Color shifts only on - * completion; everything else stays in the fg ramp so the panel reads as a - * single calm surface. + * One stroke weight, one diameter, one baseline. Status drives color; category + * (subagent vs background) drives a slight tint on running rows so the eye can + * separate them without leaning on text alone. Stopped uses MinusCircle so it + * does NOT collide with the "never started" empty circle reading. */ -const GLYPH_SIZE = 12; +const GLYPH_SIZE = 14; -function StatusGlyph({ status }: { status: ChatSubagentSnapshot["status"] }) { +type GlyphCategory = "subagent" | "background"; + +function StatusGlyph({ + status, + category = "subagent", +}: { + status: ChatSubagentSnapshot["status"]; + category?: GlyphCategory; +}) { if (status === "running") { + const tint = category === "background" + ? "text-cyan-300/85" + : "text-[color:var(--color-accent,#A78BFA)]"; return ( <CircleNotch aria-hidden size={GLYPH_SIZE} weight="bold" - className="text-[color:var(--color-accent,#A78BFA)] motion-safe:animate-spin [animation-duration:2.4s]" + className={cn(tint, "motion-safe:animate-spin [animation-duration:2.4s]")} /> ); } if (status === "completed") { - return <Check aria-hidden size={GLYPH_SIZE} weight="bold" className="text-emerald-300/85" />; + return <Check aria-hidden size={GLYPH_SIZE} weight="bold" className="text-emerald-300/90" />; } if (status === "failed") { - return <X aria-hidden size={GLYPH_SIZE} weight="bold" className="text-rose-300/80" />; + return <X aria-hidden size={GLYPH_SIZE} weight="bold" className="text-rose-300/85" />; } - return <Circle aria-hidden size={GLYPH_SIZE} weight="regular" className="text-fg/30" />; + // stopped — visibly distinct from "pending/never started" + return <MinusCircle aria-hidden size={GLYPH_SIZE} weight="regular" className="text-amber-300/55" />; } function PlanGlyph({ status }: { status: ChatInfoPlanStep["status"] }) { if (status === "completed") { - return <Check aria-hidden size={GLYPH_SIZE} weight="bold" className="text-emerald-300/85" />; + return <Check aria-hidden size={GLYPH_SIZE} weight="bold" className="text-emerald-300/90" />; } if (status === "in_progress") { return ( @@ -84,27 +98,42 @@ function PlanGlyph({ status }: { status: ChatInfoPlanStep["status"] }) { ); } if (status === "failed") { - return <X aria-hidden size={GLYPH_SIZE} weight="bold" className="text-rose-300/80" />; + return <X aria-hidden size={GLYPH_SIZE} weight="bold" className="text-rose-300/85" />; } - return <Circle aria-hidden size={GLYPH_SIZE} weight="regular" className="text-fg/25" />; + return <Circle aria-hidden size={GLYPH_SIZE} weight="regular" className="text-fg/30" />; } /* ── Section header — sentence case, paper-section feel ── */ +type SectionTone = "subagent" | "background" | "workflow" | "neutral"; + +const SECTION_DOT_CLASS: Record<SectionTone, string> = { + subagent: "bg-[color:var(--color-accent,#A78BFA)]/70", + background: "bg-cyan-300/65", + workflow: "bg-amber-300/65", + neutral: "bg-fg/30", +}; + function SectionHeader({ label, hint, + tone = "neutral", }: { label: string; hint?: string; + tone?: SectionTone; }) { return ( <div className="flex items-baseline justify-between px-4 pb-2 pt-3.5"> - <span className="font-sans text-[11.5px] font-medium tracking-[0.005em] text-fg/55"> + <span className="flex items-center gap-2 font-sans text-[11.5px] font-medium tracking-[0.005em] text-fg/65"> + <span + aria-hidden + className={cn("inline-block h-1 w-1 rounded-full", SECTION_DOT_CLASS[tone])} + /> {label} </span> {hint ? ( - <span className="font-sans text-[11px] tabular-nums text-fg/35"> + <span className="font-sans text-[11px] tabular-nums text-fg/40"> {hint} </span> ) : null} @@ -149,17 +178,26 @@ function meaningfulName(snapshot: ChatSubagentSnapshot): string { function SubagentRow({ snapshot, selected, + category, onClick, }: { snapshot: ChatSubagentSnapshot; selected: boolean; + category: GlyphCategory; onClick: () => void; }) { const runtime = runtimeText(snapshot); const name = meaningfulName(snapshot); const isRunning = snapshot.status === "running"; - const isMuted = snapshot.status === "completed" || snapshot.status === "stopped"; + const isCompleted = snapshot.status === "completed"; + const isStopped = snapshot.status === "stopped"; const isFailed = snapshot.status === "failed"; + const runningLabelTint = category === "background" + ? "text-cyan-100/95" + : "text-[color:var(--color-accent-bright,#C4B5FD)]"; + const runningRailColor = category === "background" + ? "bg-cyan-300/55" + : "bg-[color:var(--color-accent,#A78BFA)]/55"; return ( <button @@ -171,32 +209,43 @@ function SubagentRow({ "group relative flex w-full items-center gap-3 px-4 py-1.5 text-left", "transition-colors duration-150", "hover:bg-white/[0.025]", - "data-[selected=true]:bg-white/[0.035]", + "data-[selected=true]:bg-white/[0.04]", + // Running rows get a soft left rail by default — helps the eye pick out + // active work without leaning on the spinner alone. Selected rows + // upgrade to the saturated rail. + isRunning && !selected + && cn("before:absolute before:left-0 before:top-1/2 before:h-3 before:w-px before:-translate-y-1/2", runningRailColor), selected - && "before:absolute before:left-0 before:top-1/2 before:h-3 before:w-px before:-translate-y-1/2 before:bg-[color:var(--color-accent,#A78BFA)]/55", + && "before:absolute before:left-0 before:top-1/2 before:h-3 before:w-px before:-translate-y-1/2 before:bg-[color:var(--color-accent,#A78BFA)]/85", )} > - <span className="flex h-3 w-3 shrink-0 items-center justify-center"> - <StatusGlyph status={snapshot.status} /> + <span className="flex h-3.5 w-3.5 shrink-0 items-center justify-center"> + <StatusGlyph status={snapshot.status} category={category} /> </span> <span className={cn( "min-w-0 flex-1 truncate font-sans text-[12.5px] leading-5", - isRunning && "text-[color:var(--color-accent-bright,#C4B5FD)]", - isFailed && "text-rose-200/85", - isMuted && "text-fg/45", - !isRunning && !isFailed && !isMuted && "text-fg/70", + isRunning && runningLabelTint, + isFailed && "text-rose-200/90", + isCompleted && "text-fg/55", + isStopped && "text-fg/45", + !isRunning && !isFailed && !isCompleted && !isStopped && "text-fg/75", )} > {name} - {snapshot.background ? ( - <span className="ml-1.5 font-sans text-[10.5px] tracking-[0.01em] text-fg/30"> - bg + {isStopped ? ( + <span className="ml-1.5 font-sans text-[10.5px] tracking-[0.01em] text-amber-300/55"> + halted + </span> + ) : null} + {snapshot.workflowName ? ( + <span className="ml-1.5 font-sans text-[10.5px] tracking-[0.01em] text-amber-300/65"> + {snapshot.workflowName} </span> ) : null} </span> {runtime ? ( - <span className="shrink-0 truncate font-sans text-[10.5px] tabular-nums text-fg/30 group-hover:text-fg/45"> + <span className="shrink-0 truncate font-sans text-[10.5px] tabular-nums text-fg/40 group-hover:text-fg/55"> {runtime} </span> ) : null} @@ -300,6 +349,7 @@ export function ChatSubagentsPanel({ <SectionHeader label="Progress" hint={`${planComplete}/${planTotal} · ${planPercent}%`} + tone="subagent" /> <ProgressBar percent={planPercent} /> <ul className="px-4 pt-2"> @@ -312,13 +362,13 @@ export function ChatSubagentsPanel({ key={`${index}-${step.text}`} className={cn( "flex items-start gap-2.5 py-[3px] text-[12.5px] leading-5", - isCompleted && "text-fg/40", + isCompleted && "text-fg/45", isInProgress && "text-[color:var(--color-accent-bright,#C4B5FD)]", - !isCompleted && !isInProgress && !isFailed && "text-fg/55", - isFailed && "text-rose-200/85", + !isCompleted && !isInProgress && !isFailed && "text-fg/65", + isFailed && "text-rose-200/90", )} > - <span className="mt-[3px] flex h-3 w-3 shrink-0 items-center justify-center"> + <span className="mt-[3px] flex h-3.5 w-3.5 shrink-0 items-center justify-center"> <PlanGlyph status={step.status} /> </span> <span className="min-w-0 flex-1 break-words">{step.text}</span> @@ -339,6 +389,7 @@ export function ChatSubagentsPanel({ <SectionHeader label="Subagents" hint={foreground.length ? `${foreground.length}` : undefined} + tone="subagent" /> {foreground.length ? ( <div className="pb-1"> @@ -347,12 +398,13 @@ export function ChatSubagentsPanel({ key={snap.taskId} snapshot={snap} selected={selectedTaskId === snap.taskId} + category="subagent" onClick={() => handleSelect(snap)} /> ))} </div> ) : ( - <p className="px-4 pb-2 text-[11.5px] text-fg/30"> + <p className="px-4 pb-2 text-[11.5px] text-fg/40"> None active. </p> )} @@ -361,13 +413,14 @@ export function ChatSubagentsPanel({ {/* ── Background tasks ─────────────────────────────────────── */} {background.length ? ( <section className="border-t border-white/[0.04] pb-2"> - <SectionHeader label="Background tasks" hint={`${background.length}`} /> + <SectionHeader label="Background" hint={`${background.length}`} tone="background" /> <div className="pb-1"> {background.map((snap) => ( <SubagentRow key={snap.taskId} snapshot={snap} selected={selectedTaskId === snap.taskId} + category="background" onClick={() => handleSelect(snap)} /> ))} @@ -405,15 +458,20 @@ export function ChatSubagentsPanel({ if (variant === "pane") { return ( - <div className={cn("flex h-full min-h-0 flex-col font-sans", className)}> + <div className={cn( + // Subtle elevation so the panel reads as a real surface against the + // black background instead of dissolving into it. + "flex h-full min-h-0 flex-col font-sans bg-white/[0.012]", + className, + )}> {/* Single-line header: "Work" + dimmed summary clause + close. The TreeStructure icon moved into the toggle button where it actually means something. */} <div className="flex shrink-0 items-baseline gap-3 px-4 pb-2.5 pt-3.5"> - <span className="text-[12.5px] font-medium tracking-[0.005em] text-fg/80"> + <span className="text-[12.5px] font-medium tracking-[0.005em] text-fg/85"> Work </span> - <span className="min-w-0 flex-1 truncate text-[11px] text-fg/35"> + <span className="min-w-0 flex-1 truncate text-[11px] text-fg/45"> {headerSummary} </span> {onClose ? ( diff --git a/apps/desktop/src/renderer/components/chat/chatExecutionSummary.test.ts b/apps/desktop/src/renderer/components/chat/chatExecutionSummary.test.ts index d570a366a..1b7e3484a 100644 --- a/apps/desktop/src/renderer/components/chat/chatExecutionSummary.test.ts +++ b/apps/desktop/src/renderer/components/chat/chatExecutionSummary.test.ts @@ -332,6 +332,66 @@ describe("deriveChatSubagentSnapshots", () => { ]); }); + it("propagates Claude SDK taskType and marks background-typed tasks as background", () => { + const events: AgentChatEventEnvelope[] = [ + { + sessionId: "session-1", + timestamp: "2026-03-10T12:00:00.000Z", + event: { + type: "subagent_started", + taskId: "task-bg-bash", + parentToolUseId: "tool-use-9", + description: "Launch dev desktop with desktop RPC socket enabled", + background: true, + taskType: "background", + }, + }, + { + sessionId: "session-1", + timestamp: "2026-03-10T12:00:00.500Z", + event: { + type: "subagent_started", + taskId: "task-workflow", + description: "Run the /spec workflow", + background: true, + taskType: "local_workflow", + workflowName: "spec", + }, + }, + { + sessionId: "session-1", + timestamp: "2026-03-10T12:00:01.000Z", + event: { + type: "subagent_started", + taskId: "task-agent", + agentId: "agent-1", + agentType: "Explore", + parentToolUseId: "tool-use-10", + description: "Find Subagents panel + chat logs", + taskType: "subagent", + }, + }, + ]; + + const snapshots = deriveChatSubagentSnapshots(events); + const byTask = new Map(snapshots.map((snap) => [snap.taskId, snap])); + + expect(byTask.get("task-bg-bash")).toEqual(expect.objectContaining({ + background: true, + taskType: "background", + })); + expect(byTask.get("task-workflow")).toEqual(expect.objectContaining({ + background: true, + taskType: "local_workflow", + workflowName: "spec", + })); + expect(byTask.get("task-agent")).toEqual(expect.objectContaining({ + background: false, + taskType: "subagent", + agentType: "Explore", + })); + }); + it("keys Claude snapshots by SDK agent id and preserves background state", () => { const events: AgentChatEventEnvelope[] = [ { diff --git a/apps/desktop/src/renderer/components/chat/chatExecutionSummary.ts b/apps/desktop/src/renderer/components/chat/chatExecutionSummary.ts index 5c8dcc2d3..dc2bb1cf0 100644 --- a/apps/desktop/src/renderer/components/chat/chatExecutionSummary.ts +++ b/apps/desktop/src/renderer/components/chat/chatExecutionSummary.ts @@ -25,6 +25,8 @@ export type ChatSubagentSnapshot = { finalSummary?: string | null; lastToolName?: string; background?: boolean; + taskType?: "subagent" | "background" | "local_workflow" | "cron" | "other"; + workflowName?: string; usage?: { totalTokens?: number; toolUses?: number; @@ -148,6 +150,8 @@ export function deriveChatSubagentSnapshots(events: AgentChatEventEnvelope[]): C finalSummary: existing?.finalSummary ?? null, lastToolName: existing?.lastToolName, background: event.background ?? existing?.background ?? false, + taskType: event.taskType ?? existing?.taskType, + workflowName: event.workflowName ?? existing?.workflowName, usage: existing?.usage, }); continue; @@ -169,6 +173,8 @@ export function deriveChatSubagentSnapshots(events: AgentChatEventEnvelope[]): C finalSummary: existing?.finalSummary ?? null, lastToolName: event.lastToolName ?? existing?.lastToolName, background: existing?.background ?? false, + taskType: event.taskType ?? existing?.taskType, + workflowName: event.workflowName ?? existing?.workflowName, usage: event.usage ? { ...(existing?.usage ?? {}), ...event.usage } : existing?.usage, }); continue; @@ -190,6 +196,8 @@ export function deriveChatSubagentSnapshots(events: AgentChatEventEnvelope[]): C finalSummary: event.finalSummary?.trim() || event.summary?.trim() || existing?.finalSummary || null, lastToolName: existing?.lastToolName, background: existing?.background ?? false, + taskType: event.taskType ?? existing?.taskType, + workflowName: event.workflowName ?? existing?.workflowName, usage: event.usage ? { ...(existing?.usage ?? {}), ...event.usage } : existing?.usage, }); } diff --git a/apps/desktop/src/renderer/components/missions/MissionArtifactsTab.tsx b/apps/desktop/src/renderer/components/missions/MissionArtifactsTab.tsx index 0d65961d6..74ce5aee7 100644 --- a/apps/desktop/src/renderer/components/missions/MissionArtifactsTab.tsx +++ b/apps/desktop/src/renderer/components/missions/MissionArtifactsTab.tsx @@ -6,7 +6,7 @@ import { MissionComputerUsePanel } from "./MissionComputerUsePanel"; type ArtifactGroupMode = "phase" | "step" | "worker" | "type"; -const ARTIFACT_GROUP_MODES: ArtifactGroupMode[] = ["phase", "step", "worker", "type"]; +const ARTIFACT_GROUP_MODES: readonly ArtifactGroupMode[] = ["phase", "step", "worker", "type"]; function isExternalUri(value: string): boolean { return /^https?:\/\//i.test(value); diff --git a/apps/desktop/src/renderer/components/missions/MissionChatV2.tsx b/apps/desktop/src/renderer/components/missions/MissionChatV2.tsx index f3ba165a2..5cf012335 100644 --- a/apps/desktop/src/renderer/components/missions/MissionChatV2.tsx +++ b/apps/desktop/src/renderer/components/missions/MissionChatV2.tsx @@ -365,12 +365,12 @@ export const MissionChatV2 = React.memo(function MissionChatV2({ return; } if ( - (jumpTarget.kind === "worker" || jumpTarget.kind === "teammate" || jumpTarget.kind === "coordinator" || jumpTarget.kind === "agent") + (jumpTarget.kind === "worker" || jumpTarget.kind === "workers" || jumpTarget.kind === "teammate" || jumpTarget.kind === "coordinator" || jumpTarget.kind === "agent") && !threads.length ) { return; } - if (jumpTarget.kind === "worker" || jumpTarget.kind === "agent") { + if (jumpTarget.kind === "worker" || jumpTarget.kind === "workers" || jumpTarget.kind === "agent") { if (!threads.length) return; const ct = threads.find((t) => t.threadType === "coordinator"); setSelectedChannelId(ct ? `thread:${ct.id}` : "global"); @@ -380,7 +380,10 @@ export const MissionChatV2 = React.memo(function MissionChatV2({ onJumpHandled(); }, [jumpTarget, onJumpHandled, threads]); - useEffect(() => { if (selectedChannel?.kind !== "worker" && selectedChannel?.kind !== "orchestrator") return; if (threadMessages.length > 0) setJumpNotice(null); }, [selectedChannel, threadMessages.length]); + useEffect(() => { + if (selectedChannel?.kind !== "worker" && selectedChannel?.kind !== "orchestrator") return; + if (threadMessages.length > 0) setJumpNotice(null); + }, [selectedChannel, threadMessages.length]); // ── Displayed messages ── const displayMessages = useMemo(() => { @@ -408,7 +411,14 @@ export const MissionChatV2 = React.memo(function MissionChatV2({ return threadMessages; }, [selectedChannel, threadMessages, missionId, runId, runView]); - const attemptNameMap = useMemo(() => { const m = new Map<string, string>(); for (const t of threads) if (t.attemptId) m.set(t.attemptId, t.title || (t.threadType === "coordinator" ? "Orchestrator" : "Worker")); return m; }, [threads]); + const attemptNameMap = useMemo(() => { + const map = new Map<string, string>(); + for (const thread of threads) { + if (!thread.attemptId) continue; + map.set(thread.attemptId, thread.title || (thread.threadType === "coordinator" ? "Orchestrator" : "Worker")); + } + return map; + }, [threads]); const threadIntervention = useMemo( () => findThreadIntervention({ interventions, selectedChannel, runId }), [interventions, runId, selectedChannel], diff --git a/apps/desktop/src/renderer/components/prs/detail/PrDetailPane.issueResolver.test.tsx b/apps/desktop/src/renderer/components/prs/detail/PrDetailPane.test.tsx similarity index 99% rename from apps/desktop/src/renderer/components/prs/detail/PrDetailPane.issueResolver.test.tsx rename to apps/desktop/src/renderer/components/prs/detail/PrDetailPane.test.tsx index c7cdb11f4..8468561a3 100644 --- a/apps/desktop/src/renderer/components/prs/detail/PrDetailPane.issueResolver.test.tsx +++ b/apps/desktop/src/renderer/components/prs/detail/PrDetailPane.test.tsx @@ -619,7 +619,7 @@ function renderPane(args: { }; } -describe("PrDetailPane issue resolver CTA", () => { +describe("PrDetailPane", () => { beforeEach(() => { mockUsePrs.mockReturnValue({ convergenceStatesByPrId: {}, diff --git a/apps/desktop/src/renderer/components/shared/ModelPicker/ModelPicker.test.tsx b/apps/desktop/src/renderer/components/shared/ModelPicker/ModelPicker.test.tsx index 9da41b734..972a6718b 100644 --- a/apps/desktop/src/renderer/components/shared/ModelPicker/ModelPicker.test.tsx +++ b/apps/desktop/src/renderer/components/shared/ModelPicker/ModelPicker.test.tsx @@ -129,7 +129,11 @@ vi.mock("./modelOrdering", () => ({ })); import { ModelPicker } from "./ModelPicker"; -import { resetModelPickerRuntimeCatalogForTests } from "./runtimeCatalogCache"; +import { + rememberRuntimeCatalog, + resetModelPickerRuntimeCatalogForTests, + runtimeCatalogProviderIsFresh, +} from "./runtimeCatalogCache"; import { resetRuntimeCatalogDescriptorCacheForTests } from "./modelCatalog"; const SONNET: ModelDescriptor = { @@ -460,6 +464,69 @@ describe("ModelPicker", () => { expect(document.querySelector('[data-model-picker-setup-banner="true"]')).toBeNull(); }); + it("keeps empty Cursor discovery retryable when Cursor is connected", async () => { + const user = userEvent.setup(); + providerAuthStatusInternal = { cursor: "ok" }; + const modelCatalog = vi.fn(async () => ({ + groups: [], + fetchedAt: "2026-05-18T00:00:00.000Z", + stale: false, + })); + Object.defineProperty(window, "ade", { + configurable: true, + writable: true, + value: { + agentChat: { + modelCatalog, + }, + }, + }); + + renderPicker({ onOpenSignIn: vi.fn() }); + await user.click(screen.getByRole("button", { name: /Select model/i })); + const cursorRail = document.querySelector( + '[data-rail-selection="provider:cursor"]', + ) as HTMLButtonElement; + await user.click(cursorRail); + + await waitFor(() => { + expect(modelCatalog).toHaveBeenCalledWith({ mode: "refresh-stale", refreshProvider: "cursor" }); + }); + expect(await screen.findByText("No Cursor models found")).toBeTruthy(); + expect(screen.queryByText("Connect Cursor")).toBeNull(); + + modelCatalog.mockClear(); + await user.click(cursorRail); + + await waitFor(() => { + expect(modelCatalog).toHaveBeenCalledWith({ mode: "refresh-stale", refreshProvider: "cursor" }); + }); + }); + + it("does not keep Cursor marked fresh after another refresh drops Cursor rows", () => { + rememberRuntimeCatalog({ + groups: [{ + key: "cursor", + displayName: "Cursor", + providers: [{ + key: "cursor", + displayName: "Cursor", + modelCount: 1, + subsections: [], + }], + }], + fetchedAt: "2026-05-18T00:00:00.000Z", + } as any, { mode: "force", refreshProvider: "cursor" }); + expect(runtimeCatalogProviderIsFresh("cursor")).toBe(true); + + rememberRuntimeCatalog({ + groups: [], + fetchedAt: "2026-05-18T00:00:01.000Z", + }, { mode: "force", refreshProvider: "opencode" }); + + expect(runtimeCatalogProviderIsFresh("cursor")).toBe(false); + }); + it("does not render inline reasoning chips inside model rows", async () => { const user = userEvent.setup(); recentStore.unshift(SONNET.id); diff --git a/apps/desktop/src/renderer/components/shared/ModelPicker/ModelPickerContent.tsx b/apps/desktop/src/renderer/components/shared/ModelPicker/ModelPickerContent.tsx index f4acef8e2..c7a2c8aa1 100644 --- a/apps/desktop/src/renderer/components/shared/ModelPicker/ModelPickerContent.tsx +++ b/apps/desktop/src/renderer/components/shared/ModelPicker/ModelPickerContent.tsx @@ -91,6 +91,10 @@ function refreshProviderLabel(provider: AgentChatModelCatalogRefreshProvider): s return providerLabel(provider); } +function providerIsReady(status: AuthStatus | undefined): boolean { + return status === "ok" || status === "limited"; +} + export type ModelPickerContentProps = { value: string; surfaceKey: string; @@ -213,9 +217,12 @@ export const ModelPickerContent = memo(function ModelPickerContent({ }, []); const handleSelectRail = useCallback((next: RailSelection) => { + if (next === selection && next !== "favorites" && next !== "recents") { + onProviderRailSelect?.(next.slice("provider:".length) as ProviderFamily); + } setSelection(next); searchRef.current?.focus({ preventScroll: true }); - }, []); + }, [onProviderRailSelect, selection]); // authOnly === true hides models whose family isn't ready; // when off, all models are shown (including unauthed, which the row dims + offers sign-in). @@ -621,6 +628,7 @@ export const ModelPickerContent = memo(function ModelPickerContent({ opencodeBinaryInstalled={opencodeBinaryInstalled} opencodeBinaryKnown={opencodeBinaryKnown} refreshingProvider={activeProviderRefreshing ? activeRefreshProvider : null} + providerAuthStatus={effectiveAuth} {...(onOpenSignIn ? { onOpenSignIn } : {})} /> ) : ( @@ -675,6 +683,7 @@ function EmptyState({ opencodeBinaryInstalled, opencodeBinaryKnown, refreshingProvider, + providerAuthStatus, onOpenSignIn, }: { selection: RailSelection; @@ -682,6 +691,7 @@ function EmptyState({ opencodeBinaryInstalled: boolean; opencodeBinaryKnown: boolean; refreshingProvider?: AgentChatModelCatalogRefreshProvider | null; + providerAuthStatus?: Partial<Record<ProviderFamily, AuthStatus>>; onOpenSignIn?: () => void; }) { if (!searchActive && selection !== "favorites" && selection !== "recents") { @@ -714,7 +724,13 @@ function EmptyState({ </div> ); } - return <ProviderEmptyState family={family} {...(onOpenSignIn ? { onOpenSignIn } : {})} />; + return ( + <ProviderEmptyState + family={family} + mode={providerIsReady(providerAuthStatus?.[family]) ? "discovery-empty" : "default"} + {...(onOpenSignIn ? { onOpenSignIn } : {})} + /> + ); } let body = "No models match this view."; if (searchActive) body = "No models match your search."; diff --git a/apps/desktop/src/renderer/components/shared/ModelPicker/providerEmptyState.test.tsx b/apps/desktop/src/renderer/components/shared/ModelPicker/providerEmptyState.test.tsx index b744f4091..6b60b58aa 100644 --- a/apps/desktop/src/renderer/components/shared/ModelPicker/providerEmptyState.test.tsx +++ b/apps/desktop/src/renderer/components/shared/ModelPicker/providerEmptyState.test.tsx @@ -42,6 +42,13 @@ describe("ProviderEmptyState", () => { expect(openExternalCalls).toContain("https://cursor.com/dashboard/integrations"); }); + it("renders connected-but-empty Cursor discovery copy", () => { + render(<ProviderEmptyState family="cursor" mode="discovery-empty" />); + expect(screen.getByText("No Cursor models found")).toBeTruthy(); + expect(screen.getByText(/Cursor is connected/i)).toBeTruthy(); + expect(screen.queryByText("Connect Cursor")).toBeNull(); + }); + it("renders Droid (factory) copy", () => { render(<ProviderEmptyState family="factory" />); expect(screen.getByText(/Install Droid CLI/i)).toBeTruthy(); diff --git a/apps/desktop/src/renderer/components/shared/ModelPicker/providerEmptyState.tsx b/apps/desktop/src/renderer/components/shared/ModelPicker/providerEmptyState.tsx index 37b9e5262..4ef16d13b 100644 --- a/apps/desktop/src/renderer/components/shared/ModelPicker/providerEmptyState.tsx +++ b/apps/desktop/src/renderer/components/shared/ModelPicker/providerEmptyState.tsx @@ -94,7 +94,7 @@ function dispatchAction(action: ProviderEmptyStateAction, onOpenSignIn?: () => v export type ProviderEmptyStateProps = | { family: ProviderFamily; - mode?: "default"; + mode?: "default" | "discovery-empty"; onOpenSignIn?: () => void; } | { @@ -132,6 +132,26 @@ function opencodeRequiredCopy(forProvider: "opencode" | "ollama" | "lmstudio"): }; } +function discoveryEmptyCopy(family: ProviderFamily): ProviderCopy { + const label = PROVIDER_DISPLAY_LABELS[family] ?? family; + if (family === "cursor") { + return { + title: "No Cursor models found", + body: "Cursor is connected, but ADE did not receive any Cursor SDK models yet.", + primary: { label: "Open Settings", action: { kind: "open-settings" } }, + secondary: { + label: "Get Cursor API key", + action: { kind: "open-external", url: "https://cursor.com/dashboard/integrations" }, + }, + }; + } + return { + title: `No ${label} models found`, + body: `${label} is connected, but ADE did not receive any models yet.`, + primary: { label: "Open Settings", action: { kind: "open-settings" } }, + }; +} + export type ProviderSetupBannerProps = { family: ProviderFamily; onOpenSignIn?: () => void; @@ -175,7 +195,9 @@ export function ProviderEmptyState(props: ProviderEmptyStateProps) { const mode = props.mode ?? "default"; const copy = mode === "opencode-required" ? opencodeRequiredCopy(family as "opencode" | "ollama" | "lmstudio") - : PROVIDER_COPY[family]; + : mode === "discovery-empty" + ? discoveryEmptyCopy(family) + : PROVIDER_COPY[family]; if (!copy) { return ( <div className="flex h-full min-h-[200px] flex-col items-center justify-center gap-1 px-4 py-6 text-center"> diff --git a/apps/desktop/src/renderer/components/shared/ModelPicker/runtimeCatalogCache.ts b/apps/desktop/src/renderer/components/shared/ModelPicker/runtimeCatalogCache.ts index db1ee412a..21340e732 100644 --- a/apps/desktop/src/renderer/components/shared/ModelPicker/runtimeCatalogCache.ts +++ b/apps/desktop/src/renderer/components/shared/ModelPicker/runtimeCatalogCache.ts @@ -43,6 +43,14 @@ function catalogContainsRefreshProvider( }); } +function shouldMarkRefreshProviderFresh( + catalog: AgentChatModelCatalog, + provider: AgentChatModelCatalogRefreshProvider, +): boolean { + if (provider !== "cursor") return true; + return catalogContainsRefreshProvider(catalog, provider); +} + function markRuntimeCatalogProviderFresh( provider: AgentChatModelCatalogRefreshProvider, refreshedAt = Date.now(), @@ -52,6 +60,9 @@ function markRuntimeCatalogProviderFresh( export function runtimeCatalogProviderIsFresh(provider: AgentChatModelCatalogRefreshProvider): boolean { const refreshedAt = sharedRuntimeCatalogProviderRefreshedAt.get(provider); + if (provider === "cursor" && (!sharedRuntimeCatalog || !catalogContainsRefreshProvider(sharedRuntimeCatalog, provider))) { + return false; + } return Boolean(refreshedAt && Date.now() - refreshedAt <= runtimeCatalogRefreshTtlMs(provider)); } @@ -72,7 +83,11 @@ export function rememberRuntimeCatalog( } sharedRuntimeCatalog = catalog; - if (args.refreshProvider && (args.mode === "force" || catalog.stale !== true)) { + if ( + args.refreshProvider + && (args.mode === "force" || catalog.stale !== true) + && shouldMarkRefreshProviderFresh(catalog, args.refreshProvider) + ) { markRuntimeCatalogProviderFresh(args.refreshProvider); return catalog; } diff --git a/apps/desktop/src/shared/chatSubagents.ts b/apps/desktop/src/shared/chatSubagents.ts index bc0f06d44..7589a1e7e 100644 --- a/apps/desktop/src/shared/chatSubagents.ts +++ b/apps/desktop/src/shared/chatSubagents.ts @@ -13,6 +13,8 @@ export type SubagentSnapshot = { parentToolUseId?: string | null; turnId?: string | null; background?: boolean; + taskType?: "subagent" | "background" | "local_workflow" | "cron" | "other"; + workflowName?: string; startedAt?: string | null; endedAt?: string | null; tokens?: number; @@ -20,6 +22,20 @@ export type SubagentSnapshot = { lastToolName?: string; }; +const SUBAGENT_TASK_TYPES = new Set<NonNullable<SubagentSnapshot["taskType"]>>([ + "subagent", + "background", + "local_workflow", + "cron", + "other", +]); + +function normalizeSubagentTaskType(value: unknown): SubagentSnapshot["taskType"] | undefined { + return typeof value === "string" && SUBAGENT_TASK_TYPES.has(value as NonNullable<SubagentSnapshot["taskType"]>) + ? value as SubagentSnapshot["taskType"] + : undefined; +} + export type ChatInfoPlanStep = { text: string; status: "pending" | "in_progress" | "completed" | "failed"; @@ -230,6 +246,11 @@ export function subagentSnapshotsFromEvents(events: AgentChatEventEnvelope[]): S const summaryFromEvent = [event.summary, event.finalSummary, event.text, event.description] .find((value): value is string => typeof value === "string" && value.trim().length > 0); const summary = summaryFromEvent ?? existing?.summary ?? ""; + const incomingTaskType = normalizeSubagentTaskType(event.taskType); + const existingTaskType = normalizeSubagentTaskType(existing?.taskType); + const incomingWorkflowName = typeof event.workflowName === "string" && event.workflowName.trim().length + ? event.workflowName.trim() + : undefined; const base: SubagentSnapshot = { id, name: typeof event.description === "string" ? event.description : existing?.name ?? agentType, @@ -239,6 +260,12 @@ export function subagentSnapshotsFromEvents(events: AgentChatEventEnvelope[]): S parentToolUseId, turnId: typeof event.turnId === "string" ? event.turnId : existing?.turnId ?? null, background: event.background === true || existing?.background === true, + ...(incomingTaskType || existingTaskType + ? { taskType: incomingTaskType ?? existingTaskType } + : {}), + ...(incomingWorkflowName || existing?.workflowName + ? { workflowName: incomingWorkflowName ?? existing?.workflowName } + : {}), startedAt, endedAt, tokens: typeof usage.totalTokens === "number" ? usage.totalTokens : typeof event.tokens === "number" ? event.tokens : existing?.tokens, diff --git a/apps/desktop/src/shared/types/chat.ts b/apps/desktop/src/shared/types/chat.ts index b9659d092..71a52c25f 100644 --- a/apps/desktop/src/shared/types/chat.ts +++ b/apps/desktop/src/shared/types/chat.ts @@ -430,6 +430,8 @@ export type AgentChatEvent = parentToolUseId?: string | null; description: string; background?: boolean; + taskType?: "subagent" | "background" | "local_workflow" | "cron" | "other"; + workflowName?: string; turnId?: string; } | { @@ -446,6 +448,8 @@ export type AgentChatEvent = durationMs?: number; }; lastToolName?: string; + taskType?: "subagent" | "background" | "local_workflow" | "cron" | "other"; + workflowName?: string; turnId?: string; } | { @@ -462,6 +466,8 @@ export type AgentChatEvent = toolUses?: number; durationMs?: number; }; + taskType?: "subagent" | "background" | "local_workflow" | "cron" | "other"; + workflowName?: string; turnId?: string; } | { diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 5a95f5d3a..f72c3c12f 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -112,6 +112,12 @@ Use `ADE_VERSION=vX.Y.Z` for a pinned release or `ADE_INSTALL_DIR` to choose the **Windows packaging.** The installer lays down `ade-cli-windows-wrapper.cmd` plus an `ade-cli-install-path.cmd` helper alongside the bundled Electron Node runtime. The helper installs `%LOCALAPPDATA%\ADE\bin\ade.cmd`, updates the user PATH when needed, and then `ade` works from a new normal Windows shell without a global Node install. See §14.4 for the packaging flow. +**Desktop bridge socket.** The runtime daemon runs `apps/ade-cli/dist/cli.cjs` under `ELECTRON_RUN_AS_NODE=1`, so it has no access to renderer-side Electron APIs (`WebContentsView`, `nativeImage`, `session`, …). A small set of services own real desktop UI and therefore cannot live in the daemon — most notably `BuiltInBrowserService`, which drives the Browser pane's `WebContentsView`. The desktop main process hosts those services and exposes them to the daemon over a side-channel JSON-RPC Unix-domain socket / named pipe. + +The socket path is resolved by `apps/ade-cli/src/services/projects/machineLayout.ts`: `<adeHome>/sock/desktop-bridge.sock` on macOS / Linux (e.g. `~/.ade/sock/desktop-bridge.sock` stable, `~/.ade-beta/sock/desktop-bridge.sock` beta), and `\\.\pipe\ade-desktop-bridge[-<channel-suffix>]` on Windows. `ADE_DESKTOP_BRIDGE_SOCKET_PATH` overrides it for dev launches against a non-default ADE home. The server lives in `apps/desktop/src/main/services/builtInBrowser/desktopBridgeServer.ts`, wired up from `main.ts` right after `builtInBrowserService` is constructed and torn down with it on app shutdown. The daemon-side proxy is `apps/ade-cli/src/services/builtInBrowser/desktopBridgeClient.ts`; `createAdeRuntime` in `bootstrap.ts` assigns it to `runtime.builtInBrowserService` so the existing action registry slot resolves transparently (skipped when `runtimeProfile === "chat"`). Both sides share the same method allowlist: `getStatus, showPanel, setBounds, navigate, createTab, switchTab, closeTab, reload, goBack, goForward, stop, startInspect, stopInspect, captureScreenshot, selectPoint, selectCurrent, clearSelection`. + +Today only the `built_in_browser` domain rides this bridge; the pattern is generic and other Electron-only domains can be added the same way. The client lazy-connects on first call and reconnects on the next call after any failure. When no desktop is running, each call surfaces a clear `Desktop browser bridge not running at <path>. Open ADE Desktop with a project to enable \`ade browser\` commands.` error and every other runtime domain stays functional. This is distinct from the legacy desktop-socket mode (a pre-multi-project pattern where the desktop renderer hosted RPC and the CLI dialed in): the daemon still owns the full action surface — the bridge is narrowly scoped to services that physically require an Electron renderer host. + ### 2.2 Electron desktop client (`apps/desktop/`) The desktop app is a **client of the runtime**. It owns a trusted main process, a narrow typed preload bridge, the React renderer, and the shared TypeScript contracts that the whole monorepo (including the ADE CLI runtime) consumes — but the data plane it operates on lives in the runtime daemon. diff --git a/docs/features/ade-code/README.md b/docs/features/ade-code/README.md index e11c1956b..aa1ae8e06 100644 --- a/docs/features/ade-code/README.md +++ b/docs/features/ade-code/README.md @@ -27,7 +27,8 @@ Point Cursor’s browser inspector at the served page for layout debugging. The | `apps/ade-cli/src/tuiClient/connection.ts` | Resolves attached vs embedded mode, runs the `ade/initialize` handshake, registers the project with `projects.add`, wraps subsequent requests with `projectId`. | | `apps/ade-cli/src/tuiClient/jsonRpcClient.ts` | Socket client: connect, request/response, `chat/event` notifications. | | `apps/ade-cli/src/tuiClient/adeApi.ts` | Typed wrappers over `AdeCodeConnection.action` / `actionList` for lanes, chat, models, navigation, provider readiness, API-key status, OpenCode diagnostics, project slash-command discovery, lane diff stats (`listLaneDiffStats`), per-lane PR summaries (`listPrsByLane`), the Claude steer family (`steerChatMessage`, `cancelSteerMessage`, `editSteerMessage`, `dispatchSteerMessage`), the provider-grouped model catalog (`getModelCatalog(args?: AgentChatModelCatalogArgs)` → `AgentChatModelCatalog`), and the cross-surface model-picker favorites / recents (`getModelPickerFavorites`, `toggleModelPickerFavorite`, `getModelPickerRecents`, `pushModelPickerRecent`) backed by the top-level `modelPicker.*` JSON-RPC methods on `adeRpcServer`. | -| `apps/ade-cli/src/tuiClient/commands.ts` / `linearCommands.ts` | Slash command catalog and routing. | +| `apps/ade-cli/src/tuiClient/commands.ts` / `linearCommands.ts` | Slash command catalog and routing. `commands.ts` ships `/lane delete` (right-pane confirmation form that destroys the active lane) and `/effort` (reasoning-effort-only picker, a narrower companion to `/model`). `linearCommands.ts` requires a sub-command — bare `/linear` returns the usage hint instead of silently picking `workflows`. | +| `apps/ade-cli/src/tuiClient/rightPaneFormatters.ts` | Pure formatters for right-pane result panes (PR summary / review / checks / comments, memory search, Linear status, system details). Keeps `app.tsx` free of ad-hoc rendering helpers. | | `apps/ade-cli/src/tuiClient/format.ts` | Transcript rendering helpers for the TUI. | | `apps/ade-cli/src/tuiClient/aggregate.ts` | Pure derivations on top of the chat event stream. Produces `AggregatedBlock`s (assistant text, tool-calls / files-changed / plan / memory / compaction groups, runtime-activity rows for subagent and activity envelopes, queued steers) and `derivePendingSteers`, consumed by `ChatView` and the right-pane steer view. | | `apps/ade-cli/src/tuiClient/drawerSelection.ts` | Pure selectors for the lane / chat drawer (active row, expanded groups, keyboard navigation). | @@ -101,7 +102,7 @@ For the embedded runtime there is no `projects.add` step — the in-process runt - **Drawer** (toggled with the configured shortcut) — two modes, **lanes** (default) and **chats**, switched with `Tab` while the drawer is focused. In **lanes** mode, `↑`/`↓` move lane cards; the selected lane shows a read-only chat preview (`visibleDrawerChatCount` caps rows). `↓` on an available lane enters **chats** mode for that lane; `↵` opens lane details or resumes the lane's last chat. In **chats** mode, `↑`/`↓` move chat rows and `+ new chat`; highlighting a chat previews it in the centre pane via `resolveTuiChatRefreshTarget` before `↵` commits the session. `↑` at the top of the chat list returns to **lanes**; `↓` past the last chat drops to the next lane card. Lane and chat selection drive the right pane's context. - **ChatView** — the main transcript. Renders user, assistant, tool, and system events from `chat/event` notifications. Tool calls collapse into expandable blocks; the most recent expandable failure id is tracked so `Enter` can drill into it. Mouse selection is ADE-owned so it can follow virtual transcript rows: drag selects, edge-drag scrolls, wheel scrolling preserves the highlighted range, Shift-click extends the current anchor, and `Ctrl+C` / delivered `Cmd+C` copy selected chat text. - **Composer** — multi-line input with mention completion (`@…`) sourced from `MentionPalette` and slash command completion from `SlashPalette`. Pending tool approvals surface as `ApprovalPrompt`. -- **RightPane** — context-sensitive drawer for slash command output. The "right" placement commands (see below) render their results here as forms, lists, diffs, help text, or rendered objects. When a chat is active the default content is the **Chat Info** view (`kind: "chat-info"`): provider/model header, lane label, streaming/idle indicator with context-percent + token summary, plan steps for the current turn, Codex `/goal` block when present, and a roster of subagents (running first, then teammates and background). Selecting a subagent row with `↵` swaps the centre transcript to that agent's view via `buildSubagentTranscriptEvents`; `Esc` returns to the main chat. For an active lane with no chat focus, the default switches to the wireframe **`lane-details`** view: **STATUS** (clean/dirty, ahead/behind), **CHANGES** (file list + staged/unstaged counts from `diff.listLaneDiffStats`), **ACTIONS** (lane shortcuts), optional **PR #N** (state chip, CI activity via `checksPending` / `checksFailed`, `↵` opens the PR URL when the PR row is selected), and **CHATS** (active / closed / killed counts from `computeLaneChatCounts`). A `worktreeAvailable` guard surfaces a recoverable warning when the lane worktree path is missing from disk. `/model` opens a separate **`model-setup`** pane for provider/model/reasoning/permission picks before the first prompt. +- **RightPane** — context-sensitive drawer for slash command output. The "right" placement commands (see below) render their results here as forms, lists, diffs, help text, or rendered objects. When a chat is active the default content is the **Chat Info** view (`kind: "chat-info"`): provider/model header, lane label, streaming/idle indicator with context-percent + token summary, plan steps for the current turn, Codex `/goal` block when present, and a roster of subagents (running first, then teammates and background). Selecting a subagent row with `↵` swaps the centre transcript to that agent's view via `buildSubagentTranscriptEvents`; `Esc` returns to the main chat. For an active lane with no chat focus, the default switches to the wireframe **`lane-details`** view: **STATUS** (clean/dirty, ahead/behind), **CHANGES** (file list + staged/unstaged counts from `diff.listLaneDiffStats`), **ACTIONS** (lane shortcuts — `new chat`, `open / create PR`, `stage all`, `move unstaged to new lane`, `commit`, `push`, `diff`, `reparent`, `delete lane`; each row carries a semantic glyph color so additive actions are green, navigational actions are violet, the rescue-unstaged action is amber, and `delete lane` is red), optional **PR #N** (state chip, CI activity via `checksPending` / `checksFailed`, `↵` opens the PR URL when the PR row is selected), and **CHATS** (active / closed / killed counts from `computeLaneChatCounts`). A `worktreeAvailable` guard surfaces a recoverable warning when the lane worktree path is missing from disk. `/model` opens a separate **`model-setup`** pane for provider/model/reasoning/permission picks before the first prompt. - **FooterControls** — two-row footer. The top row (mode bar, only present when there's content) shows provider glyph + label, model display, fast-mode badge, reasoning effort, permission summary, pending steer count, a 10-cell token usage bar (`TokenBar`) that recolors at 50 / 80 / 95 %, and the cached context-percent / token summary. The bottom row shows pane toggles (`^o` lanes, `^p` pane, `^a` chat info) and pane-specific hints (drawer mode lanes/chats, details navigation, chat scroll position, `/steer` reminder when steers are queued). The `⊚ chat info` chip shows the live subagent count when greater than zero. `footerControlsForAvailability(agentsAvailable)` decides which toggles are wired. - **Claude terminal control** — when the active session is a running Claude terminal, `Ctrl+T` moves keyboard input from ADE into that @@ -163,11 +164,13 @@ Right pane (open the contextual drawer): | `/chats` | Sessions in the active lane. | | `/switch [lane\|chat]` | Switcher palette. | | `/help` | Keymap and command help. | -| `/keybindings` | Show Claude-compatible keybinding config diagnostics. | +| `/lane delete` | Open a right-pane confirmation form for deleting the active lane (shows lane name, branch ref, and dirty state; force toggle exposed when the lane has uncommitted changes). | +| `/keybindings [open]` | Show Claude-compatible keybinding config diagnostics. Pass `open` to launch the configured editor on `~/.claude/keybindings.json`. | | `/statusline` | Show Claude-compatible status line config. | | `/doctor` | Show ADE Code and Claude-compat diagnostics. | | `/feedback` | Multi-field feedback form (category / summary / details / expected / actual / environment / additional context) wired to `feedback.submit` via the `feedback.ts` form builder. | -| `/model`, `/effort` | Model and reasoning-effort pickers. | +| `/model` | Open the unified model / reasoning / permission picker (right pane `model-picker` view, with rail + fuzzy search). | +| `/effort` | Open a focused reasoning-effort-only picker for the active provider (skips the model rail when only the effort needs to change). For Claude terminal sessions, the picker writes the effort directly into the running Claude transcript via `submitClaudePromptToTerminal` so the change applies without restarting the chat. | | `/system` | System and runtime details. | | `/ade <domain.action> [json]` | Run an allowlisted ADE action; shows result in RightPane. | diff --git a/docs/features/chat/transcript-and-turns.md b/docs/features/chat/transcript-and-turns.md index e403bcaaf..0f0472fb8 100644 --- a/docs/features/chat/transcript-and-turns.md +++ b/docs/features/chat/transcript-and-turns.md @@ -80,7 +80,7 @@ Two helpers summarise a parsed stream: | `done` | Final turn marker with model, model id, usage, cost. Also clears non-question pending inputs when status is not `completed`. | | `activity` | Ephemeral UI hint (thinking, searching, running_command). Hidden from the transcript. | | `todo_update` | Task-list snapshot; consumed by `ChatTasksPanel`. | -| `subagent_started` / `subagent_progress` / `subagent_result` | Legacy Claude background subagent lifecycle. Each envelope carries `taskId`, `parentToolUseId`, `description`, and optional `agentId` + `agentType`: for Claude / ade-code `agentType` is the Task tool's `subagent_type` (stashed at the `tool_use` boundary and joined on `parentToolUseId`); for Codex parallel agents it is a per-turn `Agent #N` label assigned at first announcement and the raw threadId is mirrored as `agentId`; for OpenCode subagents `agentType` is omitted so the row falls back to the `description` (taken from `session.title`). The service also emits canonical `subagent.started` / `subagent.progress` / `subagent.completed` rows from `runtimeEvents.ts` so all runtimes can converge on the same envelope. | +| `subagent_started` / `subagent_progress` / `subagent_result` | Legacy Claude background subagent lifecycle. Each envelope carries `taskId`, `parentToolUseId`, `description`, and optional `agentId` + `agentType`: for Claude / ade-code `agentType` is the Task tool's `subagent_type` (stashed at the `tool_use` boundary and joined on `parentToolUseId`); for Codex parallel agents it is a per-turn `Agent #N` label assigned at first announcement and the raw threadId is mirrored as `agentId`; for OpenCode subagents `agentType` is omitted so the row falls back to the `description` (taken from `session.title`). Claude SDK runs also stash `taskType` (`subagent` / `background` / `local_workflow` / `cron` / `other`) and `workflowName` at spawn so the renderer can label rows by workflow without re-deriving them per event; ambient/housekeeping tasks (the SDK's `skip_transcript=true` flag — e.g. session-title generation) are filtered out symmetrically across spawn, progress, and completion notifications so the subagent panel never flashes them. The service also emits canonical `subagent.started` / `subagent.progress` / `subagent.completed` rows from `runtimeEvents.ts` so all runtimes can converge on the same envelope. | | `tool_use_start` / `tool_use_complete` / `tool_use_summary` | Claude SDK tool lifecycle tracking (see [Claude tool-use tracking](#claude-tool-use-tracking)). | | `step_boundary` | Mission step boundary marker. | | `system_notice` | Non-transcript chrome: auth errors, rate limits, memory notices, file persistence hints. | diff --git a/docs/features/lanes/README.md b/docs/features/lanes/README.md index 8af823427..e4e985b95 100644 --- a/docs/features/lanes/README.md +++ b/docs/features/lanes/README.md @@ -322,7 +322,10 @@ default from the Lanes list (see `isMissionLaneHiddenByDefault` in half-deleted. Generic ADE action calls (`lane.delete` through `ade actions run` / TUI `/ade`) use the same teardown path, including lane-environment cleanup and port lease - release. + release. The ADE Code TUI also surfaces this through a dedicated + `/lane delete` slash command that opens a right-pane confirmation + form (lane name + branch ref + dirty flag, with a force toggle when + the lane is dirty) before issuing the action. ## Lane color diff --git a/docs/features/missions/README.md b/docs/features/missions/README.md index 263e264f9..fe575687c 100644 --- a/docs/features/missions/README.md +++ b/docs/features/missions/README.md @@ -33,6 +33,7 @@ Caveats that follow from "runtime owns missions": - `orchestrator/executionPolicy.ts` — default `MissionExecutionPolicy`, merge rules (mission > project > fallback), completion evaluation, run/step validation. - `orchestrator/adaptiveRuntime.ts` — `classifyTaskComplexity` (trivial/simple/moderate/complex), parallelism scaling, model downgrade. - `orchestrator/workerDeliveryService.ts` — message delivery pipeline between coordinator and worker chats; retry, idempotency, in-flight leases. +- `orchestrator/workerTracking.ts` — post-attempt artifact extraction and the planning-question intervention path. Only `planner_natural_question` opens a `manual_input` intervention now; the planning-question "required before exit" enforcement has been retired (see [Planning question handling](#planning-question-handling)). - `orchestrator/delegationContracts.ts` — contracts between coordinator and workers (scope, allowed tools, handoff shape). - `orchestrator/runtimeEventRouter.ts` — routes events from worker sessions and CLI output into the coordinator. - `orchestrator/metaReasoner.ts` — higher-level reasoning for coordinator choices. @@ -112,6 +113,22 @@ When a mission reaches terminal status (`completed`, `failed`, `cancelled`), `tr - **Turn-level timeout** — Individual agent turns are capped at 5 minutes via the abort infrastructure. - **Autopilot timeout** — Autopilot polls every 15 seconds (single configurable constant, up from 5s). +### Planning question handling + +A planner can pause the run by emitting an `awaiting_user_input` step with +`source === "planner_natural_question"` and a non-empty `question`. +`workerTracking.extractAndRegisterArtifacts` translates that into a single +`manual_input` intervention (`reasonCode: "planner_natural_question"`) and a +matching `pauseRun` so the coordinator stops until the user answers. There is +no longer a separate `planner_required_question_missing` reason code or a +phase-level "questions required before exit" gate — the legacy +`planningQuestionPolicy.ts` module was removed, and the worker prompt no longer +mentions an ADE-blocking-question prerequisite. If a phase wants the planner to +ask clarifying questions, that intent is conveyed by the planner itself +through the `ask_user` tool (or `awaiting_user_input` with the natural-question +source); the orchestrator never refuses to exit planning just because no +question was asked. + ### Mission step bidirectional sync `syncRunStepsFromMission()` pulls user-initiated mutations (cancel, skip) from the mission state back into orchestrator run state. The orchestrator picks the change up on its next tick. diff --git a/docs/features/missions/orchestration.md b/docs/features/missions/orchestration.md index 040e05eec..f786d4069 100644 --- a/docs/features/missions/orchestration.md +++ b/docs/features/missions/orchestration.md @@ -25,9 +25,9 @@ All in `apps/desktop/src/main/services/orchestrator/`. Files in this directory a - `metaReasoner.ts` — higher-level reasoning helpers for coordinator decisions. - `metricsAndUsage.ts` — token and cost accounting; `estimateTokenCost`. - `recoveryService.ts` — tracked session state, recovery iteration policy (`DEFAULT_RECOVERY_LOOP_POLICY`). -- `workerTracking.ts` — worker session tracking, per-attempt artifact extraction (`extractAndRegisterArtifacts`), planning-phase plan-artifact persistence gate, and `planner_plan_missing` intervention auto-resolution on successful re-planning. +- `workerTracking.ts` — worker session tracking, per-attempt artifact extraction (`extractAndRegisterArtifacts`), planning-phase plan-artifact persistence gate, `planner_plan_missing` intervention auto-resolution on successful re-planning, and the `planner_natural_question` `manual_input` intervention path (with matching `pauseRun`) when the planner emits an `awaiting_user_input` step. The legacy `planner_required_question_missing` reason code and its companion `planningQuestionPolicy.ts` module were removed; phases no longer enforce a "required question before exit" gate. - `stepPolicyResolver.ts` — `ResolvedOrchestratorRuntimeConfig`, step-level policy merging, autopilot config, file-claim scope (`doFileClaimsOverlap`, `doesFileClaimMatchPath`), repo-relative path normalization. -- `baseOrchestratorAdapter.ts` — `buildFullPrompt` (the worker prompt builder), shell escaping, inline decoding. +- `baseOrchestratorAdapter.ts` — `buildFullPrompt` (the worker prompt builder), shell escaping, inline decoding. Worker runtime is now `tracked_session | in_process` only; the legacy `managed_chat` branch was retired with the worker-prompt simplification. - `providerOrchestratorAdapter.ts` — provider-specific launchers for Claude CLI, Codex CLI, and managed OpenCode-backed execution. - `promptInspector.ts` — coordinator / planning / worker prompt inspectors for the mission UI. - `runtimeInterventionsSteeringErrors.test.ts` — runtime intervention behavior tests. diff --git a/docs/features/pull-requests/README.md b/docs/features/pull-requests/README.md index 99a5ad4ee..90f063b32 100644 --- a/docs/features/pull-requests/README.md +++ b/docs/features/pull-requests/README.md @@ -79,7 +79,7 @@ Renderer components (`apps/desktop/src/renderer/components/prs/`): | `prsRouteState.ts` | URL ↔ page state mapping plus project-scoped last-route storage. When a project root is known, the PRs tab reads only that project's stored route and does not fall back to the legacy global route from another project. | | `CreatePrModal.tsx` | Draft/queue/integration PR creation with lane warnings, branch name validation, and optional initial values for single-PR handoffs from lane/chat surfaces. A `target: "primary"` handoff resolves the base branch from the primary lane (falling back to `main`). | | `tabs/NormalTab.tsx` | Normal PR list | -| `tabs/GitHubTab.tsx` | Repository PR browser with label filters, CI badges, review indicators, ADE-vs-unmanaged scope counts, and linked-lane context. The tab ignores legacy cross-repo `externalPullRequests` payloads; the "External" scope means repo PRs that are not managed by ADE. | +| `tabs/GitHubTab.tsx` | Repository PR browser with label filters, CI badges, review indicators, ADE-vs-unmanaged scope counts, and linked-lane context. State filter is one of `open` / `closed` / `merged` / `all`. Mission result lanes are filtered to a dedicated section via `isMissionResultLane`. The tab ignores legacy cross-repo `externalPullRequests` payloads; the "External" scope means repo PRs that are not managed by ADE. The "create lane from PR branch" affordance has been removed — open/closed PRs on branches without a lane no longer offer the preflight + create dialog (`prsPreflightCreateLaneFromPrBranch` / `prsCreateLaneFromPrBranch` IPC channels have been deleted), so creating a lane for an existing PR now goes through the standard lane creation flow. | | `tabs/QueueTab.tsx` | Merge queue UI. Hosts the "Automate Merging" entry point that opens `QueueAutomateMergingModal` with the queue's eligible members (everything that has not landed yet). | | `tabs/QueueAutomateMergingModal.tsx` | Stack-wide automation modal: edits one `PipelineSettings` config that applies to every queue member, then sequentially saves settings, calls `ade.prs.retargetBase` for non-leading members so each PR's base points at the queue's tracking branch, starts Path-to-Merge via `ade.prs.pathToMerge.start`, and polls `convergenceStateGet` every 4 s until the runtime status is terminal. Halts the sequence on the first `failed | cancelled | stopped`. Closing mid-sequence stops dispatching new starts but leaves already-launched orchestrators running. | | `tabs/IntegrationTab.tsx` | Integration (merge-plan) proposals and execution, including merge-into-lane selection, apply-and-resimulate, and adopted-lane cleanup messaging | diff --git a/scripts/tui-web.mjs b/scripts/tui-web.mjs index aa4cdc71e..352f731e1 100644 --- a/scripts/tui-web.mjs +++ b/scripts/tui-web.mjs @@ -201,15 +201,54 @@ function buildPage({ httpOrigin, ptyWsUrl, mirrorInstanceId, hasFitAddon }) { const PTY_URL = ${JSON.stringify(ptyWsUrl)}; const MIRROR_ID = ${JSON.stringify(mirrorInstanceId)}; const status = document.getElementById("status"); + const PTY_COL_SAFETY_MARGIN = 1; + const MAX_WEB_COLS = 240; + const DEBUG_DATASET = new URLSearchParams(location.search).has("debug"); let inputRole = "primary"; let lastSentDims = { cols: 0, rows: 0 }; let ptyWs; let resizeTimer = null; + const mirrorDebug = { + messages: 0, + controlMessages: 0, + stringWrites: 0, + lastPtyLength: 0, + lastPtySample: "", + blobWrites: 0, + arrayBufferWrites: 0, + viewWrites: 0, + fallbackWrites: 0, + writeErrors: [], + }; + if (DEBUG_DATASET) window.__adeTuiMirrorDebug = mirrorDebug; function setStatus(text) { status.textContent = text; } + function updateDebugDataset() { + if (!DEBUG_DATASET) return; + status.dataset.messages = String(mirrorDebug.messages); + status.dataset.controlMessages = String(mirrorDebug.controlMessages); + status.dataset.stringWrites = String(mirrorDebug.stringWrites); + status.dataset.lastPtyLength = String(mirrorDebug.lastPtyLength); + status.dataset.lastPtySample = mirrorDebug.lastPtySample; + status.dataset.writeErrors = mirrorDebug.writeErrors.slice(-3).join(" | "); + try { + status.dataset.cursor = String(term.buffer.active.cursorX) + "," + String(term.buffer.active.cursorY); + status.dataset.viewport = String(term.buffer.active.viewportY) + "," + String(term.buffer.active.baseY) + "," + String(term.buffer.active.length); + status.dataset.bufferLine0 = term.buffer.active.getLine(0)?.translateToString(true).slice(0, 120) ?? ""; + status.dataset.bufferLine1 = term.buffer.active.getLine(1)?.translateToString(true).slice(0, 120) ?? ""; + if (DEBUG_DATASET) { + const screenLines = []; + for (let index = 0; index < term.rows; index += 1) { + screenLines.push(term.buffer.active.getLine(index)?.translateToString(true) ?? ""); + } + status.dataset.screenText = screenLines.join("\\n").slice(0, 24000); + } + } catch (_) {} + } + function setRole(role) { inputRole = role; setStatus(role === "primary" ? "primary · " + lastSentDims.cols + "×" + lastSentDims.rows : "view-only · " + lastSentDims.cols + "×" + lastSentDims.rows); @@ -232,9 +271,18 @@ function buildPage({ httpOrigin, ptyWsUrl, mirrorInstanceId, hasFitAddon }) { term.focus(); document.getElementById("terminal").addEventListener("pointerdown", () => term.focus()); + function writeTerminal(data) { + term.write(data, () => { + try { + term.refresh(0, Math.max(0, term.rows - 1)); + } catch (_) {} + updateDebugDataset(); + }); + } + function measureGrid() { ${fitCall} - return { cols: term.cols, rows: term.rows }; + return { cols: Math.max(2, Math.min(MAX_WEB_COLS, term.cols - PTY_COL_SAFETY_MARGIN)), rows: term.rows }; } function pushPrimaryResize() { @@ -261,27 +309,82 @@ function buildPage({ httpOrigin, ptyWsUrl, mirrorInstanceId, hasFitAddon }) { }); }); ptyWs.addEventListener("message", (ev) => { + mirrorDebug.messages += 1; + updateDebugDataset(); if (typeof ev.data === "string") { + let handledControlMessage = false; try { const msg = JSON.parse(ev.data); if (msg.type === "session" && msg.mirrorInstanceId === MIRROR_ID) { setRole(msg.role === "primary" ? "primary" : "viewer"); + handledControlMessage = true; } if (msg.type === "sync_resize" && Number.isFinite(msg.cols) && Number.isFinite(msg.rows)) { const c = Math.floor(msg.cols); const r = Math.floor(msg.rows); lastSentDims = { cols: c, rows: r }; - if (term.cols !== c || term.rows !== r) term.resize(c, r); + const displayCols = c + PTY_COL_SAFETY_MARGIN; + if (term.cols !== displayCols || term.rows !== r) term.resize(displayCols, r); setRole(inputRole); + handledControlMessage = true; + } + if (msg.type === "pty" && typeof msg.data === "string") { + mirrorDebug.stringWrites += 1; + mirrorDebug.lastPtyLength = msg.data.length; + mirrorDebug.lastPtySample = msg.data.slice(0, 60); + writeTerminal(msg.data); + updateDebugDataset(); + handledControlMessage = true; } } catch (_) {} + if (handledControlMessage) { + mirrorDebug.controlMessages += 1; + } else { + mirrorDebug.stringWrites += 1; + writeTerminal(ev.data); + updateDebugDataset(); + } + return; + } + if (ev.data && typeof ev.data.arrayBuffer === "function") { + ev.data.arrayBuffer() + .then((buffer) => { + mirrorDebug.blobWrites += 1; + if (typeof Uint8Array !== "undefined") writeTerminal(new Uint8Array(buffer)); + else writeTerminal(buffer); + updateDebugDataset(); + }) + .catch((error) => { + mirrorDebug.writeErrors.push(String(error && error.message ? error.message : error)); + updateDebugDataset(); + }); + return; + } + if (typeof ArrayBuffer !== "undefined" && ev.data instanceof ArrayBuffer) { + mirrorDebug.arrayBufferWrites += 1; + writeTerminal(new Uint8Array(ev.data)); + updateDebugDataset(); + return; + } + if (typeof ArrayBuffer !== "undefined" && ArrayBuffer.isView(ev.data)) { + mirrorDebug.viewWrites += 1; + writeTerminal(new Uint8Array(ev.data.buffer, ev.data.byteOffset, ev.data.byteLength)); + updateDebugDataset(); return; } - term.write(new Uint8Array(ev.data)); + try { + mirrorDebug.fallbackWrites += 1; + writeTerminal(ev.data); + updateDebugDataset(); + } catch (_) { + mirrorDebug.writeErrors.push("unknown payload"); + updateDebugDataset(); + // Unknown browser WebSocket payload shape; keep the mirror alive. + } }); ptyWs.addEventListener("close", () => { setStatus("disconnected"); - term.write("\\r\\n\\x1b[33m[pty disconnected]\\x1b[0m\\r\\n"); + writeTerminal("\\r\\n\\x1b[33m[pty disconnected]\\x1b[0m\\r\\n"); }); ptyWs.addEventListener("error", () => { setStatus("WebSocket error"); @@ -305,6 +408,15 @@ function buildPage({ httpOrigin, ptyWsUrl, mirrorInstanceId, hasFitAddon }) { ro.observe(document.getElementById("terminal")); window.addEventListener("focus", () => { if (inputRole === "primary") term.focus(); + try { + term.refresh(0, Math.max(0, term.rows - 1)); + } catch (_) {} + }); + document.addEventListener("visibilitychange", () => { + if (document.hidden) return; + try { + term.refresh(0, Math.max(0, term.rows - 1)); + } catch (_) {} }); </script> </body> @@ -384,6 +496,48 @@ async function main() { let primaryClient = null; let ptyCols = 120; let ptyRows = 36; + const ptyReplayLimitBytes = 2 * 1024 * 1024; + /** @type {string[]} */ + const ptyReplayChunks = []; + let ptyReplayBytes = 0; + + function boundedPtyReplayChunk(text) { + if (Buffer.byteLength(text) <= ptyReplayLimitBytes) { + return text; + } + let slice = Buffer.from(text) + .subarray(Math.max(0, Buffer.byteLength(text) - ptyReplayLimitBytes)) + .toString(); + while (Buffer.byteLength(slice) > ptyReplayLimitBytes) { + slice = slice.slice(1); + } + return slice; + } + + function rememberPtyOutput(text) { + if (!text.length) return; + const chunk = boundedPtyReplayChunk(text); + const chunkBytes = Buffer.byteLength(chunk); + if (chunkBytes >= ptyReplayLimitBytes) { + ptyReplayChunks.length = 0; + ptyReplayChunks.push(chunk); + ptyReplayBytes = chunkBytes; + return; + } + ptyReplayChunks.push(chunk); + ptyReplayBytes += chunkBytes; + while (ptyReplayBytes > ptyReplayLimitBytes && ptyReplayChunks.length > 1) { + const removed = ptyReplayChunks.shift(); + ptyReplayBytes -= removed ? Buffer.byteLength(removed) : 0; + } + } + + function replayPtyOutput(ws) { + for (const chunk of ptyReplayChunks) { + if (ws.readyState !== WebSocket.OPEN) return; + ws.send(JSON.stringify({ type: "pty", data: chunk })); + } + } function broadcastSessionRoles() { const primaryJson = JSON.stringify({ type: "session", mirrorInstanceId, role: "primary" }); @@ -475,6 +629,7 @@ async function main() { primaryClient = ws; broadcastSessionRoles(); ws.send(JSON.stringify({ type: "sync_resize", cols: ptyCols, rows: ptyRows })); + replayPtyOutput(ws); ws.on("message", (raw) => { try { @@ -523,9 +678,11 @@ async function main() { }); shell.onData((data) => { - const buf = Buffer.isBuffer(data) ? data : Buffer.from(data); + const text = typeof data === "string" ? data : Buffer.from(data).toString("utf8"); + rememberPtyOutput(text); + const payload = JSON.stringify({ type: "pty", data: text }); for (const client of ptyClients) { - if (client.readyState === WebSocket.OPEN) client.send(buf); + if (client.readyState === WebSocket.OPEN) client.send(payload); } }); shell.onExit(({ exitCode, signal }) => {