diff --git a/.claude/skills/plan/SKILL.md b/.claude/skills/plan/SKILL.md new file mode 100644 index 000000000..fe1d124b8 --- /dev/null +++ b/.claude/skills/plan/SKILL.md @@ -0,0 +1,175 @@ +--- +name: plan +description: Deliberate a feature or change in three locked rounds — functional requirements, then UI design with wireframes, then extras/quirks/out-of-the-box ideas. Each round asks the user clarifying questions via AskUserQuestion before proceeding. New functional scope at any point cascade-restarts the new piece through all three rounds and merges it into the locked plan. Use when the user invokes `/plan`, when the user is in plan mode and asks for a design/spec/feature breakdown, or when a non-trivial change needs structured deliberation before implementation. +metadata: + author: ade + version: "1.0" +--- + +# /plan — Three-Round Locked Deliberation + +A feature plan has three layers: **what it does**, **how it looks**, and **the delightful extras**. Cross-talk between them produces sloppy plans. This skill enforces a strict order: lock functional, then lock UI, then add extras. New functional scope discovered at any point cascades back through all three rounds *for that new piece only*, then merges into the locked plan. + +## Activation + +Activate when **any** of: +- The user invokes `/plan` (with or without extra context). +- The user is in plan mode and asks for a design, spec, breakdown, or feature plan. +- A non-trivial change request would benefit from structured deliberation (multi-component features, UX-sensitive work, anything spanning >2 files). + +## Pre-flight: plan mode gate + +Before Round 1, verify plan mode is active. If not: + +> This skill is meant to run in plan mode (read-only deliberation). Enter plan mode (Shift+Tab cycles to it) and re-invoke `/plan `. + +Then stop. Do not proceed in edit/full-auto modes — the deliberation contract assumes no files will be touched mid-plan. + +## State you must track + +Maintain these in your head (or in scratch) across the whole skill: + +- **LockedFunctional** — bullet list of functional requirements confirmed so far. +- **LockedUI** — bullet list of UI decisions confirmed so far (with wireframe sketches). +- **LockedExtras** — list of delightful extras confirmed. +- **CurrentRound** — 1 (Functional), 2 (UI), or 3 (Extras). +- **PendingNewPieces** — queue of new functional requirements detected mid-flight that need their own cascade. + +When a cascade fires, you snapshot CurrentRound, run the cascade for the new piece, merge results back into the Locked* sets, then resume from the snapshotted round. + +## Round 1 — Functional Requirements + +Silently deliberate on what the user asked for. Pull out: +- Core capabilities the feature must have. +- Boundaries (what it does *not* do). +- Inputs, outputs, edge cases that change behavior. +- Ambiguities where multiple interpretations exist. + +Then call **AskUserQuestion** with 1–4 questions that resolve the meaningful ambiguities. Each option should describe a concrete interpretation with its trade-off. Avoid asking the user to write prose — frame everything as concrete choices. + +### Tool reference: AskUserQuestion + +Use `AskUserQuestion(questions)` to gather structured choices during Round 1, Round 2, Round 3, and any cascade. `questions` is an array of question objects: + +- `id`: stable snake_case identifier. +- `title`: short user-facing label. +- `text`: the actual question. +- `multiSelect`: optional boolean for menus like LockedExtras. +- `allowOther`: optional boolean that permits an "Other" selection. +- `allowAnnotation`: optional boolean that permits free-text annotation alongside a selected option. +- `options`: array of `{ id, label, tradeoffs, description?, preview? }`. + +The return value is keyed by question id and contains `selectedOptionIds`, optional `otherText`, and optional `annotations` / `freeText`. Treat selected option ids as the locked choice. Treat `otherText` as a new option authored by the user; if it changes behavior, update **LockedFunctional** and trigger the **Cascade rule**. Treat annotations as refinements to the selected option; if an annotation adds behavior rather than clarifying wording or presentation, it also cascades. + +When the user responds: +- Treat selected options as additions to **LockedFunctional**. +- If the user's free-text (the "Other" reply or annotation) adds new scope → see **Cascade rule** below. +- If the user only clarifies an existing item → update **LockedFunctional** and proceed. + +Once questions are answered, **do not ask for explicit confirmation**. Silently move to Round 2. + +## Round 2 — UI Design + +Now deliberate on how the feature presents to the user. Generate 1–3 candidate UI directions. Round 2 candidates may be full-layout wireframes when the whole surface is up for decision, or component-level candidates when only one area is ambiguous. Be explicit which level each candidate represents. + +Call **AskUserQuestion** with options that carry **markdown wireframe previews** in the `preview` field. Wireframes are ASCII boxes: + +``` +┌─────────────────────────────────┐ +│ Header · filter ▾ · +new │ +├─────────────────────────────────┤ +│ ▣ item ⋯ │ +│ ▢ item ⋯ │ +│ ▢ item ⋯ │ +└─────────────────────────────────┘ +``` + +Keep each preview readable in a chat-width column (~70 chars). Show real labels, real density, real affordances — not lorem ipsum. + +Cover the meaningful axes (layout type, hierarchy of actions, empty/loading/error states, dense vs roomy) in **at most 4 questions total**. Don't waste a question on a decision that has one obvious answer. + +When the user responds: +- Selected wireframes/options → **LockedUI**. +- If candidates were component-level, merge the selected components into **LockedUI** as integration decisions. The Final output still needs one composed/merged inline wireframe derived from **LockedFunctional**, **LockedUI**, and any selected **LockedExtras**. +- Free-text that implies new functional behavior (e.g. "add export button" implies an export *flow*) → see **Cascade rule**. +- Pure UI refinements stay in Round 2. + +Silently proceed to Round 3. + +## Round 3 — Extras, Quirks, Out-of-the-Box + +Now generate 4–8 candidate ideas the user *didn't* ask for but might love: micro-interactions, keyboard shortcuts, empty-state delight, power-user features, animations, accessibility wins, surprise affordances, anti-aesthetics-of-AI touches (favor warmth and specificity over generic monochrome polish). + +Call **AskUserQuestion** with `multiSelect: true` for the menu of extras. Each option's `description` should be one sentence explaining the idea and one sentence on the cost/benefit. + +Selected items → **LockedExtras**. + +If the user adds a new functional idea in free-text → **Cascade rule**. + +## Cascade rule (the core contract) + +A **new functional requirement** is any input — from the user OR self-detected from your own proposals — that adds, removes, or changes scope of the feature. Not a UI refinement. Not an extras pick. *New behavior the system must perform.* + +When detected: + +1. **Snapshot** the current round. +2. **Queue** the new piece in **PendingNewPieces** with a one-line description. +3. **Announce briefly**: "New functional piece detected: . Cascading it through R1→R2→R3 before resuming Round ." +4. **Run a focused mini-cascade for that piece only**: + - **R1 for new piece**: AskUserQuestion scoped to just the new piece's functional ambiguities. + - **R2 for new piece**: AskUserQuestion with wireframes scoped to just how this new piece integrates into the already-locked UI (do NOT redesign locked layouts — show how the new piece slots in). + - **R3 for new piece**: AskUserQuestion with extras *for this piece only*. +5. **Merge** the results into the appropriate Locked* sets. +6. **Resume** from the snapshotted round. + +Multiple cascades may stack. Process them depth-first; never lose the snapshot. + +### Self-detecting new functional scope + +Before sending a UI option or an extra, ask yourself silently: *does this option require behavior that isn't already in LockedFunctional?* If yes, you have a choice: +- Drop the option (cleanest). +- Or, if it's genuinely a great idea, **fire the cascade yourself** before sending it. Don't sneak new functional scope into UI/extras questions. + +## Final output + +After Round 3 completes with no pending cascades: + +1. Print a tight consolidated plan in chat with three sections: + - **Functional** — bulleted LockedFunctional. + - **UI** — bulleted LockedUI with one inline wireframe of the final composed layout. + - **Extras** — bulleted LockedExtras. + Keep total under ~50 lines. No filler. +2. Call **ExitPlanMode** to formally request approval. + +### Tool reference: ExitPlanMode + +Use `ExitPlanMode(): void` immediately after the Final output. It has no parameters and returns no value. Calling it signals that **LockedFunctional**, **LockedUI**, and **LockedExtras** are complete, including the final composed wireframe, and asks the user to approve leaving plan mode. Do not call it before all pending cascades are processed. + +## Anti-patterns (do not do) + +- Asking the user to "describe what they want" in prose. Always frame as concrete options. +- Sending more than 4 questions in a single AskUserQuestion call (tool max). +- Slipping new functional behavior into UI or Extras rounds without cascading. +- Asking explicit "lock in? yes/no" questions between rounds — proceed silently unless interrupted. +- Re-litigating LockedFunctional during R2 or R3 unless a cascade explicitly opens that piece. +- Generic AI aesthetics in wireframes — no `font-mono` aesthetic apologies, no centered-everything, no purple gradients in mocked copy. Be specific and warm. +- Long preamble. The user invoked `/plan`; jump to Round 1. + +## Minimal example flow + +User: `/plan a markdown note app with tags` + +You: silent deliberation → AskUserQuestion (R1): +- Q1: "How should tags be created?" (inline `#tag` parsing / explicit tag field / both) +- Q2: "Search scope when filtering by tag?" (notes containing tag / notes tagged-only / both modes) +- Q3: "Multi-user or single-user?" + +User selects + adds "and they sync to iCloud" (new functional scope). + +You: "New piece detected: iCloud sync. Cascading." +→ R1-for-sync: conflict resolution? offline-first? +→ R2-for-sync: sync-status indicator placement? +→ R3-for-sync: optimistic UI? merge-conflict modal? +Merge → resume Round 1. + +… and so on through R2 and R3 of the main plan, then ExitPlanMode. diff --git a/.github/workflows/release-core.yml b/.github/workflows/release-core.yml index 6ebe518fb..eb2b89c46 100644 --- a/.github/workflows/release-core.yml +++ b/.github/workflows/release-core.yml @@ -368,6 +368,55 @@ jobs: cp apps/ade-cli/scripts/install-runtime.sh release-assets/runtime/install.sh chmod 755 release-assets/runtime/install.sh + - name: Validate publish asset manifest + run: | + set -euo pipefail + shopt -s nullglob + + require_file() { + local file="$1" + local label="${2:-$1}" + if [ ! -s "$file" ]; then + echo "::error::Missing or empty $label: $file" + exit 1 + fi + } + + require_glob() { + local pattern="$1" + local label="${2:-$1}" + mapfile -t matches < <(compgen -G "$pattern" || true) + if [ "${#matches[@]}" -eq 0 ]; then + echo "::error::Missing $label matching $pattern" + exit 1 + fi + for file in "${matches[@]}"; do + require_file "$file" "$label" + done + } + + require_glob 'release-assets/mac/*.dmg' 'macOS DMG' + require_glob 'release-assets/mac/*.zip' 'macOS zip' + require_glob 'release-assets/mac/*-mac.zip.blockmap' 'macOS blockmap' + require_file 'release-assets/mac/latest-mac.yml' 'macOS auto-update metadata' + require_glob 'release-assets/win/*.exe' 'Windows installer' + require_glob 'release-assets/win/*.exe.blockmap' 'Windows blockmap' + require_file 'release-assets/win/latest.yml' 'Windows auto-update metadata' + require_file 'release-assets/runtime/install.sh' 'standalone runtime installer' + if [ ! -x 'release-assets/runtime/install.sh' ]; then + echo "::error::Standalone runtime installer is not executable." + exit 1 + fi + + for target in darwin-arm64 darwin-x64 linux-arm64 linux-x64; do + require_file "release-assets/runtime/ade-$target" "ADE runtime binary for $target" + require_file "release-assets/runtime/ade-$target.native.tar.gz" "ADE native dependency archive for $target" + tar -tzf "release-assets/runtime/ade-$target.native.tar.gz" | grep -q '^\./node_modules/' || { + echo "::error::ADE native dependency archive for $target is missing node_modules." + exit 1 + } + done + - name: Create or update draft GitHub release env: GH_TOKEN: ${{ github.token }} diff --git a/apps/ade-cli/src/bootstrap.ts b/apps/ade-cli/src/bootstrap.ts index 65997a223..8d7f73072 100644 --- a/apps/ade-cli/src/bootstrap.ts +++ b/apps/ade-cli/src/bootstrap.ts @@ -980,6 +980,7 @@ export async function createAdeRuntime(args: { logger, pollIntervalMs: 120_000, onUpdate: (snapshot) => pushEvent("runtime", { type: "usage", snapshot }), + onThresholdEvent: (event) => pushEvent("runtime", { type: "usage_threshold", event }), }); const budgetCapService = createBudgetCapService({ db, diff --git a/apps/ade-cli/src/cli.ts b/apps/ade-cli/src/cli.ts index f6b5f7871..ab8f4dce3 100644 --- a/apps/ade-cli/src/cli.ts +++ b/apps/ade-cli/src/cli.ts @@ -10543,9 +10543,11 @@ async function spawnMachineRuntimeDaemon( async function connectMachineRuntimeDaemon( options: GlobalOptions, socketPathOverride?: string | null, + connectOptions: { allowSpawn?: boolean } = {}, ): Promise { const socketPath = await resolveMachineRuntimeSocketPath(socketPathOverride); const label = "ADE runtime daemon socket"; + const allowSpawn = connectOptions.allowSpawn ?? !options.requireSocket; try { const client = await SocketJsonRpcClient.connect( socketPath, @@ -10557,6 +10559,12 @@ async function connectMachineRuntimeDaemon( options, ); if (runtimeVersion && runtimeVersion !== VERSION) { + if (!allowSpawn) { + client.close(); + throw new Error( + `ADE runtime daemon version ${runtimeVersion} does not match CLI version ${VERSION}.`, + ); + } await shutdownMachineRuntimeDaemon(client); const spawned = await spawnMachineRuntimeDaemon(socketPath, options); if (!spawned) { @@ -10583,6 +10591,7 @@ async function connectMachineRuntimeDaemon( } return client; } catch (firstError) { + if (!allowSpawn) throw firstError; const spawned = await spawnMachineRuntimeDaemon(socketPath, options); if (!spawned) throw firstError; try { @@ -10658,7 +10667,9 @@ async function runRuntimeCommand( } if (sub === "start") { - const client = await connectMachineRuntimeDaemon(options, socketOverride); + const client = await connectMachineRuntimeDaemon(options, socketOverride, { + allowSpawn: true, + }); try { const runtimeVersion = await initializeMachineRuntimeDaemon( client, diff --git a/apps/ade-cli/src/headlessLinearServices.ts b/apps/ade-cli/src/headlessLinearServices.ts index 94c3d49eb..ef0fb9b11 100644 --- a/apps/ade-cli/src/headlessLinearServices.ts +++ b/apps/ade-cli/src/headlessLinearServices.ts @@ -116,6 +116,10 @@ export type HeadlessGitHubService = { getStatus: (opts?: { forceRefresh?: boolean; }) => Promise; + getRemoteStatus: () => Promise<{ + repo: { owner: string; name: string } | null; + hasOrigin: boolean; + }>; detectRepo: () => Promise<{ owner: string; name: string } | null>; getRepoOrThrow: () => Promise<{ owner: string; name: string }>; getTokenOrThrow: () => string; @@ -770,6 +774,12 @@ export function createHeadlessGitHubService( return status; } }, + async getRemoteStatus() { + return { + repo: detectGitHubRepo(projectRoot), + hasOrigin: Boolean(readGitOrigin(projectRoot)), + }; + }, async detectRepo() { return detectGitHubRepo(projectRoot); }, diff --git a/apps/ade-cli/src/services/sync/syncHostService.test.ts b/apps/ade-cli/src/services/sync/syncHostService.test.ts index bb8982a63..b36f76db2 100644 --- a/apps/ade-cli/src/services/sync/syncHostService.test.ts +++ b/apps/ade-cli/src/services/sync/syncHostService.test.ts @@ -16,11 +16,20 @@ import { const publishMock = vi.hoisted(() => vi.fn()); const bonjourDestroyMock = vi.hoisted(() => vi.fn()); const bonjourConstructorMock = vi.hoisted(() => vi.fn()); +const spawnMock = vi.hoisted(() => vi.fn()); vi.mock("bonjour-service", () => ({ Bonjour: bonjourConstructorMock, })); +vi.mock("node:child_process", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + spawn: spawnMock, + }; +}); + type BonjourPublishArgs = { name: string; type: string; @@ -195,6 +204,27 @@ function publishedAnnouncements(): BonjourPublishArgs[] { return publishMock.mock.calls.map(([payload]) => payload as BonjourPublishArgs); } +type NativeDiscoveryProcess = { + child: { + kill: ReturnType; + once: ReturnType void], NativeDiscoveryProcess["child"]>>; + unref: ReturnType; + }; + handlers: Map void>; +}; + +function createNativeDiscoveryProcess(): NativeDiscoveryProcess { + const handlers = new Map void>(); + const child = {} as NativeDiscoveryProcess["child"]; + child.kill = vi.fn(); + child.once = vi.fn<[string, (...args: unknown[]) => void], NativeDiscoveryProcess["child"]>((event, handler) => { + handlers.set(event, handler); + return child; + }); + child.unref = vi.fn(); + return { child, handlers }; +} + function createHostArgs(projectRoot: string, projects: SyncMobileProjectSummary[]) { return { db: { @@ -275,14 +305,25 @@ function createHostArgs(projectRoot: string, projects: SyncMobileProjectSummary[ } describe("createSyncHostService LAN discovery", () => { + let originalPlatform: PropertyDescriptor | undefined; + let originalElectronVersion: PropertyDescriptor | undefined; + beforeEach(() => { publishMock.mockReset(); + spawnMock.mockReset(); bonjourDestroyMock.mockReset(); bonjourConstructorMock.mockReset(); + originalPlatform = Object.getOwnPropertyDescriptor(process, "platform"); + originalElectronVersion = Object.getOwnPropertyDescriptor(process.versions, "electron"); bonjourConstructorMock.mockImplementation(() => ({ publish: publishMock, destroy: bonjourDestroyMock, })); + spawnMock.mockImplementation(() => ({ + kill: vi.fn(), + once: vi.fn(), + unref: vi.fn(), + })); publishMock.mockImplementation(() => ({ on: vi.fn(), stop: vi.fn(), @@ -290,6 +331,14 @@ describe("createSyncHostService LAN discovery", () => { }); afterEach(() => { + if (originalPlatform) { + Object.defineProperty(process, "platform", originalPlatform); + } + if (originalElectronVersion) { + Object.defineProperty(process.versions, "electron", originalElectronVersion); + } else { + Reflect.deleteProperty(process.versions, "electron"); + } vi.restoreAllMocks(); }); @@ -340,4 +389,133 @@ describe("createSyncHostService LAN discovery", () => { cleanup(); } }); + + it("forces a fresh LAN Bonjour announcement when discovery is explicitly refreshed", async () => { + const { projectRoot, cleanup } = createTempProjectRoot(); + const publishedServices: Array<{ on: ReturnType; stop: ReturnType }> = []; + publishMock.mockImplementation(() => { + const service = { on: vi.fn(), stop: vi.fn() }; + publishedServices.push(service); + return service; + }); + const host = createSyncHostService( + createHostArgs(projectRoot, [createDiscoveryProject({ id: "project-1" })]) as unknown as Parameters< + typeof createSyncHostService + >[0], + ); + + try { + await host.waitUntilListening(); + await vi.waitFor(() => { + expect(publishedAnnouncements().some((announcement) => announcement.txt.projectCount === "1")).toBe(true); + }); + + const activeAnnouncement = publishedServices[publishedServices.length - 1]; + publishMock.mockClear(); + + host.refreshLanDiscovery(); + expect(publishMock).not.toHaveBeenCalled(); + expect(activeAnnouncement.stop).not.toHaveBeenCalled(); + + host.refreshLanDiscovery({ forceLan: true }); + + expect(activeAnnouncement.stop).toHaveBeenCalledTimes(1); + await vi.waitFor(() => { + expect(publishMock).toHaveBeenCalledTimes(1); + }); + expect(publishedAnnouncements()[0]?.txt.projectCount).toBe("1"); + } finally { + await host.dispose(); + cleanup(); + } + }); + + it("publishes LAN discovery through native dns-sd when running under Electron on macOS", async () => { + Object.defineProperty(process, "platform", { value: "darwin", configurable: true }); + Object.defineProperty(process.versions, "electron", { + value: "35.0.0", + configurable: true, + }); + const { projectRoot, cleanup } = createTempProjectRoot(); + const nativeProcesses: Array<{ kill: ReturnType; once: ReturnType; unref: ReturnType }> = []; + spawnMock.mockImplementation(() => { + const child = { kill: vi.fn(), once: vi.fn(), unref: vi.fn() }; + nativeProcesses.push(child); + return child; + }); + const host = createSyncHostService( + createHostArgs(projectRoot, [createDiscoveryProject({ id: "project-1" })]) as unknown as Parameters< + typeof createSyncHostService + >[0], + ); + + try { + await host.waitUntilListening(); + await vi.waitFor(() => { + expect(spawnMock.mock.calls.some(([, args]) => Array.isArray(args) && args.includes("projectCount=1"))).toBe(true); + }); + + expect(bonjourConstructorMock).not.toHaveBeenCalled(); + expect(publishMock).not.toHaveBeenCalled(); + const [, args] = spawnMock.mock.calls.find(([, candidateArgs]) => + Array.isArray(candidateArgs) && candidateArgs.includes("projectCount=1") + )!; + const publishedPort = String(args[4]); + expect(args).toEqual(expect.arrayContaining([ + "-R", + `ADE Sync ADE Build Host ${publishedPort}`, + "_ade-sync._tcp", + "local", + publishedPort, + "projects=project-1", + "projectNames=Project", + "addresses=192.168.1.50,100.64.0.10", + ])); + expect(nativeProcesses.at(-1)?.unref).toHaveBeenCalledTimes(1); + } finally { + await host.dispose(); + cleanup(); + } + expect(nativeProcesses.at(-1)?.kill).toHaveBeenCalledWith("SIGTERM"); + }); + + it("falls back to Bonjour when the native dns-sd publisher exits", async () => { + Object.defineProperty(process, "platform", { value: "darwin", configurable: true }); + Object.defineProperty(process.versions, "electron", { + value: "35.0.0", + configurable: true, + }); + const { projectRoot, cleanup } = createTempProjectRoot(); + const nativeProcesses: ReturnType[] = []; + spawnMock.mockImplementation(() => { + const nativeProcess = createNativeDiscoveryProcess(); + nativeProcesses.push(nativeProcess); + return nativeProcess.child; + }); + const host = createSyncHostService( + createHostArgs(projectRoot, [createDiscoveryProject({ id: "project-1" })]) as unknown as Parameters< + typeof createSyncHostService + >[0], + ); + + try { + await host.waitUntilListening(); + await vi.waitFor(() => { + expect(spawnMock.mock.calls.some(([, args]) => Array.isArray(args) && args.includes("projectCount=1"))).toBe(true); + }); + + const nativeSpawnCount = spawnMock.mock.calls.length; + publishMock.mockClear(); + nativeProcesses.at(-1)?.handlers.get("exit")?.(1, null); + + await vi.waitFor(() => { + expect(publishMock).toHaveBeenCalledTimes(1); + }, { timeout: 2_000 }); + expect(spawnMock).toHaveBeenCalledTimes(nativeSpawnCount); + expect(publishedAnnouncements()[0]?.txt.projectCount).toBe("1"); + } finally { + await host.dispose(); + cleanup(); + } + }); }); diff --git a/apps/ade-cli/src/services/sync/syncHostService.ts b/apps/ade-cli/src/services/sync/syncHostService.ts index 8e5d76293..03fd2d585 100644 --- a/apps/ade-cli/src/services/sync/syncHostService.ts +++ b/apps/ade-cli/src/services/sync/syncHostService.ts @@ -1,5 +1,5 @@ import fs from "node:fs"; -import { execFile } from "node:child_process"; +import { execFile, spawn, type ChildProcess } from "node:child_process"; import os from "node:os"; import path from "node:path"; import { fileURLToPath } from "node:url"; @@ -105,6 +105,8 @@ const DEFAULT_SYNC_HEARTBEAT_MISS_LIMIT = 2; const MOBILE_SYNC_HEARTBEAT_MISS_LIMIT = 6; const DEFAULT_SYNC_POLL_INTERVAL_MS = 400; const DEFAULT_BRAIN_STATUS_INTERVAL_MS = 5_000; +const NATIVE_LAN_DISCOVERY_RECOVERY_DELAY_MS = 1_000; +const NATIVE_LAN_DISCOVERY_FALLBACK_MS = 30_000; const DEFAULT_TERMINAL_SNAPSHOT_BYTES = 220_000; const PEER_BACKPRESSURE_BYTES = 4 * 1024 * 1024; const MOBILE_COMMAND_RESULT_CACHE_TTL_MS = 30 * 60 * 1000; @@ -1104,6 +1106,9 @@ export function createSyncHostService(args: SyncHostServiceArgs) { let startupError: Error | null = null; let bonjourInstance: Bonjour | null = null; let bonjourAnnouncement: BonjourService | null = null; + let nativeBonjourProcess: ChildProcess | null = null; + let nativeBonjourRecoveryTimer: ReturnType | null = null; + let nativeLanDiscoveryFallbackUntilMs = 0; let bonjourPort: number | null = null; let bonjourSignature: string | null = null; let bonjourProjectTxt: { projects: string; projectNames: string; projectCount: string } = { @@ -1256,7 +1261,93 @@ export function createSyncHostService(args: SyncHostServiceArgs) { }); }); - const publishLanDiscovery = (port: number): void => { + const clearNativeLanDiscoveryRecovery = (): void => { + if (!nativeBonjourRecoveryTimer) return; + clearTimeout(nativeBonjourRecoveryTimer); + nativeBonjourRecoveryTimer = null; + }; + + const scheduleNativeLanDiscoveryRecovery = (port: number): void => { + clearNativeLanDiscoveryRecovery(); + if (disposed || !discoveryEnabled || bonjourPort !== port) return; + nativeBonjourRecoveryTimer = setTimeout(() => { + nativeBonjourRecoveryTimer = null; + if (disposed || !discoveryEnabled || bonjourPort !== port) return; + publishLanDiscovery(port); + }, NATIVE_LAN_DISCOVERY_RECOVERY_DELAY_MS); + if ( + typeof nativeBonjourRecoveryTimer === "object" + && typeof (nativeBonjourRecoveryTimer as { unref?: unknown }).unref === "function" + ) { + (nativeBonjourRecoveryTimer as { unref: () => void }).unref(); + } + }; + + const stopNativeLanDiscovery = (): void => { + clearNativeLanDiscoveryRecovery(); + const child = nativeBonjourProcess; + if (!child) return; + nativeBonjourProcess = null; + try { + child.kill("SIGTERM"); + } catch { + // ignore cleanup failures + } + }; + + const stopBonjourAnnouncement = (): void => { + if (!bonjourAnnouncement) return; + try { + bonjourAnnouncement.stop?.(); + } catch { + // ignore cleanup failures + } + bonjourAnnouncement = null; + }; + + const publishNativeLanDiscovery = ( + serviceName: string, + port: number, + txt: Record, + ): void => { + clearNativeLanDiscoveryRecovery(); + stopNativeLanDiscovery(); + const child = spawn("dns-sd", [ + "-R", + serviceName, + "_ade-sync._tcp", + "local", + String(port), + ...Object.entries(txt).map(([key, value]) => `${key}=${value}`), + ], { + stdio: "ignore", + }); + nativeBonjourProcess = child; + child.unref(); + child.once("error", (error) => { + if (nativeBonjourProcess !== child) return; + nativeBonjourProcess = null; + nativeLanDiscoveryFallbackUntilMs = Date.now() + NATIVE_LAN_DISCOVERY_FALLBACK_MS; + args.logger.warn("sync_host.discovery_native_publish_failed", { + error: error instanceof Error ? error.message : String(error), + }); + scheduleNativeLanDiscoveryRecovery(port); + }); + child.once("exit", (code, signal) => { + if (nativeBonjourProcess !== child) return; + nativeBonjourProcess = null; + nativeLanDiscoveryFallbackUntilMs = Date.now() + NATIVE_LAN_DISCOVERY_FALLBACK_MS; + args.logger.warn("sync_host.discovery_native_exited", { code, signal }); + scheduleNativeLanDiscoveryRecovery(port); + }); + }; + + const shouldUseNativeLanDiscovery = (): boolean => + process.platform === "darwin" + && typeof process.versions.electron === "string" + && Date.now() >= nativeLanDiscoveryFallbackUntilMs; + + const publishLanDiscovery = (port: number, options?: { force?: boolean }): void => { if (disposed) return; if (!discoveryEnabled) { unpublishLanDiscovery(); @@ -1291,7 +1382,18 @@ export function createSyncHostService(args: SyncHostServiceArgs) { tailscaleDnsName: tailscaleDnsName.endsWith(".ts.net") ? tailscaleDnsName : "", }; const signature = JSON.stringify({ hostName, port, txt }); - if (bonjourAnnouncement && bonjourPort === port && bonjourSignature === signature) return; + const alreadyPublished = bonjourPort === port && bonjourSignature === signature; + if (!options?.force && alreadyPublished && (bonjourAnnouncement || nativeBonjourProcess)) return; + const serviceName = `ADE Sync ${hostName} ${port}`; + if (shouldUseNativeLanDiscovery()) { + stopBonjourAnnouncement(); + bonjourPort = port; + bonjourSignature = signature; + publishNativeLanDiscovery(serviceName, port, txt); + refreshLanDiscoveryProjects(port); + return; + } + stopNativeLanDiscovery(); if (!bonjourInstance) { bonjourInstance = new Bonjour(undefined, (error: unknown) => { args.logger.warn("sync_host.discovery_error", { @@ -1299,18 +1401,11 @@ export function createSyncHostService(args: SyncHostServiceArgs) { }); }); } - if (bonjourAnnouncement) { - try { - bonjourAnnouncement.stop?.(); - } catch { - // ignore cleanup failures - } - bonjourAnnouncement = null; - } + stopBonjourAnnouncement(); bonjourPort = port; bonjourSignature = signature; bonjourAnnouncement = bonjourInstance.publish({ - name: `ADE Sync ${hostName} ${port}`, + name: serviceName, type: SYNC_MDNS_SERVICE_TYPE, protocol: "tcp", port, @@ -1367,13 +1462,8 @@ export function createSyncHostService(args: SyncHostServiceArgs) { }; const unpublishLanDiscovery = (): void => { - if (!bonjourAnnouncement) return; - try { - bonjourAnnouncement.stop?.(); - } catch { - // ignore cleanup failures - } - bonjourAnnouncement = null; + stopNativeLanDiscovery(); + stopBonjourAnnouncement(); bonjourPort = null; bonjourSignature = null; }; @@ -3055,10 +3145,10 @@ export function createSyncHostService(args: SyncHostServiceArgs) { setLocalActiveLanePresence(laneIds); }, - refreshLanDiscovery(options?: { forceTailnet?: boolean }): void { + refreshLanDiscovery(options?: { forceLan?: boolean; forceTailnet?: boolean }): void { const address = server.address(); if (typeof address === "object" && address) { - publishLanDiscovery(address.port); + publishLanDiscovery(address.port, { force: options?.forceLan }); publishTailnetDiscovery(address.port, { force: options?.forceTailnet }); } }, @@ -3082,7 +3172,7 @@ export function createSyncHostService(args: SyncHostServiceArgs) { return; } if (typeof address === "object" && address) { - publishLanDiscovery(address.port); + publishLanDiscovery(address.port, { force: true }); publishTailnetDiscovery(address.port, { force: true }); } }, diff --git a/apps/ade-cli/src/services/sync/syncService.ts b/apps/ade-cli/src/services/sync/syncService.ts index bf9dacab0..76945c9ba 100644 --- a/apps/ade-cli/src/services/sync/syncService.ts +++ b/apps/ade-cli/src/services/sync/syncService.ts @@ -906,12 +906,23 @@ export function createSyncService(args: SyncServiceArgs) { }, async getStatus(options?: SyncGetStatusArgs): Promise { - const localDevice = deviceRegistryService.ensureLocalDevice(); + let localDevice = deviceRegistryService.ensureLocalDevice(); + const activeHostPort = hostService?.getPort() ?? null; + if (activeHostPort != null && localDevice.lastPort !== activeHostPort) { + localDevice = deviceRegistryService.touchLocalDevice({ + lastSeenAt: nowIso(), + lastHost: localDevice.ipAddresses[0] ?? localDevice.tailscaleIp ?? localDevice.lastHost, + lastPort: activeHostPort, + }); + } const cluster = deviceRegistryService.getClusterState(); const savedDraft = readSavedDraft(); - const currentBrain = cluster + const rawCurrentBrain = cluster ? deviceRegistryService.getDevice(cluster.brainDeviceId) : localDevice; + const currentBrain = rawCurrentBrain?.deviceId === localDevice.deviceId + ? localDevice + : rawCurrentBrain; const isLocalBrain = forceHostRole || (cluster ? cluster.brainDeviceId === localDevice.deviceId : !savedDraft && !syncPeerService.isConnected()); @@ -969,7 +980,7 @@ export function createSyncService(args: SyncServiceArgs) { }, async refreshDiscovery(): Promise { - hostService?.refreshLanDiscovery?.({ forceTailnet: true }); + hostService?.refreshLanDiscovery?.({ forceLan: true, forceTailnet: true }); const snapshot = await this.getStatus(); args.onStatusChanged?.(snapshot); return snapshot; diff --git a/apps/ade-cli/src/tuiClient/__tests__/ChatView.test.tsx b/apps/ade-cli/src/tuiClient/__tests__/ChatView.test.tsx index a02ac34ab..c93bcc81e 100644 --- a/apps/ade-cli/src/tuiClient/__tests__/ChatView.test.tsx +++ b/apps/ade-cli/src/tuiClient/__tests__/ChatView.test.tsx @@ -1,7 +1,8 @@ import React from "react"; import { describe, expect, it } from "vitest"; import { render } from "ink-testing-library"; -import { ChatView } from "../components/ChatView"; +import { ChatView, computeChatScrollMaxOffset, renderChatTranscriptPlainText } from "../components/ChatView"; +import { buildSubagentTranscriptEvents } from "../subagentPane"; import type { AgentChatEventEnvelope, AgentChatSessionSummary } from "../../../../desktop/src/shared/types/chat"; const session: AgentChatSessionSummary = { @@ -17,9 +18,13 @@ const session: AgentChatSessionSummary = { summary: null, }; +function stripAnsi(value: string): string { + return value.replace(/\[[0-9;]*m/g, ""); +} + function renderEvents( events: AgentChatEventEnvelope[], - options: { maxRows?: number; scrollOffsetRows?: number; width?: number } = {}, + options: { maxRows?: number; scrollOffsetRows?: number; width?: number; streaming?: boolean } = {}, ): string { const result = render( , ); - return result.lastFrame() ?? ""; + return stripAnsi(result.lastFrame() ?? ""); +} + +function transcriptLines(frame: string): string[] { + return frame.split(/\r?\n/); } describe("ChatView", () => { it("renders a bordered hero card with the ADE wordmark when the chat is empty", () => { const frame = renderEvents([]); - expect(frame).toMatch(/[╭╮╯╰]/); - expect(frame).toContain("██████"); - expect(frame).toContain("ade code"); - expect(frame).toContain("v0.1"); - expect(frame).toContain("Project"); - expect(frame).toContain("Lane"); - expect(frame).toContain("Branch"); + // Hero card uses a bordered box + expect(frame).toMatch(/[╭╮╯╰┌┐└┘]/); + expect(frame).toContain("AGENTIC DEVELOPMENT ENVIRONMENT"); + expect(frame).toContain("project"); + expect(frame).toContain("lane"); + expect(frame).toContain("branch"); expect(frame).toContain("Primary"); - expect(frame).toContain("type to chat"); - expect(frame).toContain("commands"); + expect(frame).toContain("type"); + expect(frame).toContain("cmds"); + }); + + it("does not invite chat input when the selected lane worktree is missing", () => { + const result = render( + , + ); + const frame = stripAnsi(result.lastFrame() ?? ""); + + expect(frame).toContain("worktree missing"); + expect(frame).toContain("restore lane before chat"); + expect(frame).not.toContain("type to chat"); + }); + + it("shows a model working indicator while a turn is active before text arrives", () => { + const frame = renderEvents([ + { + sessionId: "s1", + timestamp: "2026-01-01T12:00:00.000Z", + sequence: 1, + event: { type: "user_message", text: "check status", turnId: "turn-active" }, + }, + { + sessionId: "s1", + timestamp: "2026-01-01T12:00:01.000Z", + sequence: 2, + event: { type: "status", turnStatus: "started", turnId: "turn-active" }, + }, + ], { streaming: true, width: 80 }); + + expect(frame).toContain("check status"); + expect(frame).toContain("model working"); + }); + + it("keeps the model working indicator visible while active text is streaming", () => { + const frame = renderEvents([ + { + sessionId: "s1", + timestamp: "2026-01-01T12:00:00.000Z", + sequence: 1, + event: { type: "text", text: "I found the issue.", turnId: "turn-active" }, + }, + ], { streaming: true, width: 80 }); + + expect(frame).toContain("I found the issue."); + expect(frame).toContain("model working"); + }); + + it("renders context compaction as an explicit active state", () => { + const frame = renderEvents([ + { + sessionId: "s1", + timestamp: "2026-01-01T12:00:00.000Z", + sequence: 1, + event: { type: "codex_context_compaction", state: "started", trigger: "auto", turnId: "turn-active" }, + }, + ], { width: 80 }); + + expect(frame).toContain("compacting context"); + expect(frame).toContain("auto"); + }); + + it("renders queued steer messages as staged instead of normal sent bubbles", () => { + const frame = renderEvents([ + { + sessionId: "s1", + timestamp: "2026-01-01T12:00:00.000Z", + sequence: 1, + event: { type: "user_message", text: "follow this after the tool finishes", steerId: "steer-1", deliveryState: "queued", turnId: "turn-active" }, + }, + ], { width: 80 }); + + expect(frame).toContain("staged message"); + expect(frame).toContain("sends after turn"); + expect(frame).toContain("follow this after the tool finishes"); + expect(frame).not.toMatch(/[╭╮╯╰]/); + }); + + it("removes staged steer rows once the steer is delivered", () => { + const frame = renderEvents([ + { + sessionId: "s1", + timestamp: "2026-01-01T12:00:00.000Z", + sequence: 1, + event: { type: "user_message", text: "queued version", steerId: "steer-1", deliveryState: "queued", turnId: "turn-active" }, + }, + { + sessionId: "s1", + timestamp: "2026-01-01T12:00:01.000Z", + sequence: 2, + event: { type: "user_message", text: "delivered version", steerId: "steer-1", deliveryState: "delivered", turnId: "turn-active" }, + }, + ], { width: 80 }); + + expect(frame).not.toContain("queued version"); + expect(frame).not.toContain("staged message"); + expect(frame).toContain("delivered version"); + }); + + it("keeps steer lifecycle notices out of visible chat blocks", () => { + const frame = renderEvents([ + { + sessionId: "s1", + timestamp: "2026-01-01T12:00:00.000Z", + sequence: 1, + event: { type: "user_message", text: "queued version", steerId: "steer-1", deliveryState: "queued", turnId: "turn-active" }, + }, + { + sessionId: "s1", + timestamp: "2026-01-01T12:00:00.500Z", + sequence: 2, + event: { type: "system_notice", steerId: "steer-1", message: "Message queued" } as never, + }, + { + sessionId: "s1", + timestamp: "2026-01-01T12:00:01.000Z", + sequence: 3, + event: { type: "system_notice", steerId: "steer-1", message: "Delivering queued message" } as never, + }, + { + sessionId: "s1", + timestamp: "2026-01-01T12:00:01.500Z", + sequence: 4, + event: { type: "system_notice", steerId: "steer-1", message: "Queued message cancelled" } as never, + }, + ], { width: 80 }); + + expect(frame).not.toContain("Message queued"); + expect(frame).not.toContain("Delivering queued message"); + expect(frame).not.toContain("Queued message cancelled"); }); it("right-aligns user messages inside an accent-bordered bubble", () => { @@ -62,14 +210,67 @@ describe("ChatView", () => { ]); const lines = frame.split(/\r?\n/); const bubbleLine = lines.find((line) => line.includes("hello")); + const borderLine = lines.find((line) => line.includes("╭")); expect(bubbleLine, "expected the rendered frame to include the user message").toBeDefined(); // Round border characters wrap the bubble; verify presence so layout stays a bubble. expect(frame).toMatch(/[╭╮╯╰]/); - // Bubble is right-aligned: the content sits past the half-width of the frame. + expect(borderLine?.trim().length).toBeLessThan(24); + // Bubble is right-aligned: a compact bubble still has left padding. const helloIndex = (bubbleLine ?? "").indexOf("hello"); expect(helloIndex).toBeGreaterThan(0); }); + it("keeps a simple exchange quiet without message metadata or turn footer", () => { + const turnId = "quiet-turn"; + const frame = renderEvents([ + { sessionId: "s1", timestamp: "2026-01-01T12:00:00.000Z", sequence: 1, event: { type: "user_message", text: "test message", turnId } }, + { sessionId: "s1", timestamp: "2026-01-01T12:00:01.000Z", sequence: 2, event: { type: "reasoning", text: "internal thought", turnId } }, + { sessionId: "s1", timestamp: "2026-01-01T12:00:01.500Z", sequence: 3, event: { type: "activity", activity: "thinking", detail: "Thinking through the answer", turnId } }, + { sessionId: "s1", timestamp: "2026-01-01T12:00:02.000Z", sequence: 4, event: { type: "text", text: "Got it.", turnId } }, + { sessionId: "s1", timestamp: "2026-01-01T12:00:03.000Z", sequence: 5, event: { type: "done", turnId, status: "completed", usage: { inputTokens: 40, outputTokens: 12 } } }, + ], { width: 80 }); + + expect(frame).toContain("test message"); + expect(frame).toContain("Got it."); + expect(frame).not.toContain("internal thought"); + expect(frame).not.toContain("Thinking through the answer"); + expect(frame).not.toContain("Codex"); + expect(frame).not.toContain("gpt"); + expect(frame).not.toContain("tok"); + expect(frame).not.toContain("[status]"); + expect(frame).not.toContain("[done]"); + }); + + it("keeps startup/auth notices out of the transcript header spam path", () => { + const result = render( + , + ); + const frame = stripAnsi(result.lastFrame() ?? ""); + + expect(frame).toContain("Failed to authenticate. API Error: 401 Invalid authentication credentials"); + expect(frame).toContain("Starting `claude auth login` in this terminal."); + expect(frame).toContain("Claude auth completed. Refreshing provider status."); + expect(frame).not.toContain("Session ready"); + expect(frame).not.toContain("Hook: SessionStart"); + expect(frame).not.toContain("Claude ·"); + expect(frame).not.toContain("ADE Code ·"); + expect(frame).not.toContain("12:00"); + }); + it("renders assistant messages flat without the bubble border", () => { const frame = renderEvents([ { @@ -130,21 +331,88 @@ describe("ChatView", () => { timestamp: `2026-01-01T12:00:${String(index).padStart(2, "0")}.000Z`, sequence: index + 1, event: index % 2 === 0 - ? { type: "user_message", text: `user row ${index + 1}` } - : { type: "text", text: `assistant row ${index + 1}` }, + ? { type: "user_message", text: `user row ${String(index + 1).padStart(2, "0")}` } + : { type: "text", text: `assistant row ${String(index + 1).padStart(2, "0")}` }, })); const bottom = renderEvents(events, { maxRows: 5, width: 80 }); expect(bottom).toContain("assistant row 12"); - expect(bottom).not.toContain("user row 1"); + expect(bottom).not.toContain("user row 01"); expect(bottom).toContain("↑ older messages"); const older = renderEvents(events, { maxRows: 5, scrollOffsetRows: 8, width: 80 }); expect(older).toContain("row"); expect(older).toContain("↓ newer messages"); expect(older).not.toContain("assistant row 12"); + expect(older.split("\n").at(-1)).toContain("↓ newer messages"); + }); + + it("stays at the oldest rows when the transcript is overscrolled", () => { + const events = Array.from({ length: 12 }, (_, index): AgentChatEventEnvelope => ({ + sessionId: "s1", + timestamp: `2026-01-01T12:00:${String(index).padStart(2, "0")}.000Z`, + sequence: index + 1, + event: index % 2 === 0 + ? { type: "user_message", text: `user row ${String(index + 1).padStart(2, "0")}` } + : { type: "text", text: `assistant row ${String(index + 1).padStart(2, "0")}` }, + })); + + const frame = renderEvents(events, { maxRows: 5, scrollOffsetRows: 100_000, width: 80 }); + expect(frame).toContain("user row 01"); + expect(frame).toContain("↓ newer messages"); + expect(frame).not.toContain("assistant row 12"); }); - it("indents tool call output", () => { + it("keeps a fixed transcript row count while scroll indicators move", () => { + const events = Array.from({ length: 14 }, (_, index): AgentChatEventEnvelope => ({ + sessionId: "s1", + timestamp: `2026-01-01T12:00:${String(index).padStart(2, "0")}.000Z`, + sequence: index + 1, + event: index % 2 === 0 + ? { type: "user_message", text: `user row ${String(index + 1).padStart(2, "0")}` } + : { type: "text", text: `assistant row ${String(index + 1).padStart(2, "0")}` }, + })); + const maxRows = 6; + const frames = [ + renderEvents(events, { maxRows, scrollOffsetRows: 0, width: 80 }), + renderEvents(events, { maxRows, scrollOffsetRows: 5, width: 80 }), + renderEvents(events, { maxRows, scrollOffsetRows: 100_000, width: 80 }), + ]; + + for (const frame of frames) { + expect(transcriptLines(frame)).toHaveLength(maxRows); + } + expect(transcriptLines(frames[1]!).at(-1)).toContain("↓ newer messages"); + expect(transcriptLines(frames[2]!)[0]).not.toContain("↑ older messages"); + expect(transcriptLines(frames[2]!).at(-1)).toContain("↓ newer messages"); + }); + + it("can scroll to the true oldest row in long histories beyond the old render cap", () => { + const events = Array.from({ length: 240 }, (_, index): AgentChatEventEnvelope => ({ + sessionId: "s1", + timestamp: `2026-01-01T12:${String(Math.floor(index / 60)).padStart(2, "0")}:${String(index % 60).padStart(2, "0")}.000Z`, + sequence: index + 1, + event: index % 2 === 0 + ? { type: "user_message", text: `user row ${String(index + 1).padStart(3, "0")}` } + : { type: "text", text: `assistant row ${String(index + 1).padStart(3, "0")}` }, + })); + const maxRows = 8; + const maxOffset = computeChatScrollMaxOffset({ + events, + notices: [], + activeSession: session, + maxRows, + width: 80, + }); + + expect(maxOffset).toBeGreaterThan(200); + const frame = renderEvents(events, { maxRows, scrollOffsetRows: maxOffset, width: 80 }); + expect(frame).toContain("user row 001"); + expect(frame).not.toContain("user row 041"); + expect(transcriptLines(frame)).toHaveLength(maxRows); + expect(transcriptLines(frame).at(-1)).toContain("↓ newer messages"); + }); + + it("renders single command in a work block with bash header and command arg", () => { const frame = renderEvents([ { sessionId: "s1", @@ -152,11 +420,316 @@ describe("ChatView", () => { sequence: 1, event: { type: "command", command: "git branch", cwd: "/repo", output: "main", itemId: "cmd-1", status: "completed", exitCode: 0, durationMs: 12 }, }, - ]); - const lines = frame.split(/\r?\n/).filter((line) => line.includes("run git branch")); - expect(lines.length).toBeGreaterThan(0); - for (const line of lines) { - expect(line.startsWith(" ")).toBe(true); - } + ], { width: 100 }); + expect(frame).toMatch(/▸\s+working/); + expect(frame).toMatch(/✓ bash/); + expect(frame).toContain("git branch"); + }); + + it("groups consecutive tool calls into a live work block with counter and last tool row", () => { + const turnId = "turn-live"; + const events: AgentChatEventEnvelope[] = [ + { + sessionId: "s1", + timestamp: "2026-01-01T12:00:00.000Z", + sequence: 1, + event: { type: "command", command: "npm test", cwd: "/repo", output: "", itemId: "cmd-a", status: "completed", exitCode: 0, durationMs: 2100, turnId }, + }, + { + sessionId: "s1", + timestamp: "2026-01-01T12:00:02.000Z", + sequence: 2, + event: { type: "file_change", path: "src/auth.ts", diff: "+a\n+b\n-c\n", kind: "modify", itemId: "fc-a", status: "completed", turnId }, + }, + { + sessionId: "s1", + timestamp: "2026-01-01T12:00:03.000Z", + sequence: 3, + event: { type: "command", command: "npm run typecheck", cwd: "/repo", output: "", itemId: "cmd-b", status: "running", turnId }, + }, + ]; + const frame = renderEvents(events, { width: 100 }); + expect(frame).toMatch(/working · 3 tools so far/); + // Newest tool (typecheck) is on top of the live list. + expect(frame).toContain("npm run typecheck"); + }); + + it("keeps subagent lifecycle and child tool chatter out of the center transcript", () => { + const turnId = "turn-subagents"; + const frame = renderEvents([ + { + sessionId: "s1", + timestamp: "2026-01-01T12:00:00.000Z", + sequence: 1, + event: { + type: "tool_call", + tool: "spawn_agent", + args: { message: "Explore renderer" }, + itemId: "spawn-1", + turnId, + }, + }, + { + sessionId: "s1", + timestamp: "2026-01-01T12:00:01.000Z", + sequence: 2, + event: { + type: "subagent_started", + taskId: "agent-1", + parentToolUseId: "spawn-1", + description: "child launch spam", + turnId, + }, + }, + { + sessionId: "s1", + timestamp: "2026-01-01T12:00:02.000Z", + sequence: 3, + event: { + type: "tool_call", + tool: "read_file", + args: { path: "src/noisy-child.ts" }, + itemId: "child-tool-1", + parentItemId: "spawn-1", + turnId, + }, + }, + { + sessionId: "s1", + timestamp: "2026-01-01T12:00:03.000Z", + sequence: 4, + event: { + type: "tool_result", + tool: "read_file", + result: "child tool result spam", + itemId: "child-tool-1", + status: "completed", + turnId, + }, + }, + { + sessionId: "s1", + timestamp: "2026-01-01T12:00:04.000Z", + sequence: 5, + event: { + type: "subagent_progress", + taskId: "agent-1", + parentToolUseId: "spawn-1", + summary: "child progress spam", + turnId, + }, + }, + { + sessionId: "s1", + timestamp: "2026-01-01T12:00:05.000Z", + sequence: 6, + event: { + type: "subagent_result", + taskId: "agent-1", + parentToolUseId: "spawn-1", + status: "completed", + summary: "child result spam", + turnId, + }, + }, + ], { width: 100 }); + + expect(frame).toContain("working · 1 tool so far"); + expect(frame).toContain("spawn_agent"); + expect(frame).toContain("Explore renderer"); + expect(frame).not.toContain("child launch spam"); + expect(frame).not.toContain("read_file"); + expect(frame).not.toContain("noisy-child"); + expect(frame).not.toContain("child tool result spam"); + expect(frame).not.toContain("child progress spam"); + expect(frame).not.toContain("child result spam"); + }); + + it("renders a selected subagent transcript with the same ChatView format", () => { + const turnId = "turn-subagents"; + const events: AgentChatEventEnvelope[] = [ + { + sessionId: "s1", + timestamp: "2026-01-01T12:00:00.000Z", + sequence: 1, + event: { type: "subagent_started", taskId: "agent-1", parentToolUseId: "spawn-1", description: "Explore renderer", turnId }, + }, + { + sessionId: "s1", + timestamp: "2026-01-01T12:00:01.000Z", + sequence: 2, + event: { type: "tool_call", tool: "read_file", args: { path: "src/child.ts" }, itemId: "child-tool-1", parentItemId: "spawn-1", turnId }, + }, + { + sessionId: "s1", + timestamp: "2026-01-01T12:00:02.000Z", + sequence: 3, + event: { type: "tool_result", tool: "read_file", result: "child output", itemId: "child-tool-1", status: "completed", turnId }, + }, + { + sessionId: "s1", + timestamp: "2026-01-01T12:00:03.000Z", + sequence: 4, + event: { type: "subagent_progress", taskId: "agent-1", parentToolUseId: "spawn-1", summary: "found the renderer path", turnId }, + }, + { + sessionId: "s1", + timestamp: "2026-01-01T12:00:04.000Z", + sequence: 5, + event: { type: "subagent_result", taskId: "agent-2", parentToolUseId: "spawn-2", status: "completed", summary: "unrelated agent result", turnId }, + }, + ]; + const transcriptEvents = buildSubagentTranscriptEvents({ + events, + activeSession: session, + snapshot: { + id: "agent-1", + name: "Explore renderer", + kind: "subagent", + status: "running", + summary: "found the renderer path", + parentToolUseId: "spawn-1", + turnId, + }, + }); + const transcriptBody = transcriptEvents + .map((entry) => JSON.stringify(entry.event)) + .join("\n"); + const frame = renderEvents(transcriptEvents, { width: 100, maxRows: 40 }); + expect(frame).toContain("Viewing subagent: Explore renderer"); + expect(frame).toContain("Leave the agents pane to return to the main chat."); + expect(transcriptBody).toContain("Subagent started: Explore renderer"); + expect(frame).toContain("read_file"); + expect(frame).toContain("src/child.ts"); + expect(transcriptBody).toContain("found the renderer path"); + expect(transcriptBody).not.toContain("unrelated agent result"); + }); + + it("collapses work block on done with failed tool surfaced alongside last ok", () => { + const turnId = "turn-done"; + const events: AgentChatEventEnvelope[] = [ + { + sessionId: "s1", + timestamp: "2026-01-01T12:00:00.000Z", + sequence: 1, + event: { type: "command", command: "lint check", cwd: "/repo", output: "", itemId: "c1", status: "completed", exitCode: 0, durationMs: 200, turnId }, + }, + { + sessionId: "s1", + timestamp: "2026-01-01T12:00:01.000Z", + sequence: 2, + event: { type: "command", command: "npm test", cwd: "/repo", output: "", itemId: "c2", status: "failed", exitCode: 1, durationMs: 800, turnId }, + }, + { + sessionId: "s1", + timestamp: "2026-01-01T12:00:02.000Z", + sequence: 3, + event: { type: "command", command: "echo ok", cwd: "/repo", output: "", itemId: "c3", status: "completed", exitCode: 0, durationMs: 50, turnId }, + }, + { + sessionId: "s1", + timestamp: "2026-01-01T12:00:03.000Z", + sequence: 4, + event: { type: "command", command: "echo two", cwd: "/repo", output: "", itemId: "c4", status: "completed", exitCode: 0, durationMs: 50, turnId }, + }, + { + sessionId: "s1", + timestamp: "2026-01-01T12:00:08.300Z", + sequence: 5, + event: { type: "done", turnId, status: "completed", usage: { inputTokens: 4000, outputTokens: 2200 }, costUsd: 0.31 }, + }, + ]; + const frame = renderEvents(events, { width: 100 }); + expect(frame).toMatch(/▸\s+4 tools/); + expect(frame).toMatch(/3 ok/); + expect(frame).toMatch(/1 failed/); + // Failed tool surfaced. + expect(frame).toContain("npm test"); + // Last successful tool also surfaced. + expect(frame).toContain("echo two"); + }); + + it("hides reasoning-only events from the quiet transcript", () => { + const turnId = "turn-think"; + const events: AgentChatEventEnvelope[] = [ + { sessionId: "s1", timestamp: "2026-01-01T12:00:00.000Z", sequence: 1, event: { type: "reasoning", text: "first thought", turnId } }, + { sessionId: "s1", timestamp: "2026-01-01T12:00:00.500Z", sequence: 2, event: { type: "reasoning", text: "second thought", turnId } }, + { sessionId: "s1", timestamp: "2026-01-01T12:00:01.000Z", sequence: 3, event: { type: "reasoning", text: "third thought", turnId } }, + ]; + const frame = renderEvents(events, { width: 80 }); + expect(frame).not.toMatch(/✦/); + expect(frame).not.toContain("first thought"); + expect(frame).not.toContain("second thought"); + expect(frame).not.toContain("third thought"); + }); + + it("suppresses done footers because token/runtime detail lives in the footer", () => { + const turnId = "turn-footer"; + const events: AgentChatEventEnvelope[] = [ + { sessionId: "s1", timestamp: "2026-01-01T12:00:00.000Z", sequence: 1, event: { type: "user_message", text: "hi", turnId } }, + { sessionId: "s1", timestamp: "2026-01-01T12:00:08.300Z", sequence: 2, event: { type: "done", turnId, status: "completed", usage: { inputTokens: 4000, outputTokens: 2200 }, costUsd: 0.31 } }, + ]; + const frame = renderEvents(events, { width: 80 }); + expect(frame).toContain("hi"); + expect(frame).not.toContain("8.3s"); + expect(frame).not.toContain("6.2k tok"); + expect(frame).not.toContain("$0.31"); + }); + + it("renders a markdown table with box-drawing borders", () => { + const text = [ + "| tool | duration | status |", + "|------|----------|--------|", + "| bash | 2.1s | running |", + "| edit | 120ms | ok |", + ].join("\n"); + const frame = renderEvents([ + { sessionId: "s1", timestamp: "2026-01-01T12:00:00.000Z", sequence: 1, event: { type: "text", text } }, + ], { width: 80 }); + expect(frame).toMatch(/┌.*┬.*┐/); + expect(frame).toMatch(/└.*┴.*┘/); + expect(frame).toMatch(/│/); + expect(frame).toContain("tool"); + expect(frame).toContain("duration"); + expect(frame).toContain("status"); + expect(frame).toContain("bash"); + expect(frame).toContain("120ms"); + }); + + it("exports visible transcript rows as plain text for copy", () => { + const text = renderChatTranscriptPlainText({ + events: [ + { sessionId: "s1", timestamp: "2026-01-01T12:00:00.000Z", sequence: 1, event: { type: "user_message", text: "copy me" } }, + { sessionId: "s1", timestamp: "2026-01-01T12:00:01.000Z", sequence: 2, event: { type: "text", text: "copied" } }, + ], + notices: [], + activeSession: session, + width: 64, + }); + + expect(text).toContain("copy me"); + expect(text).toContain("copied"); + expect(text).not.toContain("Codex"); + expect(text).not.toContain("gpt"); + }); + + it("omits scroll affordances from copied transcript text", () => { + const events = Array.from({ length: 8 }, (_, index): AgentChatEventEnvelope => ({ + sessionId: "s1", + timestamp: `2026-01-01T12:00:${String(index).padStart(2, "0")}.000Z`, + sequence: index + 1, + event: { type: "text", text: `copy row ${index + 1}` }, + })); + const text = renderChatTranscriptPlainText({ + events, + notices: [], + activeSession: session, + maxRows: 4, + width: 64, + }); + + expect(text).toContain("copy row 8"); + expect(text).not.toContain("older messages"); + expect(text).not.toContain("newer messages"); }); }); diff --git a/apps/ade-cli/src/tuiClient/__tests__/Drawer.test.tsx b/apps/ade-cli/src/tuiClient/__tests__/Drawer.test.tsx new file mode 100644 index 000000000..15c704e8c --- /dev/null +++ b/apps/ade-cli/src/tuiClient/__tests__/Drawer.test.tsx @@ -0,0 +1,271 @@ +import React from "react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { render } from "ink-testing-library"; +import type { AgentChatSessionSummary } from "../../../../desktop/src/shared/types/chat"; +import type { LaneSummary } from "../../../../desktop/src/shared/types/lanes"; +import { Drawer } from "../components/Drawer"; + +function stripAnsi(text: string): string { + return text.replace(/\u001b\[[0-?]*[ -/]*[@-~]/g, ""); +} + +function lane(id: string, name: string, branchRef: string, createdAt: string, ahead = 0, behind = 0, laneType: LaneSummary["laneType"] = "worktree"): LaneSummary { + return { + id, + name, + laneType, + baseRef: "main", + branchRef, + worktreePath: `/tmp/${id}`, + parentLaneId: null, + childCount: 0, + stackDepth: 0, + parentStatus: null, + isEditProtected: false, + status: { + dirty: ahead > 0 || behind > 0, + ahead, + behind, + remoteBehind: 0, + rebaseInProgress: false, + }, + color: null, + icon: null, + tags: [], + createdAt, + }; +} + +afterEach(() => { + vi.useRealTimers(); +}); + +describe("Drawer diff stats", () => { + it("renders per-lane diff stats from line stats, not ahead/behind commits", () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-05-12T12:00:00.000Z")); + + const frame = 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"); + }); +}); + +describe("Drawer lane and chat navigation layout", () => { + it("puts the primary lane first and removes old header/footer controls", () => { + const frame = stripAnsi(render( + , + ).lastFrame() ?? ""); + + expect(frame.indexOf("Primary")).toBeLessThan(frame.indexOf("Work lane")); + expect(frame).not.toContain("new / filter"); + expect(frame).not.toContain(" run "); + expect(frame).not.toContain(" wait "); + expect(frame).not.toContain("↑↓ view"); + expect(frame).not.toContain("↵ open"); + expect(frame).toContain("+ new lane"); + }); + + it("shows an indented chat group with new chat at the bottom only in chat mode", () => { + const sessions: AgentChatSessionSummary[] = [ + { + sessionId: "chat-1", + laneId: "lane-1", + provider: "codex", + model: "gpt-5.5", + title: "First chat", + status: "idle", + startedAt: "2026-05-12T11:30:00.000Z", + endedAt: null, + lastActivityAt: "2026-05-12T11:31:00.000Z", + lastOutputPreview: null, + summary: null, + }, + ]; + + const baseProps = { + lanes: [lane("lane-1", "Feature", "feature/chat-nav", "2026-05-12T11:55:00.000Z")], + sessions, + activeLaneId: "lane-1", + activeSessionId: "chat-1", + browsingLaneId: "lane-1", + selectedLaneIndex: 0, + panelHeight: 30, + }; + + const laneModeFrame = stripAnsi(render( + , + ).lastFrame() ?? ""); + const chatModeFrame = stripAnsi(render( + , + ).lastFrame() ?? ""); + + expect(laneModeFrame).not.toContain("First chat"); + expect(chatModeFrame).toContain("CHATS · 1"); + expect(chatModeFrame.indexOf("First chat")).toBeLessThan(chatModeFrame.indexOf("+ new chat")); + expect(chatModeFrame).not.toContain("^N"); + expect(chatModeFrame).toContain("Esc back to lanes"); + }); + + it("does not offer a new chat action for a missing lane worktree", () => { + const frame = stripAnsi(render( + , + ).lastFrame() ?? ""); + + expect(frame).toContain("miss"); + expect(frame).toContain("CHATS · unavailable"); + expect(frame).toContain("worktree missing"); + expect(frame).toContain("lane unavailable"); + expect(frame).not.toContain("+ new chat"); + }); +}); + +describe("Drawer PR pill", () => { + it("renders open PR number and check counts inline", () => { + const frame = stripAnsi(render( + , + ).lastFrame() ?? ""); + + expect(frame).toContain("[#168 ·4/6]"); + }); + + it("does not render closed or merged PR pills", () => { + for (const state of ["closed", "merged"] as const) { + const frame = stripAnsi(render( + , + ).lastFrame() ?? ""); + + expect(frame).not.toContain("[#168"); + } + }); + + it("skips the pill in mini drawer density", () => { + const frame = stripAnsi(render( + , + ).lastFrame() ?? ""); + + expect(frame).not.toContain("[#168"); + }); +}); + +describe("Drawer active chat indicator", () => { + it("uses a spinning frame for active chats", () => { + const activeSession: AgentChatSessionSummary = { + sessionId: "chat-1", + laneId: "lane-1", + provider: "codex", + model: "gpt-5.5", + title: "Implement polish", + status: "active", + startedAt: "2026-05-12T12:00:00.000Z", + endedAt: null, + lastActivityAt: "2026-05-12T12:00:00.000Z", + lastOutputPreview: null, + summary: null, + }; + + const frame = stripAnsi(render( + , + ).lastFrame() ?? ""); + + expect(frame).toMatch(/[◐◓◑◒] now/); + }); +}); diff --git a/apps/ade-cli/src/tuiClient/__tests__/HeaderFooter.test.tsx b/apps/ade-cli/src/tuiClient/__tests__/HeaderFooter.test.tsx new file mode 100644 index 000000000..3abc5ad00 --- /dev/null +++ b/apps/ade-cli/src/tuiClient/__tests__/HeaderFooter.test.tsx @@ -0,0 +1,220 @@ +import React from "react"; +import { describe, expect, it } from "vitest"; +import { render } from "ink-testing-library"; +import { Header } from "../components/Header"; +import { FooterControls } from "../components/FooterControls"; +import type { LaneSummary } from "../../../../desktop/src/shared/types/lanes"; + +function stripAnsi(text: string): string { + return text.replace(/\u001b\[[0-?]*[ -/]*[@-~]/g, ""); +} + +function lane(overrides: Partial = {}): LaneSummary { + return { + id: "lane-1", + name: "TUI polish", + laneType: "worktree", + baseRef: "main", + branchRef: "feature/tui-polish", + worktreePath: "/tmp/lane", + 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-12T12:00:00.000Z", + ...overrides, + }; +} + +describe("Header", () => { + it("does not duplicate ADE when the project itself is ADE", () => { + const result = render(
); + const frame = stripAnsi(result.lastFrame() ?? ""); + + expect(frame.match(/ADE/g)).toHaveLength(1); + }); + + it("shows concise lane and branch context without model details", () => { + const result = render(
); + const frame = stripAnsi(result.lastFrame() ?? ""); + + expect(frame).toContain("lane"); + expect(frame).toContain("TUI polish"); + expect(frame).toContain("branch"); + expect(frame).toContain("feature/tui-polish"); + expect(frame).toContain("chat"); + expect(frame).toContain("Design pass"); + expect(frame).not.toContain("GPT"); + }); + + it("suppresses the project label when it repeats the branch basename", () => { + const result = render( +
, + ); + const frame = stripAnsi(result.lastFrame() ?? ""); + + expect(frame).toContain("chat Review"); + expect(frame).toContain("branch ade/feature-a"); + expect(frame.match(/feature-a/g)).toHaveLength(1); + }); +}); + +describe("FooterControls", () => { + it("labels the right pane as info and keeps fast with model mode info", () => { + const result = render( + , + ); + const frame = stripAnsi(result.lastFrame() ?? ""); + + expect(frame).toContain("info"); + expect(frame).toContain("GPT-5.5"); + expect(frame).toContain("fast"); + expect(frame).toContain("full-auto"); + expect(frame).not.toContain("setup"); + }); + + it("shows chat scroll controls when the transcript is focused", () => { + const result = render( + , + ); + const frame = stripAnsi(result.lastFrame() ?? ""); + + expect(frame).toContain("scroll"); + expect(frame).toContain("Tab lanes"); + expect(frame).not.toContain("↑↓ lanes"); + }); + + it("shows the clamped chat scroll position in the footer", () => { + const result = render( + , + ); + const frame = stripAnsi(result.lastFrame() ?? ""); + + expect(frame).toContain("42/42"); + }); + + it("surfaces staged steer state in the footer", () => { + const result = render( + , + ); + const frame = stripAnsi(result.lastFrame() ?? ""); + + expect(frame).toContain("staged 2"); + expect(frame).toContain("/steer"); + }); + + it("shows an agents pane toggle when the active chat has subagent history", () => { + const result = render( + , + ); + const frame = stripAnsi(result.lastFrame() ?? ""); + + expect(frame).toContain("agents"); + expect(frame).toContain("^a"); + expect(frame.indexOf("agents")).toBeLessThan(frame.indexOf("lanes")); + expect(frame.indexOf("lanes")).toBeLessThan(frame.indexOf("info")); + expect(frame).not.toContain("● 2 agents"); + }); + + it("shows lane navigation controls when the drawer lane list is focused", () => { + const result = render( + , + ); + const frame = stripAnsi(result.lastFrame() ?? ""); + + expect(frame).toContain("↑↓ lanes"); + expect(frame).toContain("↵ open"); + expect(frame).not.toContain("scroll"); + }); + + it("shows chat list controls when drawer chats are focused", () => { + const result = render( + , + ); + const frame = stripAnsi(result.lastFrame() ?? ""); + + expect(frame).toContain("↑↓ chats"); + expect(frame).toContain("Esc lanes"); + expect(frame).not.toContain("scroll"); + }); +}); diff --git a/apps/ade-cli/src/tuiClient/__tests__/Palettes.test.tsx b/apps/ade-cli/src/tuiClient/__tests__/Palettes.test.tsx new file mode 100644 index 000000000..dea8d1b0f --- /dev/null +++ b/apps/ade-cli/src/tuiClient/__tests__/Palettes.test.tsx @@ -0,0 +1,106 @@ +import React from "react"; +import { describe, expect, it } from "vitest"; +import { render } from "ink-testing-library"; +import { MentionPalette } from "../components/MentionPalette"; +import { SlashPalette } from "../components/SlashPalette"; +import type { MentionSuggestion } from "../types"; + +function stripAnsi(text: string): string { + return text.replace(/\[[0-?]*[ -/]*[@-~]/g, ""); +} + +const mentionSuggestions: MentionSuggestion[] = [ + { + kind: "lane", + label: "TUI picker polish", + insertText: "@lane:lane-123", + detail: "feature/tui-picker-polish", + }, + { + kind: "chat", + label: "Current implementation thread", + insertText: "@chat:chat-123", + detail: "lane-123", + }, + { + kind: "file", + label: "apps/ade-cli/src/tuiClient/components/MentionPalette.tsx", + insertText: + "@file:apps/ade-cli/src/tuiClient/components/MentionPalette.tsx", + detail: "file", + filePath: "apps/ade-cli/src/tuiClient/components/MentionPalette.tsx", + }, + { + kind: "commit", + label: "Refine TUI command palettes", + insertText: "@commit:abc1234", + detail: "abc1234", + }, + { + kind: "pr", + label: "Desktop parity for TUI pickers", + insertText: "@pr:42", + detail: "#42", + }, + { + kind: "file", + label: "pasted-screenshot.png", + insertText: "@pasted-screenshot.png", + detail: "/tmp/pasted-screenshot.png", + filePath: "/tmp/pasted-screenshot.png", + attachment: true, + }, +]; + +describe("MentionPalette", () => { + it("renders the desktop-style mention surface for mixed reference kinds", () => { + const frame = stripAnsi( + render( + , + ).lastFrame() ?? "", + ); + + expect(frame).toContain("References"); + expect(frame).toContain("@auth"); + expect(frame).toContain("6 matches"); + expect(frame).toContain("Lane"); + expect(frame).toContain("Chat"); + expect(frame).toContain("File"); + expect(frame).toContain("MentionPalette.tsx"); + expect(frame).toContain("Adds file context"); + expect(frame).toContain("Tab insert"); + expect(frame).not.toContain("Type"); + }); +}); + +describe("SlashPalette", () => { + it("uses the available center width for long slash command names", () => { + const frame = stripAnsi(render( + , + ).lastFrame() ?? ""); + + expect(frame).toContain("/wide-subscription-localization"); + expect(frame).toContain("Commands"); + expect(frame).toContain("1 match"); + expect(frame).toContain("Bulk-localize subscription"); + expect(frame).toContain("Tab insert"); + expect(frame).toContain("Enter run"); + expect(frame).not.toContain("Group"); + expect(frame.split("\n")[0]?.length).toBeGreaterThanOrEqual(90); + }); +}); diff --git a/apps/ade-cli/src/tuiClient/__tests__/RightPane.test.tsx b/apps/ade-cli/src/tuiClient/__tests__/RightPane.test.tsx index 5b984ffb4..7b08a15c8 100644 --- a/apps/ade-cli/src/tuiClient/__tests__/RightPane.test.tsx +++ b/apps/ade-cli/src/tuiClient/__tests__/RightPane.test.tsx @@ -2,6 +2,7 @@ import React from "react"; import { describe, expect, it } from "vitest"; import { render } from "ink-testing-library"; import { RightPane } from "../components/RightPane"; +import type { LaneSummary } from "../../../../desktop/src/shared/types/lanes"; import type { ProviderReadinessRow, RightPaneContent, SetupPaneRow } from "../types"; const setupRows: SetupPaneRow[] = [ @@ -22,6 +23,10 @@ const providerRows: ProviderReadinessRow[] = [ { provider: "opencode", label: "OpenCode", status: "ready", detail: "user-installed · 0 shared runtime", modelCount: 4442 }, ]; +function stripAnsi(text: string): string { + return text.replace(/\u001b\[[0-?]*[ -/]*[@-~]/g, ""); +} + function content(overrides: Partial> = {}): RightPaneContent { return { kind: "model-setup", @@ -42,74 +47,357 @@ function renderModelSetup(selectedIndex: number, overrides: Partial { - it("renders MODEL and PROVIDERS section headers", () => { + it("renders PROVIDER tab strip + MODEL section header", () => { const frame = renderModelSetup(0); + expect(frame).toContain("PROVIDER"); expect(frame).toContain("MODEL"); - expect(frame).toContain("PROVIDERS"); }); - it("shows ‹ › chevron on cyclable setup rows and ↵ on action rows", () => { - const frame = renderModelSetup(0); - expect(frame).toContain("‹ ›"); - expect(frame).toContain("↵"); + it("renders all five providers as compact brand chips", () => { + const frame = stripAnsi(renderModelSetup(0)); + expect(frame).toContain("[● Codex]"); + expect(frame).toContain("[● Claude]"); + expect(frame).toContain("[● Cursor]"); + expect(frame).toContain("[● Droid]"); + expect(frame).toContain("[● OpenCode]"); + expect(frame).not.toContain("◇ Codex"); + expect(frame).not.toContain("◆ Claude"); }); - it("renders all five providers with their brand glyphs", () => { + it("renders the STATUS readiness section for the active provider", () => { const frame = renderModelSetup(0); - expect(frame).toContain("◇ Codex"); - expect(frame).toContain("◆ Claude"); - expect(frame).toContain("▲ Cursor"); - expect(frame).toContain("▣ Droid"); - expect(frame).toContain("◈ OpenCode"); + expect(frame).toContain("STATUS · CODEX"); }); - it("collapses provider detail when no provider row is selected", () => { - const frame = renderModelSetup(0); - expect(frame).not.toContain("4 models"); - expect(frame).not.toContain("/usr/local/bin/claude"); + it("uses the compact target title and active-first provider order in wide mode", () => { + const result = render( + , + ); + const frame = stripAnsi(result.lastFrame() ?? ""); + + expect(frame).toContain("SETUP · MODEL"); + expect(frame.indexOf("Claude")).toBeLessThan(frame.indexOf("Codex")); }); - it("expands provider detail when its row is selected", () => { - const claudeIndex = setupRows.length + 1; - const frame = renderModelSetup(claudeIndex); - expect(frame).toContain("4 models"); - expect(frame).toContain("/usr/local/bin/claude"); + it("renders output style as a model setup row when provided", () => { + const result = render( + , + ); + const frame = stripAnsi(result.lastFrame() ?? ""); + + expect(frame).toContain("Output style"); + expect(frame).toContain("default · concise · verbose"); }); - it("renders the footer with checked time and key hints", () => { + it("renders provider tab readiness legend", () => { const frame = renderModelSetup(0); - expect(frame).toContain("19:57:09"); - expect(frame).toContain("↑↓"); - expect(frame).toContain("←→"); - expect(frame).toContain("enter"); + expect(frame).toContain("ready"); + expect(frame).toContain("active"); + expect(frame).toContain("needs login"); }); - it("marks the active provider in the providers list", () => { + it("renders the footer hint row", () => { const frame = renderModelSetup(0); - expect(frame).toMatch(/◇ Codex.*active/); + expect(frame).toContain("provider"); + expect(frame).toContain("apply"); + expect(frame).toContain("login"); }); }); describe("RightPane subagents", () => { - it("renders subagent tabs and active rows", () => { - const frame = render( + it("renders an agents process table with main, subagents, and teammates", () => { + const result = render( , - ).lastFrame() ?? ""; + ); + const frame = stripAnsi(result.lastFrame() ?? ""); + + expect(frame).toContain("AGENTS · CLAUDE"); + expect(frame).toContain("Subagents · 1"); + expect(frame).toContain("Teammates · 1"); + expect(frame).toContain("Background · 0"); + expect(frame).toContain("main"); + expect(frame).toContain("research"); + expect(frame).toContain("TEAMMATES"); + expect(frame).toContain("mate-x"); + expect(frame).toContain("transcript follows"); + }); + + it("renders a single tab + placeholder for Droid (no subagents in ACP)", () => { + const result = render( + , + ); + const frame = result.lastFrame() ?? ""; + + expect(frame).toContain("AGENTS · DROID"); + expect(frame).toContain("agentclientprotocol.com"); + // Droid does not show the Teammates tab. + expect(frame).not.toContain("Teammates"); + }); + + it("renders only the Subagents tab for Codex/Cursor/OpenCode", () => { + const result = render( + , + ); + const frame = stripAnsi(result.lastFrame() ?? ""); + + expect(frame).toContain("AGENTS · CODEX"); + expect(frame).toContain("Subagents · 1"); + expect(frame).toContain("Teammates · 0"); + expect(frame).toContain("delegated"); + }); + + it("uses a spinning frame for running subagents at or below the cap", () => { + const result = render( + , + ); + const frame = stripAnsi(result.lastFrame() ?? ""); + + expect(frame).toMatch(/[◐◓◑◒] 01/); + }); + + it("falls back to the static running glyph when more than twelve subagents are running", () => { + const snapshots = Array.from({ length: 13 }, (_, index) => ({ + id: `x${index + 1}`, + name: `agent-${index + 1}`, + kind: "subagent" as const, + status: "running" as const, + summary: "", + })); + const result = render( + , + ); + const frame = stripAnsi(result.lastFrame() ?? ""); + + expect(frame).toContain("● 01"); + expect(frame).not.toMatch(/[◐◓◑◒] 01/); + }); +}); + +function lane(overrides: Partial = {}): LaneSummary { + return { + id: "lane-1", + name: "Diff lane", + laneType: "worktree", + baseRef: "main", + branchRef: "feature/diff-lane", + worktreePath: "/tmp/lane", + parentLaneId: null, + childCount: 0, + stackDepth: 0, + parentStatus: null, + isEditProtected: false, + status: { + dirty: true, + ahead: 1, + behind: 0, + remoteBehind: 0, + rebaseInProgress: false, + }, + color: null, + icon: null, + tags: [], + createdAt: "2026-05-12T12:00:00.000Z", + ...overrides, + }; +} + +describe("RightPane lane-details", () => { + it("renders lane identity and branch as a focused lane detail header", () => { + const result = render( + , + ); + const frame = stripAnsi(result.lastFrame() ?? ""); + + expect(frame).toContain("DIFF LANE · FOCUSED"); + expect(frame).toContain("Diff lane"); + expect(frame).toContain("⎇ feature/diff-lane"); + expect(frame).not.toContain("name"); + expect(frame).not.toContain("branch"); + expect(frame).not.toContain("tracking"); + expect(frame).not.toContain("model"); + }); + + it("renders real line diff stats with a U+2212 minus sign", () => { + const result = render( + , + ); + const frame = stripAnsi(result.lastFrame() ?? ""); + + expect(frame).toContain("diff"); + expect(frame).toContain("+12"); + expect(frame).toContain("−4"); + expect(frame).toContain("CHANGES · 3"); + expect(frame).not.toContain("-4"); + expect(frame).toContain("2 staged"); + expect(frame).toContain("1 unstaged"); + expect(frame).not.toContain("tracking"); + }); + + it("renders a compact changes header when line additions and deletions are zero", () => { + const result = render( + , + ); + const frame = stripAnsi(result.lastFrame() ?? ""); + + expect(frame).toContain("CHANGES · 2"); + expect(frame).toContain("No changed files."); + expect(frame).not.toContain("— · 2 files"); + }); + + it("replaces runnable actions with an unavailable message when the worktree is missing", () => { + const result = render( + , + ); + const frame = stripAnsi(result.lastFrame() ?? ""); - expect(frame).toContain("[Subagents]"); - expect(frame).toContain("Teammates"); - expect(frame).toContain("Background"); - expect(frame).toContain("research-explorer"); - expect(frame).not.toContain("reviewer"); + expect(frame).toContain("worktree missing"); + expect(frame).toContain("UNAVAILABLE"); + expect(frame).toContain("Restore this lane worktree"); + expect(frame).not.toContain("stage all"); + expect(frame).not.toContain("commit"); + expect(frame).not.toContain("push"); }); }); diff --git a/apps/ade-cli/src/tuiClient/__tests__/adeApi.test.ts b/apps/ade-cli/src/tuiClient/__tests__/adeApi.test.ts index 9d64d1dad..4d7f6e492 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 { createChatSession, DEFAULT_CODEX_REASONING_EFFORT, discoverProjectSlashCommands, latestGoal, latestTokenStats, sendChatMessage } from "../adeApi"; +import { cancelSteerMessage, createChatSession, DEFAULT_CODEX_REASONING_EFFORT, dispatchSteerMessage, discoverProjectSlashCommands, editSteerMessage, latestGoal, latestTokenStats, listLaneDiffStats, listPrsByLane, sendChatMessage, steerChatMessage } from "../adeApi"; import type { AdeCodeConnection } from "../types"; const tmpPaths: string[] = []; @@ -33,6 +33,29 @@ function envelope( }; } +describe("listLaneDiffStats", () => { + it("calls the bulk diff stats ADE action with lane ids", async () => { + const calls: Array<{ domain: string; action: string; args: Record | undefined }> = []; + const connection = { + action: async (domain: string, action: string, args?: Record) => { + calls.push({ domain, action, args }); + return { "lane-1": { additions: 12, deletions: 4, files: 3 } }; + }, + } as unknown as AdeCodeConnection; + + const result = await listLaneDiffStats(connection, ["lane-1"]); + + expect(calls).toEqual([ + { + domain: "diff", + action: "listLaneDiffStats", + args: { laneIds: ["lane-1"] }, + }, + ]); + expect(result["lane-1"]).toEqual({ additions: 12, deletions: 4, files: 3 }); + }); +}); + describe("latestTokenStats", () => { it("tracks streaming state, context percentage, token counts, and cost", () => { const events = [ @@ -290,8 +313,40 @@ describe("createChatSession", () => { }); }); +describe("listPrsByLane", () => { + it("passes through the bulk PR lane action", async () => { + const calls: Array<{ domain: string; action: string; args?: Record }> = []; + const connection = { + action: async (domain: string, action: string, args?: Record) => { + calls.push({ domain, action, args }); + return [ + { + laneId: "lane-1", + number: 168, + state: "open", + checksPassed: 4, + checksTotal: 6, + }, + ]; + }, + } as unknown as AdeCodeConnection; + + await expect(listPrsByLane(connection)).resolves.toEqual([ + { + laneId: "lane-1", + number: 168, + state: "open", + checksPassed: 4, + checksTotal: 6, + }, + ]); + + expect(calls).toEqual([{ domain: "pr", action: "listPrsByLane", args: {} }]); + }); +}); + describe("sendChatMessage", () => { - it("waits until the runtime has accepted the turn", async () => { + it("forwards chat text unchanged and waits until the shared runtime has accepted the turn", async () => { const calls: Array<{ domain: string; action: string; argsList: unknown[] }> = []; const connection = { actionList: async (domain: string, action: string, argsList: unknown[]) => { @@ -311,5 +366,33 @@ describe("sendChatMessage", () => { ], }, ]); + expect(JSON.stringify(calls)).not.toContain("only normal reason to skip ADE CLI"); + expect(JSON.stringify(calls)).not.toContain("ade actions list --text"); + }); +}); + +describe("steer helpers", () => { + it("routes steer, edit, cancel, and dispatch actions through the shared chat domain", async () => { + const calls: Array<{ domain: string; action: string; args: Record }> = []; + const connection = { + action: async (domain: string, action: string, args: Record) => { + calls.push({ domain, action, args }); + if (action === "steer") return { steerId: "steer-1", queued: true }; + if (action === "dispatchSteer") return { dispatchedAt: 123 }; + return undefined; + }, + } as unknown as AdeCodeConnection; + + await expect(steerChatMessage(connection, "chat-1", "while busy")).resolves.toEqual({ steerId: "steer-1", queued: true }); + await editSteerMessage(connection, "chat-1", "steer-1", "updated"); + await cancelSteerMessage(connection, "chat-1", "steer-1"); + await expect(dispatchSteerMessage(connection, "chat-1", "steer-1", "inline")).resolves.toEqual({ dispatchedAt: 123 }); + + expect(calls).toEqual([ + { domain: "chat", action: "steer", args: { sessionId: "chat-1", text: "while busy" } }, + { domain: "chat", action: "editSteer", args: { sessionId: "chat-1", steerId: "steer-1", text: "updated" } }, + { domain: "chat", action: "cancelSteer", args: { sessionId: "chat-1", steerId: "steer-1" } }, + { domain: "chat", action: "dispatchSteer", args: { sessionId: "chat-1", steerId: "steer-1", mode: "inline" } }, + ]); }); }); diff --git a/apps/ade-cli/src/tuiClient/__tests__/appInput.test.ts b/apps/ade-cli/src/tuiClient/__tests__/appInput.test.ts new file mode 100644 index 000000000..e2abe7e32 --- /dev/null +++ b/apps/ade-cli/src/tuiClient/__tests__/appInput.test.ts @@ -0,0 +1,148 @@ +import { describe, expect, it } from "vitest"; +import { + clampChatScrollOffsetRows, + deletePreviousPromptWord, + footerControlsForAvailability, + isPromptWordBackspace, + parseTerminalMouseInput, + subagentSnapshotsFromEvents, +} from "../app"; + +describe("parseTerminalMouseInput", () => { + it("parses SGR mouse wheel events from Ink input", () => { + expect(parseTerminalMouseInput("\x1b[<64;42;12M")).toEqual({ + kind: "wheel", + direction: "up", + x: 42, + y: 12, + }); + expect(parseTerminalMouseInput("[<64;42;12M")).toEqual({ + kind: "wheel", + direction: "up", + x: 42, + y: 12, + }); + expect(parseTerminalMouseInput("[<65;42;12M")).toEqual({ + kind: "wheel", + direction: "down", + x: 42, + y: 12, + }); + }); + + it("parses rxvt mouse wheel events from terminals that do not emit SGR", () => { + expect(parseTerminalMouseInput("[64;42;12M")).toEqual({ + kind: "wheel", + direction: "up", + x: 42, + y: 12, + }); + }); + + it("parses X10 mouse wheel events from legacy terminal packets", () => { + expect(parseTerminalMouseInput("\x1b[M`J,")).toEqual({ + kind: "wheel", + direction: "up", + x: 42, + y: 12, + }); + }); + + it("parses primary clicks so panes can opt into mouse selection", () => { + expect(parseTerminalMouseInput("[<0;5;6M")).toEqual({ + kind: "click", + x: 5, + y: 6, + }); + }); + + it("swallows batched SGR mouse events from fast scrolling", () => { + expect(parseTerminalMouseInput("[<64;104;32M[<64;104;32M[<65;104;31M")).toEqual({ + kind: "wheel", + direction: "up", + x: 104, + y: 32, + }); + }); + + it("ignores normal keyboard input", () => { + expect(parseTerminalMouseInput("hello")).toBeNull(); + }); +}); + +describe("footer control ordering", () => { + it("puts agents first only when the active chat has subagent history", () => { + expect(footerControlsForAvailability(true)).toEqual(["agents", "drawer", "details"]); + expect(footerControlsForAvailability(false)).toEqual(["drawer", "details"]); + }); +}); + +describe("subagentSnapshotsFromEvents", () => { + it("uses agentId as the stable row id when runtimes provide both ids", () => { + const snapshots = subagentSnapshotsFromEvents([ + { + sessionId: "s1", + timestamp: "2026-01-01T12:00:00.000Z", + sequence: 1, + event: { + type: "subagent_started", + taskId: "task-1", + agentId: "agent-1", + parentToolUseId: null, + description: "Investigate issue", + }, + }, + { + sessionId: "s1", + timestamp: "2026-01-01T12:00:01.000Z", + sequence: 2, + event: { + type: "subagent_result", + taskId: "task-1", + agentId: "agent-1", + parentToolUseId: null, + status: "completed", + summary: "done", + }, + }, + ]); + + expect(snapshots).toHaveLength(1); + expect(snapshots[0]).toMatchObject({ + id: "agent-1", + name: "Investigate issue", + status: "completed", + summary: "done", + }); + }); +}); + +describe("clampChatScrollOffsetRows", () => { + it("clamps overscroll immediately so downward input can recover from the oldest rows", () => { + const top = clampChatScrollOffsetRows(Number.MAX_SAFE_INTEGER, 12); + expect(top).toBe(12); + expect(clampChatScrollOffsetRows(top - 3, 12)).toBe(9); + }); + + it("clamps negative and invalid offsets to the bottom", () => { + expect(clampChatScrollOffsetRows(-5, 12)).toBe(0); + expect(clampChatScrollOffsetRows(Number.NaN, 12)).toBe(0); + }); +}); + +describe("prompt editing helpers", () => { + it("deletes the previous whitespace-delimited word", () => { + expect(deletePreviousPromptWord("hello world")).toBe("hello "); + expect(deletePreviousPromptWord("hello world ")).toBe("hello "); + expect(deletePreviousPromptWord("single")).toBe(""); + expect(deletePreviousPromptWord("")).toBe(""); + }); + + it("recognizes common word-backspace key encodings", () => { + expect(isPromptWordBackspace("w", { ctrl: true })).toBe(true); + expect(isPromptWordBackspace("", { ctrl: true, backspace: true })).toBe(true); + expect(isPromptWordBackspace("", { meta: true, backspace: true })).toBe(true); + expect(isPromptWordBackspace("\x1b\u007f", { meta: true })).toBe(true); + expect(isPromptWordBackspace("x", {})).toBe(false); + }); +}); diff --git a/apps/ade-cli/src/tuiClient/__tests__/commands.test.ts b/apps/ade-cli/src/tuiClient/__tests__/commands.test.ts index c0c92e0b4..1a5f4753a 100644 --- a/apps/ade-cli/src/tuiClient/__tests__/commands.test.ts +++ b/apps/ade-cli/src/tuiClient/__tests__/commands.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vitest"; import { commandPlacement, parseCommand, paletteCommands } from "../commands"; +import { buildLinearToolRequest, parseLinearArgs } from "../linearCommands"; describe("commands", () => { it("parses multi-word ADE commands before generic slash commands", () => { @@ -226,3 +227,45 @@ describe("commands", () => { expect(rows.find((row) => row.name === "/resume" && row.source === "ade")).toBeUndefined(); }); }); + +describe("linear command routing", () => { + it("parses flags and quoted values", () => { + expect(parseLinearArgs("run cancel run-1 --reason \"not ready\" --launch false")).toEqual({ + positionals: ["run", "cancel", "run-1"], + options: { reason: "not ready", launch: false }, + }); + }); + + it("routes sync dashboard and queue resolution", () => { + expect(buildLinearToolRequest("sync dashboard")).toEqual({ + kind: "tool", + title: "Linear sync dashboard", + toolName: "getLinearSyncDashboard", + args: {}, + }); + expect(buildLinearToolRequest("sync resolve queue-1 approve --note ok")).toEqual({ + kind: "tool", + title: "Linear sync resolve", + toolName: "resolveLinearSyncQueueItem", + args: { + queueItemId: "queue-1", + action: "approve", + note: "ok", + }, + }); + }); + + it("routes worker handoff and reports usage for missing fields", () => { + expect(buildLinearToolRequest("route worker LIN-123 agent-1")).toEqual({ + kind: "tool", + title: "Linear route worker", + toolName: "routeLinearIssueToWorker", + args: { issueId: "LIN-123", agentId: "agent-1" }, + }); + expect(buildLinearToolRequest("run cancel run-1")).toEqual({ + kind: "usage", + title: "Linear run cancel", + body: "Usage: /linear run cancel --reason ", + }); + }); +}); diff --git a/apps/ade-cli/src/tuiClient/__tests__/connection.test.ts b/apps/ade-cli/src/tuiClient/__tests__/connection.test.ts index 47c27a32d..bcdbfb181 100644 --- a/apps/ade-cli/src/tuiClient/__tests__/connection.test.ts +++ b/apps/ade-cli/src/tuiClient/__tests__/connection.test.ts @@ -5,6 +5,14 @@ import path from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { connectToAde } from "../connection"; import { JsonRpcClient } from "../jsonRpcClient"; +import { startTuiHeartbeat, type TuiHeartbeat } from "../heartbeat"; +import { + appendReservedTuiEvent, + reserveTuiEventDedupKey, + syncTuiEventDedupKeys, + tuiEventDedupKey, +} from "../eventDedup"; +import type { AgentChatEventEnvelope } from "../../../../desktop/src/shared/types/chat"; import type { ProjectLaunchContext } from "../types"; const childProcess = vi.hoisted(() => { @@ -265,3 +273,400 @@ describe("connectToAde embedded mode", () => { ]); }); }); + +const heartbeats: TuiHeartbeat[] = []; + +function tempProjectRoot(): string { + return fs.mkdtempSync(path.join(os.tmpdir(), "ade-code-heartbeat-")); +} + +function heartbeatFile(projectRoot: string): string { + return path.join(projectRoot, ".ade", "cache", "ade-code", "clients", `${process.pid}.json`); +} + +describe("startTuiHeartbeat", () => { + afterEach(() => { + for (const heartbeat of heartbeats.splice(0)) { + heartbeat.stop(); + } + }); + + it("shares process cleanup handlers across active heartbeats", () => { + const exitListeners = process.listenerCount("exit"); + const sigintListeners = process.listenerCount("SIGINT"); + const firstRoot = tempProjectRoot(); + const secondRoot = tempProjectRoot(); + + const first = startTuiHeartbeat(firstRoot); + heartbeats.push(first); + const second = startTuiHeartbeat(secondRoot); + heartbeats.push(second); + + expect(process.listenerCount("exit")).toBe(exitListeners + 1); + expect(process.listenerCount("SIGINT")).toBe(sigintListeners + 1); + expect(fs.existsSync(heartbeatFile(firstRoot))).toBe(true); + expect(fs.existsSync(heartbeatFile(secondRoot))).toBe(true); + + first.stop(); + expect(process.listenerCount("exit")).toBe(exitListeners + 1); + expect(process.listenerCount("SIGINT")).toBe(sigintListeners + 1); + expect(fs.existsSync(heartbeatFile(firstRoot))).toBe(false); + expect(fs.existsSync(heartbeatFile(secondRoot))).toBe(true); + + second.stop(); + expect(process.listenerCount("exit")).toBe(exitListeners); + expect(process.listenerCount("SIGINT")).toBe(sigintListeners); + expect(fs.existsSync(heartbeatFile(secondRoot))).toBe(false); + }); + + it("makes stop idempotent", () => { + const exitListeners = process.listenerCount("exit"); + const projectRoot = tempProjectRoot(); + const heartbeat = startTuiHeartbeat(projectRoot); + heartbeats.push(heartbeat); + + heartbeat.stop(); + heartbeat.stop(); + + expect(process.listenerCount("exit")).toBe(exitListeners); + expect(fs.existsSync(heartbeatFile(projectRoot))).toBe(false); + }); +}); + +function listenRpc(server: net.Server, socketPath: string): Promise { + return new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(socketPath, () => { + server.off("error", reject); + resolve(); + }); + }); +} + +function closeServer(server: net.Server): Promise { + return new Promise((resolve) => server.close(() => resolve())); +} + +describe("JsonRpcClient", () => { + it("handles framed notifications before JSONL responses", async () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "ade-code-jsonrpc-")); + const socketPath = path.join(tmpDir, "rpc.sock"); + let resolveServerSocket: (socket: net.Socket) => void = () => {}; + const serverSocketReady = new Promise((resolve) => { + resolveServerSocket = resolve; + }); + const server = net.createServer((socket) => { + resolveServerSocket(socket); + socket.on("data", (chunk) => { + const text = String(chunk); + const match = /"id":(\d+)/.exec(text); + const id = match ? Number.parseInt(match[1]!, 10) : 1; + socket.write(`${JSON.stringify({ jsonrpc: "2.0", id, result: { ok: true } })}\n`); + }); + }); + + await listenRpc(server, socketPath); + const client = await JsonRpcClient.connect(socketPath); + const socket = await serverSocketReady; + try { + const notification = new Promise((resolve) => { + client.onNotification("chat/event", resolve); + }); + const payload = JSON.stringify({ + jsonrpc: "2.0", + method: "chat/event", + params: { sessionId: "s1" }, + }); + socket.write(`Content-Length: ${Buffer.byteLength(payload, "utf8")}\r\n\r\n${payload}`); + + await expect(notification).resolves.toEqual({ sessionId: "s1" }); + await expect(client.request("ping")).resolves.toEqual({ ok: true }); + } finally { + client.close(); + socket.destroy(); + await closeServer(server); + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }); + + it("honors byte-based Content-Length framing for unicode payloads", async () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "ade-code-jsonrpc-")); + const socketPath = path.join(tmpDir, "rpc.sock"); + let resolveServerSocket: (socket: net.Socket) => void = () => {}; + const serverSocketReady = new Promise((resolve) => { + resolveServerSocket = resolve; + }); + const server = net.createServer((socket) => { + resolveServerSocket(socket); + }); + + await listenRpc(server, socketPath); + const client = await JsonRpcClient.connect(socketPath); + const socket = await serverSocketReady; + try { + const notification = new Promise((resolve) => { + client.onNotification("chat/event", resolve); + }); + const payload = JSON.stringify({ + jsonrpc: "2.0", + method: "chat/event", + params: { message: "héllo ✅" }, + }); + const framed = Buffer.concat([ + Buffer.from(`Content-Length: ${Buffer.byteLength(payload, "utf8")}\r\n\r\n`, "ascii"), + Buffer.from(payload, "utf8"), + ]); + socket.write(framed.subarray(0, 20)); + socket.write(framed.subarray(20)); + + await expect(notification).resolves.toEqual({ message: "héllo ✅" }); + } finally { + client.close(); + socket.destroy(); + await closeServer(server); + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }); +}); + +async function loadStateModule(home: string): Promise { + vi.resetModules(); + vi.spyOn(os, "homedir").mockReturnValue(home); + return await import("../state"); +} + +describe("ade-code TUI state", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("loads legacy state without a last lane id", async () => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), "ade-tui-state-")); + fs.mkdirSync(path.join(home, ".ade"), { recursive: true }); + fs.writeFileSync( + path.join(home, ".ade", "ade-code-state.json"), + JSON.stringify({ lastChatByLane: { "lane-1": "chat-1" } }), + "utf8", + ); + + const { loadAdeCodeState } = await loadStateModule(home); + expect(loadAdeCodeState()).toEqual({ + lastChatByLane: { "lane-1": "chat-1" }, + lastLaneId: null, + }); + }); + + it("persists the last lane id with last chat pointers", async () => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), "ade-tui-state-")); + const { loadAdeCodeState, saveAdeCodeState } = await loadStateModule(home); + + saveAdeCodeState({ + lastChatByLane: { "lane-2": "chat-9" }, + lastLaneId: "lane-2", + }); + + expect(loadAdeCodeState()).toEqual({ + lastChatByLane: { "lane-2": "chat-9" }, + lastLaneId: "lane-2", + }); + }); +}); + +describe("tuiEventDedupKey", () => { + it("uses sequence when present", () => { + const event = { + sessionId: "session-1", + sequence: 42, + timestamp: "2026-01-01T00:00:00.000Z", + event: { type: "text", text: "hello" }, + } as AgentChatEventEnvelope; + + expect(tuiEventDedupKey(event)).toContain("seq:42"); + }); + + it("keeps same-millisecond payload variants distinct when sequence is absent", () => { + const base = { + sessionId: "session-1", + timestamp: "2026-01-01T00:00:00.000Z", + }; + const first = { + ...base, + event: { type: "text", text: "hel" }, + } as AgentChatEventEnvelope; + const second = { + ...base, + event: { type: "text", text: "lo" }, + } as AgentChatEventEnvelope; + + expect(tuiEventDedupKey(first)).not.toBe(tuiEventDedupKey(second)); + }); + + it("keeps rebuilt events distinct when sequence restarts", () => { + const first = { + sessionId: "session-1", + sequence: 1, + timestamp: "2026-01-01T00:00:00.000Z", + event: { type: "text", text: "old" }, + } as AgentChatEventEnvelope; + const rebuilt = { + sessionId: "session-1", + sequence: 1, + timestamp: "2026-01-01T00:00:01.000Z", + event: { type: "text", text: "new" }, + } as AgentChatEventEnvelope; + + expect(tuiEventDedupKey(first)).not.toBe(tuiEventDedupKey(rebuilt)); + }); + + it("dedupes exact fallback event replays", () => { + const first = { + sessionId: "session-1", + timestamp: "2026-01-01T00:00:00.000Z", + event: { type: "text", text: "hello" }, + } as AgentChatEventEnvelope; + const replay = { + sessionId: "session-1", + timestamp: "2026-01-01T00:00:00.000Z", + event: { type: "text", text: "hello" }, + } as AgentChatEventEnvelope; + + expect(tuiEventDedupKey(first)).toBe(tuiEventDedupKey(replay)); + }); + + it("appends using cached keys without re-stringifying previous events", () => { + const previous = { + sessionId: "session-1", + sequence: 1, + timestamp: "2026-01-01T00:00:00.000Z", + event: { + type: "text", + text: "old", + toJSON() { + throw new Error("previous event should not be stringified again"); + }, + }, + } as unknown as AgentChatEventEnvelope; + const incoming = { + sessionId: "session-1", + sequence: 2, + timestamp: "2026-01-01T00:00:01.000Z", + event: { type: "text", text: "new" }, + } as AgentChatEventEnvelope; + const previousKey = "precomputed-previous-key"; + const keys = new Set([previousKey]); + + const key = reserveTuiEventDedupKey(incoming, keys); + expect(key).not.toBeNull(); + const next = appendReservedTuiEvent([previous], incoming, keys, [previousKey], key!); + + expect(next.events).toEqual([previous, incoming]); + expect(next.eventKeys).toEqual([previousKey, key]); + expect(keys.has(key!)).toBe(true); + }); + + it("uses cached keys to reject replays", () => { + const first = { + sessionId: "session-1", + timestamp: "2026-01-01T00:00:00.000Z", + event: { type: "text", text: "hello" }, + } as AgentChatEventEnvelope; + const keys = new Set(); + syncTuiEventDedupKeys(keys, [first]); + + expect(reserveTuiEventDedupKey(first, keys)).toBeNull(); + }); + + it("keeps pending reserved keys when trimming old events", () => { + const oldFirst = { + sessionId: "session-1", + sequence: 1, + timestamp: "2026-01-01T00:00:00.000Z", + event: { type: "text", text: "old first" }, + } as AgentChatEventEnvelope; + const oldSecond = { + sessionId: "session-1", + sequence: 2, + timestamp: "2026-01-01T00:00:01.000Z", + event: { type: "text", text: "old second" }, + } as AgentChatEventEnvelope; + const incomingFirst = { + sessionId: "session-1", + sequence: 3, + timestamp: "2026-01-01T00:00:02.000Z", + event: { type: "text", text: "new first" }, + } as AgentChatEventEnvelope; + const incomingSecond = { + sessionId: "session-1", + sequence: 4, + timestamp: "2026-01-01T00:00:03.000Z", + event: { type: "text", text: "new second" }, + } as AgentChatEventEnvelope; + const keys = new Set(); + const oldKeys = syncTuiEventDedupKeys(keys, [oldFirst, oldSecond]); + + const firstKey = reserveTuiEventDedupKey(incomingFirst, keys); + const secondKey = reserveTuiEventDedupKey(incomingSecond, keys); + expect(firstKey).not.toBeNull(); + expect(secondKey).not.toBeNull(); + + const afterFirstAppend = appendReservedTuiEvent([oldFirst, oldSecond], incomingFirst, keys, oldKeys, firstKey!, 2); + + expect(afterFirstAppend.events).toEqual([oldSecond, incomingFirst]); + expect(keys.has(oldKeys[0]!)).toBe(false); + expect(keys.has(secondKey!)).toBe(true); + expect(reserveTuiEventDedupKey(incomingSecond, keys)).toBeNull(); + + const afterSecondAppend = appendReservedTuiEvent( + afterFirstAppend.events, + incomingSecond, + keys, + afterFirstAppend.eventKeys, + secondKey!, + 2, + ); + + expect(afterSecondAppend.events).toEqual([incomingFirst, incomingSecond]); + expect(keys.has(oldKeys[1]!)).toBe(false); + expect(keys.has(firstKey!)).toBe(true); + expect(keys.has(secondKey!)).toBe(true); + }); + + it("evicts cached keys without re-stringifying trimmed events", () => { + const oldFirst = { + sessionId: "session-1", + sequence: 1, + timestamp: "2026-01-01T00:00:00.000Z", + event: { + type: "text", + text: "old first", + toJSON() { + return { type: "text", text: "old first" }; + }, + }, + } as unknown as AgentChatEventEnvelope; + const oldSecond = { + sessionId: "session-1", + sequence: 2, + timestamp: "2026-01-01T00:00:01.000Z", + event: { type: "text", text: "old second" }, + } as AgentChatEventEnvelope; + const incoming = { + sessionId: "session-1", + sequence: 3, + timestamp: "2026-01-01T00:00:02.000Z", + event: { type: "text", text: "new" }, + } as AgentChatEventEnvelope; + const keys = new Set(); + const oldKeys = syncTuiEventDedupKeys(keys, [oldFirst, oldSecond]); + (oldFirst.event as { toJSON?: () => unknown }).toJSON = () => { + throw new Error("trimmed event should not be stringified again"); + }; + + const incomingKey = reserveTuiEventDedupKey(incoming, keys); + expect(incomingKey).not.toBeNull(); + const next = appendReservedTuiEvent([oldFirst, oldSecond], incoming, keys, oldKeys, incomingKey!, 2); + + expect(next.events).toEqual([oldSecond, incoming]); + expect(keys.has(oldKeys[0]!)).toBe(false); + }); +}); diff --git a/apps/ade-cli/src/tuiClient/__tests__/drawerSelection.test.ts b/apps/ade-cli/src/tuiClient/__tests__/drawerSelection.test.ts index 5ee095ce6..0e1a4f46b 100644 --- a/apps/ade-cli/src/tuiClient/__tests__/drawerSelection.test.ts +++ b/apps/ade-cli/src/tuiClient/__tests__/drawerSelection.test.ts @@ -57,6 +57,18 @@ describe("resolveDrawerChatSelection", () => { })).toBeNull(); }); + it("keeps the selected new-chat row as a preview before a draft starts", () => { + expect(resolveDrawerChatSelection({ + activeLaneId: "lane-1", + activeSessionId: null, + draftChatActive: false, + drawerLaneId: "lane-1", + drawerVisibleLaneSessions: [session("chat-1")], + selectedDrawerChatAction: "new-chat", + selectedDrawerChatId: null, + })).toBeNull(); + }); + it("snaps stale new-chat selection back to the active visible chat", () => { expect(resolveDrawerChatSelection({ activeLaneId: "lane-1", diff --git a/apps/ade-cli/src/tuiClient/__tests__/eventDedup.test.ts b/apps/ade-cli/src/tuiClient/__tests__/eventDedup.test.ts deleted file mode 100644 index 4df09ca15..000000000 --- a/apps/ade-cli/src/tuiClient/__tests__/eventDedup.test.ts +++ /dev/null @@ -1,207 +0,0 @@ -import { describe, expect, it } from "vitest"; -import type { AgentChatEventEnvelope } from "../../../../desktop/src/shared/types/chat"; -import { - appendReservedTuiEvent, - reserveTuiEventDedupKey, - syncTuiEventDedupKeys, - tuiEventDedupKey, -} from "../eventDedup"; - -describe("tuiEventDedupKey", () => { - it("uses sequence when present", () => { - const event = { - sessionId: "session-1", - sequence: 42, - timestamp: "2026-01-01T00:00:00.000Z", - event: { type: "text", text: "hello" }, - } as AgentChatEventEnvelope; - - expect(tuiEventDedupKey(event)).toContain("seq:42"); - }); - - it("keeps same-millisecond payload variants distinct when sequence is absent", () => { - const base = { - sessionId: "session-1", - timestamp: "2026-01-01T00:00:00.000Z", - }; - const first = { - ...base, - event: { type: "text", text: "hel" }, - } as AgentChatEventEnvelope; - const second = { - ...base, - event: { type: "text", text: "lo" }, - } as AgentChatEventEnvelope; - - expect(tuiEventDedupKey(first)).not.toBe(tuiEventDedupKey(second)); - }); - - it("keeps rebuilt events distinct when sequence restarts", () => { - const first = { - sessionId: "session-1", - sequence: 1, - timestamp: "2026-01-01T00:00:00.000Z", - event: { type: "text", text: "old" }, - } as AgentChatEventEnvelope; - const rebuilt = { - sessionId: "session-1", - sequence: 1, - timestamp: "2026-01-01T00:00:01.000Z", - event: { type: "text", text: "new" }, - } as AgentChatEventEnvelope; - - expect(tuiEventDedupKey(first)).not.toBe(tuiEventDedupKey(rebuilt)); - }); - - it("dedupes exact fallback event replays", () => { - const first = { - sessionId: "session-1", - timestamp: "2026-01-01T00:00:00.000Z", - event: { type: "text", text: "hello" }, - } as AgentChatEventEnvelope; - const replay = { - sessionId: "session-1", - timestamp: "2026-01-01T00:00:00.000Z", - event: { type: "text", text: "hello" }, - } as AgentChatEventEnvelope; - - expect(tuiEventDedupKey(first)).toBe(tuiEventDedupKey(replay)); - }); - - it("appends using cached keys without re-stringifying previous events", () => { - const previous = { - sessionId: "session-1", - sequence: 1, - timestamp: "2026-01-01T00:00:00.000Z", - event: { - type: "text", - text: "old", - toJSON() { - throw new Error("previous event should not be stringified again"); - }, - }, - } as unknown as AgentChatEventEnvelope; - const incoming = { - sessionId: "session-1", - sequence: 2, - timestamp: "2026-01-01T00:00:01.000Z", - event: { type: "text", text: "new" }, - } as AgentChatEventEnvelope; - const previousKey = "precomputed-previous-key"; - const keys = new Set([previousKey]); - - const key = reserveTuiEventDedupKey(incoming, keys); - expect(key).not.toBeNull(); - const next = appendReservedTuiEvent([previous], incoming, keys, [previousKey], key!); - - expect(next.events).toEqual([previous, incoming]); - expect(next.eventKeys).toEqual([previousKey, key]); - expect(keys.has(key!)).toBe(true); - }); - - it("uses cached keys to reject replays", () => { - const first = { - sessionId: "session-1", - timestamp: "2026-01-01T00:00:00.000Z", - event: { type: "text", text: "hello" }, - } as AgentChatEventEnvelope; - const keys = new Set(); - syncTuiEventDedupKeys(keys, [first]); - - expect(reserveTuiEventDedupKey(first, keys)).toBeNull(); - }); - - it("keeps pending reserved keys when trimming old events", () => { - const oldFirst = { - sessionId: "session-1", - sequence: 1, - timestamp: "2026-01-01T00:00:00.000Z", - event: { type: "text", text: "old first" }, - } as AgentChatEventEnvelope; - const oldSecond = { - sessionId: "session-1", - sequence: 2, - timestamp: "2026-01-01T00:00:01.000Z", - event: { type: "text", text: "old second" }, - } as AgentChatEventEnvelope; - const incomingFirst = { - sessionId: "session-1", - sequence: 3, - timestamp: "2026-01-01T00:00:02.000Z", - event: { type: "text", text: "new first" }, - } as AgentChatEventEnvelope; - const incomingSecond = { - sessionId: "session-1", - sequence: 4, - timestamp: "2026-01-01T00:00:03.000Z", - event: { type: "text", text: "new second" }, - } as AgentChatEventEnvelope; - const keys = new Set(); - const oldKeys = syncTuiEventDedupKeys(keys, [oldFirst, oldSecond]); - - const firstKey = reserveTuiEventDedupKey(incomingFirst, keys); - const secondKey = reserveTuiEventDedupKey(incomingSecond, keys); - expect(firstKey).not.toBeNull(); - expect(secondKey).not.toBeNull(); - - const afterFirstAppend = appendReservedTuiEvent([oldFirst, oldSecond], incomingFirst, keys, oldKeys, firstKey!, 2); - - expect(afterFirstAppend.events).toEqual([oldSecond, incomingFirst]); - expect(keys.has(oldKeys[0]!)).toBe(false); - expect(keys.has(secondKey!)).toBe(true); - expect(reserveTuiEventDedupKey(incomingSecond, keys)).toBeNull(); - - const afterSecondAppend = appendReservedTuiEvent( - afterFirstAppend.events, - incomingSecond, - keys, - afterFirstAppend.eventKeys, - secondKey!, - 2, - ); - - expect(afterSecondAppend.events).toEqual([incomingFirst, incomingSecond]); - expect(keys.has(oldKeys[1]!)).toBe(false); - expect(keys.has(firstKey!)).toBe(true); - expect(keys.has(secondKey!)).toBe(true); - }); - - it("evicts cached keys without re-stringifying trimmed events", () => { - const oldFirst = { - sessionId: "session-1", - sequence: 1, - timestamp: "2026-01-01T00:00:00.000Z", - event: { - type: "text", - text: "old first", - toJSON() { - return { type: "text", text: "old first" }; - }, - }, - } as unknown as AgentChatEventEnvelope; - const oldSecond = { - sessionId: "session-1", - sequence: 2, - timestamp: "2026-01-01T00:00:01.000Z", - event: { type: "text", text: "old second" }, - } as AgentChatEventEnvelope; - const incoming = { - sessionId: "session-1", - sequence: 3, - timestamp: "2026-01-01T00:00:02.000Z", - event: { type: "text", text: "new" }, - } as AgentChatEventEnvelope; - const keys = new Set(); - const oldKeys = syncTuiEventDedupKeys(keys, [oldFirst, oldSecond]); - (oldFirst.event as { toJSON?: () => unknown }).toJSON = () => { - throw new Error("trimmed event should not be stringified again"); - }; - - const incomingKey = reserveTuiEventDedupKey(incoming, keys); - expect(incomingKey).not.toBeNull(); - const next = appendReservedTuiEvent([oldFirst, oldSecond], incoming, keys, oldKeys, incomingKey!, 2); - - expect(next.events).toEqual([oldSecond, incoming]); - expect(keys.has(oldKeys[0]!)).toBe(false); - }); -}); diff --git a/apps/ade-cli/src/tuiClient/__tests__/format.test.ts b/apps/ade-cli/src/tuiClient/__tests__/format.test.ts index 8bdb508b8..413a763c3 100644 --- a/apps/ade-cli/src/tuiClient/__tests__/format.test.ts +++ b/apps/ade-cli/src/tuiClient/__tests__/format.test.ts @@ -25,7 +25,7 @@ describe("renderChatLines", () => { ]); }); - it("renders compact rule-separated chat turns", () => { + it("renders compact chat turns without speaker metadata spam", () => { const lines = renderChatLines({ activeSession: null, notices: [], @@ -45,8 +45,7 @@ describe("renderChatLines", () => { ], }); expect(lines.map((line) => line.tone)).toEqual(["user", "assistant"]); - expect(lines[0]?.header).toContain("you"); - expect(lines[1]?.header).toContain("ADE"); + expect(lines.map((line) => line.header)).toEqual([undefined, undefined]); }); it("orders local notices and chat events by timestamp", () => { @@ -77,9 +76,10 @@ describe("renderChatLines", () => { }); expect(lines.map((line) => line.body)).toEqual(["hello", "Auth completed.", "hi"]); + expect(lines.map((line) => line.header)).toEqual([undefined, undefined, undefined]); }); - it("keeps terminal formatting artifacts out of model labels", () => { + it("omits assistant model labels from normal text", () => { const lines = renderChatLines({ activeSession: { sessionId: "s1", @@ -104,7 +104,8 @@ describe("renderChatLines", () => { ], }); - expect(lines[0]?.header).toMatch(/^Claude · .* · claude-opus-4-7$/); + expect(lines[0]?.header).toBeUndefined(); + expect(lines[0]?.body).toBe("hi"); }); it("renders non-JSON-safe objects without throwing", () => { @@ -484,6 +485,64 @@ describe("renderChatLines", () => { expect(lines[1]?.tone).toBe("assistant"); }); + it("suppresses low-value startup system notices and keeps auth failures concise", () => { + const lines = renderChatLines({ + activeSession: null, + notices: [], + events: [ + { + sessionId: "s1", + timestamp: "2026-01-01T12:00:00.000Z", + sequence: 1, + event: { type: "system_notice", noticeKind: "info", message: "Session ready" } as never, + }, + { + sessionId: "s1", + timestamp: "2026-01-01T12:00:01.000Z", + sequence: 2, + event: { type: "system_notice", noticeKind: "hook", message: "Hook: SessionStart:startup started" } as never, + }, + { + sessionId: "s1", + timestamp: "2026-01-01T12:00:02.000Z", + sequence: 3, + event: { type: "system_notice", noticeKind: "auth", message: "Failed to authenticate. API Error: 401 Invalid authentication credentials" } as never, + }, + ], + }); + + expect(lines).toHaveLength(1); + expect(lines[0]).toEqual(expect.objectContaining({ + tone: "error", + body: "Failed to authenticate. API Error: 401 Invalid authentication credentials", + })); + expect(lines[0]?.header).toBeUndefined(); + }); + + it("deduplicates consecutive identical notice rows", () => { + const lines = renderChatLines({ + activeSession: null, + notices: [], + events: [ + { + sessionId: "s1", + timestamp: "2026-01-01T12:00:00.000Z", + sequence: 1, + event: { type: "system_notice", noticeKind: "info", message: "Refreshing provider status." } as never, + }, + { + sessionId: "s1", + timestamp: "2026-01-01T12:00:01.000Z", + sequence: 2, + event: { type: "system_notice", noticeKind: "info", message: "Refreshing provider status." } as never, + }, + ], + }); + + expect(lines).toHaveLength(1); + expect(lines[0]?.body).toBe("Refreshing provider status."); + }); + it("routes severity-bearing system_notice variants to tone=error in the TUI", () => { const lines = renderChatLines({ activeSession: null, @@ -602,7 +661,7 @@ describe("renderChatLines", () => { expect(lines[0]?.blocks).toEqual([ { kind: "paragraph", text: "I'm Codex, running as a GPT-5 based software engineering agent." }, ]); - expect(lines[0]?.header).toMatch(/^Codex /); + expect(lines[0]?.header).toBeUndefined(); }); it("does not coalesce assistant text across a tool call", () => { @@ -645,7 +704,7 @@ describe("renderChatLines", () => { expect(lines.map((line) => line.tone)).toEqual(["assistant", "tool", "assistant"]); expect(lines[0]?.body).toBe("I'll check the branch."); expect(lines[2]?.body).toBe("We're on main."); - expect(lines[2]?.header).toMatch(/^Codex /); + expect(lines[2]?.header).toBeUndefined(); }); it("renders expanded failed tool output when requested", () => { diff --git a/apps/ade-cli/src/tuiClient/__tests__/heartbeat.test.ts b/apps/ade-cli/src/tuiClient/__tests__/heartbeat.test.ts deleted file mode 100644 index 44de87b1d..000000000 --- a/apps/ade-cli/src/tuiClient/__tests__/heartbeat.test.ts +++ /dev/null @@ -1,64 +0,0 @@ -import fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; -import { afterEach, describe, expect, it } from "vitest"; -import { startTuiHeartbeat, type TuiHeartbeat } from "../heartbeat"; - -const heartbeats: TuiHeartbeat[] = []; - -function tempProjectRoot(): string { - return fs.mkdtempSync(path.join(os.tmpdir(), "ade-code-heartbeat-")); -} - -function heartbeatFile(projectRoot: string): string { - return path.join(projectRoot, ".ade", "cache", "ade-code", "clients", `${process.pid}.json`); -} - -afterEach(() => { - for (const heartbeat of heartbeats.splice(0)) { - heartbeat.stop(); - } -}); - -describe("startTuiHeartbeat", () => { - it("shares process cleanup handlers across active heartbeats", () => { - const exitListeners = process.listenerCount("exit"); - const sigintListeners = process.listenerCount("SIGINT"); - const firstRoot = tempProjectRoot(); - const secondRoot = tempProjectRoot(); - - const first = startTuiHeartbeat(firstRoot); - heartbeats.push(first); - const second = startTuiHeartbeat(secondRoot); - heartbeats.push(second); - - expect(process.listenerCount("exit")).toBe(exitListeners + 1); - expect(process.listenerCount("SIGINT")).toBe(sigintListeners + 1); - expect(fs.existsSync(heartbeatFile(firstRoot))).toBe(true); - expect(fs.existsSync(heartbeatFile(secondRoot))).toBe(true); - - first.stop(); - expect(process.listenerCount("exit")).toBe(exitListeners + 1); - expect(process.listenerCount("SIGINT")).toBe(sigintListeners + 1); - expect(fs.existsSync(heartbeatFile(firstRoot))).toBe(false); - expect(fs.existsSync(heartbeatFile(secondRoot))).toBe(true); - - second.stop(); - expect(process.listenerCount("exit")).toBe(exitListeners); - expect(process.listenerCount("SIGINT")).toBe(sigintListeners); - expect(fs.existsSync(heartbeatFile(secondRoot))).toBe(false); - }); - - it("makes stop idempotent", () => { - const exitListeners = process.listenerCount("exit"); - const projectRoot = tempProjectRoot(); - const heartbeat = startTuiHeartbeat(projectRoot); - heartbeats.push(heartbeat); - - heartbeat.stop(); - heartbeat.stop(); - - expect(process.listenerCount("exit")).toBe(exitListeners); - expect(fs.existsSync(heartbeatFile(projectRoot))).toBe(false); - }); -}); diff --git a/apps/ade-cli/src/tuiClient/__tests__/imageTargets.test.ts b/apps/ade-cli/src/tuiClient/__tests__/imageTargets.test.ts index 282e1af26..e08d1437c 100644 --- a/apps/ade-cli/src/tuiClient/__tests__/imageTargets.test.ts +++ b/apps/ade-cli/src/tuiClient/__tests__/imageTargets.test.ts @@ -1,7 +1,45 @@ import path from "node:path"; -import { describe, expect, it } from "vitest"; +import fs from "node:fs"; +import os from "node:os"; +import { spawnSync } from "node:child_process"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { AgentChatEventEnvelope } from "../../../../desktop/src/shared/types/chat"; -import { latestOpenableImageTarget, normalizeOpenableImageTarget } from "../imageTargets"; +import { + latestOpenableImageTarget, + normalizeOpenableImageTarget, + parseAppleScriptClipboardData, + readClipboardImageAttachment, + readImageDimensionsFromBuffer, +} from "../imageTargets"; + +vi.mock("node:child_process", () => ({ + spawnSync: vi.fn(), +})); + +const spawnSyncMock = vi.mocked(spawnSync); +const processPlatform = Object.getOwnPropertyDescriptor(process, "platform"); + +function pngBuffer(width: number, height: number): Buffer { + const buffer = Buffer.alloc(24); + Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]).copy(buffer, 0); + buffer.write("IHDR", 12, "ascii"); + buffer.writeUInt32BE(width, 16); + buffer.writeUInt32BE(height, 20); + return buffer; +} + +function setPlatform(value: NodeJS.Platform): void { + Object.defineProperty(process, "platform", { value }); +} + +beforeEach(() => { + spawnSyncMock.mockReset(); + spawnSyncMock.mockReturnValue({ status: 1, stdout: "" } as ReturnType); +}); + +afterEach(() => { + if (processPlatform) Object.defineProperty(process, "platform", processPlatform); +}); describe("normalizeOpenableImageTarget", () => { it("allows http and https URLs", () => { @@ -72,3 +110,55 @@ describe("latestOpenableImageTarget", () => { ])).toBe(olderPath); }); }); + +describe("clipboard image helpers", () => { + it("parses AppleScript clipboard data records", () => { + expect(parseAppleScriptClipboardData("«data PNGf89504E47»")).toEqual(Buffer.from("89504E47", "hex")); + }); + + it("reads PNG dimensions from a pasted screenshot buffer", () => { + expect(readImageDimensionsFromBuffer(pngBuffer(1280, 760))).toEqual({ width: 1280, height: 760 }); + }); + + it("uses macOS AppleScript PNG data when pngpaste is unavailable", () => { + setPlatform("darwin"); + const workspaceRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-code-image-targets-")); + const payload = pngBuffer(1280, 760); + spawnSyncMock.mockImplementation((command, args) => { + if (command === "command" && Array.isArray(args) && args[1] === "osascript") { + return { status: 0, stdout: "" } as ReturnType; + } + if (command === "osascript") { + return { status: 0, stdout: `«data PNGf${payload.toString("hex").toUpperCase()}»` } as ReturnType; + } + return { status: 1, stdout: "" } as ReturnType; + }); + + const attachment = readClipboardImageAttachment(workspaceRoot); + + expect(attachment?.type).toBe("image"); + expect(attachment?.path).toContain(path.join(".ade", "cache", "ade-code-clipboard", "pasted-screenshot-")); + expect(attachment?.path.endsWith(".png")).toBe(true); + expect(fs.readFileSync(attachment!.path)).toEqual(payload); + }); + + it("does not save arbitrary pbpaste text as an image", () => { + setPlatform("darwin"); + const workspaceRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-code-image-targets-")); + spawnSyncMock.mockImplementation((command, args) => { + if (command === "command" && Array.isArray(args) && (args[1] === "pbpaste" || args[1] === "osascript")) { + return { status: 0, stdout: "" } as ReturnType; + } + if (command === "pbpaste" && Array.isArray(args) && args.includes("-Prefer")) { + return { status: 0, stdout: Buffer.from("not an image") } as ReturnType; + } + if (command === "pbpaste") { + return { status: 1, stdout: "" } as ReturnType; + } + return { status: 1, stdout: "" } as ReturnType; + }); + + expect(readClipboardImageAttachment(workspaceRoot)).toBeNull(); + expect(fs.existsSync(path.join(workspaceRoot, ".ade", "cache", "ade-code-clipboard"))).toBe(false); + }); +}); diff --git a/apps/ade-cli/src/tuiClient/__tests__/jsonRpcClient.test.ts b/apps/ade-cli/src/tuiClient/__tests__/jsonRpcClient.test.ts deleted file mode 100644 index 40b313108..000000000 --- a/apps/ade-cli/src/tuiClient/__tests__/jsonRpcClient.test.ts +++ /dev/null @@ -1,102 +0,0 @@ -import fs from "node:fs"; -import net from "node:net"; -import os from "node:os"; -import path from "node:path"; -import { describe, expect, it } from "vitest"; -import { JsonRpcClient } from "../jsonRpcClient"; - -function listen(server: net.Server, socketPath: string): Promise { - return new Promise((resolve, reject) => { - server.once("error", reject); - server.listen(socketPath, () => { - server.off("error", reject); - resolve(); - }); - }); -} - -function closeServer(server: net.Server): Promise { - return new Promise((resolve) => server.close(() => resolve())); -} - -describe("JsonRpcClient", () => { - it("handles framed notifications before JSONL responses", async () => { - const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "ade-code-jsonrpc-")); - const socketPath = path.join(tmpDir, "rpc.sock"); - let resolveServerSocket: (socket: net.Socket) => void = () => {}; - const serverSocketReady = new Promise((resolve) => { - resolveServerSocket = resolve; - }); - const server = net.createServer((socket) => { - resolveServerSocket(socket); - socket.on("data", (chunk) => { - const text = String(chunk); - const match = /"id":(\d+)/.exec(text); - const id = match ? Number.parseInt(match[1]!, 10) : 1; - socket.write(`${JSON.stringify({ jsonrpc: "2.0", id, result: { ok: true } })}\n`); - }); - }); - - await listen(server, socketPath); - const client = await JsonRpcClient.connect(socketPath); - const socket = await serverSocketReady; - try { - const notification = new Promise((resolve) => { - client.onNotification("chat/event", resolve); - }); - const payload = JSON.stringify({ - jsonrpc: "2.0", - method: "chat/event", - params: { sessionId: "s1" }, - }); - socket.write(`Content-Length: ${Buffer.byteLength(payload, "utf8")}\r\n\r\n${payload}`); - - await expect(notification).resolves.toEqual({ sessionId: "s1" }); - await expect(client.request("ping")).resolves.toEqual({ ok: true }); - } finally { - client.close(); - socket.destroy(); - await closeServer(server); - fs.rmSync(tmpDir, { recursive: true, force: true }); - } - }); - - it("honors byte-based Content-Length framing for unicode payloads", async () => { - const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "ade-code-jsonrpc-")); - const socketPath = path.join(tmpDir, "rpc.sock"); - let resolveServerSocket: (socket: net.Socket) => void = () => {}; - const serverSocketReady = new Promise((resolve) => { - resolveServerSocket = resolve; - }); - const server = net.createServer((socket) => { - resolveServerSocket(socket); - }); - - await listen(server, socketPath); - const client = await JsonRpcClient.connect(socketPath); - const socket = await serverSocketReady; - try { - const notification = new Promise((resolve) => { - client.onNotification("chat/event", resolve); - }); - const payload = JSON.stringify({ - jsonrpc: "2.0", - method: "chat/event", - params: { message: "héllo ✅" }, - }); - const framed = Buffer.concat([ - Buffer.from(`Content-Length: ${Buffer.byteLength(payload, "utf8")}\r\n\r\n`, "ascii"), - Buffer.from(payload, "utf8"), - ]); - socket.write(framed.subarray(0, 20)); - socket.write(framed.subarray(20)); - - await expect(notification).resolves.toEqual({ message: "héllo ✅" }); - } finally { - client.close(); - socket.destroy(); - await closeServer(server); - fs.rmSync(tmpDir, { recursive: true, force: true }); - } - }); -}); diff --git a/apps/ade-cli/src/tuiClient/__tests__/keybindings.test.ts b/apps/ade-cli/src/tuiClient/__tests__/keybindings.test.ts index b7b5780d6..ae9baa183 100644 --- a/apps/ade-cli/src/tuiClient/__tests__/keybindings.test.ts +++ b/apps/ade-cli/src/tuiClient/__tests__/keybindings.test.ts @@ -36,13 +36,14 @@ describe("keybindings", () => { it("dispatches context bindings with Global fallback", () => { const diagnostics = validateClaudeKeybindingsConfig({ bindings: [ - { context: "Global", bindings: { "ctrl+p": "pane:toggle" } }, + { context: "Global", bindings: { "ctrl+p": "pane:toggle", "ctrl+a": "pane:agents" } }, { context: "Chat", bindings: { "up": "history:previous", "ctrl+g": "chat:externalEditor" } }, ], }); expect(dispatchKeybinding(diagnostics.bindings, "Chat", "", { upArrow: true })).toBe("history:previous"); expect(dispatchKeybinding(diagnostics.bindings, "Chat", "p", { ctrl: true })).toBe("pane:toggle"); + expect(dispatchKeybinding(diagnostics.bindings, "Chat", "a", { ctrl: true })).toBe("pane:agents"); expect(dispatchKeybinding(diagnostics.bindings, "Chat", "g", { ctrl: true })).toBe("chat:externalEditor"); expect(dispatchKeybinding(diagnostics.bindings, "Help", "", { upArrow: true })).toBeUndefined(); }); diff --git a/apps/ade-cli/src/tuiClient/__tests__/linearCommands.test.ts b/apps/ade-cli/src/tuiClient/__tests__/linearCommands.test.ts deleted file mode 100644 index c0fd61398..000000000 --- a/apps/ade-cli/src/tuiClient/__tests__/linearCommands.test.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { buildLinearToolRequest, parseLinearArgs } from "../linearCommands"; - -describe("linear command routing", () => { - it("parses flags and quoted values", () => { - expect(parseLinearArgs("run cancel run-1 --reason \"not ready\" --launch false")).toEqual({ - positionals: ["run", "cancel", "run-1"], - options: { reason: "not ready", launch: false }, - }); - }); - - it("routes sync dashboard and queue resolution", () => { - expect(buildLinearToolRequest("sync dashboard")).toEqual({ - kind: "tool", - title: "Linear sync dashboard", - toolName: "getLinearSyncDashboard", - args: {}, - }); - expect(buildLinearToolRequest("sync resolve queue-1 approve --note ok")).toEqual({ - kind: "tool", - title: "Linear sync resolve", - toolName: "resolveLinearSyncQueueItem", - args: { - queueItemId: "queue-1", - action: "approve", - note: "ok", - }, - }); - }); - - it("routes worker handoff and reports usage for missing fields", () => { - expect(buildLinearToolRequest("route worker LIN-123 agent-1")).toEqual({ - kind: "tool", - title: "Linear route worker", - toolName: "routeLinearIssueToWorker", - args: { issueId: "LIN-123", agentId: "agent-1" }, - }); - expect(buildLinearToolRequest("run cancel run-1")).toEqual({ - kind: "usage", - title: "Linear run cancel", - body: "Usage: /linear run cancel --reason ", - }); - }); -}); diff --git a/apps/ade-cli/src/tuiClient/__tests__/project.test.ts b/apps/ade-cli/src/tuiClient/__tests__/project.test.ts index bdb138060..3e380f253 100644 --- a/apps/ade-cli/src/tuiClient/__tests__/project.test.ts +++ b/apps/ade-cli/src/tuiClient/__tests__/project.test.ts @@ -1,6 +1,7 @@ import path from "node:path"; import { describe, expect, it } from "vitest"; -import { chooseInitialLane } from "../project"; +import { chooseInitialLane, chooseMostRecentSessionLane, chooseTuiLaunchLane, resolveTuiChatRefreshTarget } from "../project"; +import type { AgentChatSessionSummary } from "../../../../desktop/src/shared/types/chat"; import type { LaneSummary } from "../../../../desktop/src/shared/types/lanes"; function lane(overrides: Partial): LaneSummary { @@ -25,6 +26,21 @@ function lane(overrides: Partial): LaneSummary { }; } +function chat(sessionId: string, laneId: string, lastActivityAt: string): AgentChatSessionSummary { + return { + sessionId, + laneId, + provider: "codex", + model: "gpt-5.5", + status: "idle", + startedAt: "2026-01-01T00:00:00.000Z", + endedAt: null, + lastActivityAt, + lastOutputPreview: null, + summary: null, + }; +} + describe("chooseInitialLane", () => { it("prefers the ADE worktree lane hint", () => { const lanes = [ @@ -49,3 +65,145 @@ describe("chooseInitialLane", () => { })?.id).toBe("feature-b"); }); }); + +describe("chooseTuiLaunchLane", () => { + it("uses the persisted last lane when launch context only resolves to primary", () => { + const lanes = [ + lane({ id: "main", name: "main", laneType: "primary", worktreePath: "/repo" }), + lane({ id: "feature-b", name: "Feature B", laneType: "worktree", worktreePath: "/repo/.ade/worktrees/feature-b" }), + ]; + + expect(chooseTuiLaunchLane(lanes, { + workspaceRoot: "/repo", + laneHint: null, + }, "feature-b")?.id).toBe("feature-b"); + }); + + it("keeps an explicit worktree launch ahead of the persisted last lane", () => { + const featureAPath = path.resolve("/repo/.ade/worktrees/feature-a"); + const lanes = [ + lane({ id: "main", name: "main", laneType: "primary", worktreePath: "/repo" }), + lane({ id: "feature-a", name: "Feature A", laneType: "worktree", worktreePath: featureAPath }), + lane({ id: "feature-b", name: "Feature B", laneType: "worktree", worktreePath: "/repo/.ade/worktrees/feature-b" }), + ]; + + expect(chooseTuiLaunchLane(lanes, { + workspaceRoot: path.join(featureAPath, "apps/desktop"), + laneHint: null, + }, "feature-b")?.id).toBe("feature-a"); + }); +}); + +describe("chooseMostRecentSessionLane", () => { + it("uses runtime chat activity to choose the launch preview lane", () => { + const lanes = [ + lane({ id: "main", name: "main", laneType: "primary", worktreePath: "/repo" }), + lane({ id: "feature-a", name: "Feature A", laneType: "worktree", worktreePath: "/repo/.ade/worktrees/feature-a" }), + lane({ id: "feature-b", name: "Feature B", laneType: "worktree", worktreePath: "/repo/.ade/worktrees/feature-b" }), + ]; + + expect(chooseMostRecentSessionLane(lanes, [ + chat("old", "feature-a", "2026-01-01T00:00:00.000Z"), + chat("recent", "feature-b", "2026-01-02T00:00:00.000Z"), + ])?.id).toBe("feature-b"); + }); + + it("ignores sessions for lanes missing from the current runtime state", () => { + const lanes = [ + lane({ id: "main", name: "main", laneType: "primary", worktreePath: "/repo" }), + ]; + + expect(chooseMostRecentSessionLane(lanes, [ + chat("stale", "deleted-lane", "2026-01-02T00:00:00.000Z"), + ])).toBeNull(); + }); +}); + +describe("resolveTuiChatRefreshTarget", () => { + it("launches into a new-chat preview for the most recent runtime lane without hydrating its last chat", () => { + const lanes = [ + lane({ id: "main", name: "main", laneType: "primary", worktreePath: "/repo" }), + lane({ id: "feature-a", name: "Feature A", laneType: "worktree", worktreePath: "/repo/.ade/worktrees/feature-a" }), + lane({ id: "feature-b", name: "Feature B", laneType: "worktree", worktreePath: "/repo/.ade/worktrees/feature-b" }), + ]; + const recent = chat("recent-chat", "feature-b", "2026-01-02T00:00:00.000Z"); + + const target = resolveTuiChatRefreshTarget({ + lanes, + sessions: [ + chat("old-chat", "feature-a", "2026-01-01T00:00:00.000Z"), + recent, + ], + context: { workspaceRoot: "/repo", laneHint: null }, + lastLaneId: "feature-a", + activeLaneId: null, + activeSessionId: null, + draftChatActive: false, + initialNewChatPreview: true, + newChatPreviewLaneId: null, + selectedDrawerChatAction: null, + drawerLaneId: null, + }); + + expect(target.laneId).toBe("feature-b"); + expect(target.launchToNewChatPreview).toBe(true); + expect(target.previewMode).toBe(true); + expect(target.session).toBeNull(); + expect(target.seedSession?.sessionId).toBe(recent.sessionId); + }); + + it("lets the initial new-chat launch target override a stale active lane", () => { + const lanes = [ + lane({ id: "main", name: "main", laneType: "primary", worktreePath: "/repo" }), + lane({ id: "feature-a", name: "Feature A", laneType: "worktree", worktreePath: "/repo/.ade/worktrees/feature-a" }), + lane({ id: "feature-b", name: "Feature B", laneType: "worktree", worktreePath: "/repo/.ade/worktrees/feature-b" }), + ]; + const recent = chat("recent-chat", "feature-b", "2026-01-02T00:00:00.000Z"); + + const target = resolveTuiChatRefreshTarget({ + lanes, + sessions: [ + chat("old-chat", "feature-a", "2026-01-01T00:00:00.000Z"), + recent, + ], + context: { workspaceRoot: "/repo", laneHint: null }, + lastLaneId: "feature-a", + activeLaneId: "feature-a", + activeSessionId: null, + draftChatActive: false, + initialNewChatPreview: true, + newChatPreviewLaneId: null, + selectedDrawerChatAction: null, + drawerLaneId: null, + }); + + expect(target.laneId).toBe("feature-b"); + expect(target.session).toBeNull(); + expect(target.seedSession?.sessionId).toBe(recent.sessionId); + }); + + it("keeps the new-chat preview on later refreshes instead of snapping to the newest lane chat", () => { + const lanes = [ + lane({ id: "main", name: "main", laneType: "primary", worktreePath: "/repo" }), + lane({ id: "feature-b", name: "Feature B", laneType: "worktree", worktreePath: "/repo/.ade/worktrees/feature-b" }), + ]; + + const target = resolveTuiChatRefreshTarget({ + lanes, + sessions: [chat("recent-chat", "feature-b", "2026-01-02T00:00:00.000Z")], + context: { workspaceRoot: "/repo", laneHint: null }, + lastLaneId: "main", + activeLaneId: "feature-b", + activeSessionId: null, + draftChatActive: false, + initialNewChatPreview: false, + newChatPreviewLaneId: "feature-b", + selectedDrawerChatAction: "new-chat", + drawerLaneId: "feature-b", + }); + + expect(target.laneId).toBe("feature-b"); + expect(target.previewMode).toBe(true); + expect(target.session).toBeNull(); + }); +}); diff --git a/apps/ade-cli/src/tuiClient/__tests__/subagentPane.test.ts b/apps/ade-cli/src/tuiClient/__tests__/subagentPane.test.ts new file mode 100644 index 000000000..98a5fee16 --- /dev/null +++ b/apps/ade-cli/src/tuiClient/__tests__/subagentPane.test.ts @@ -0,0 +1,130 @@ +import { describe, expect, it } from "vitest"; +import { + buildSubagentPaneRows, + buildSubagentTranscriptEvents, + selectedSubagentSnapshot, + subagentIndexForPaneLine, + subagentPaneSelectableLineOffsets, +} from "../subagentPane"; +import type { AgentChatEventEnvelope, AgentChatSessionSummary } from "../../../../desktop/src/shared/types/chat"; +import type { RightPaneContent } from "../types"; + +const session: AgentChatSessionSummary = { + sessionId: "s1", + laneId: "lane-1", + provider: "codex", + model: "gpt-5.5", + status: "idle", + startedAt: "2026-01-01T12:00:00.000Z", + endedAt: null, + lastActivityAt: "2026-01-01T12:00:00.000Z", + lastOutputPreview: null, + summary: null, +}; + +function subagentsContent(): Extract { + return { + kind: "subagents", + tab: "subagents", + provider: "codex", + snapshots: [ + { id: "run-1", name: "running", kind: "subagent", status: "running", summary: "checking files" }, + { id: "team-1", name: "review", kind: "teammate", status: "completed", summary: "done" }, + { id: "bg-1", name: "lane pack", kind: "subagent", status: "running", background: true, summary: "refreshing" }, + { id: "done-1", name: "tests", kind: "subagent", status: "completed", summary: "passed" }, + ], + }; +} + +describe("subagent pane helpers", () => { + it("keeps main first and groups selectable agent rows by section", () => { + const rows = buildSubagentPaneRows(subagentsContent()); + + expect(rows.map((row) => row.key)).toEqual(["main", "run-1", "team-1", "bg-1", "done-1"]); + expect(rows.map((row) => row.section)).toEqual(["main", "subagents", "teammates", "background", "recent"]); + expect(selectedSubagentSnapshot(subagentsContent(), 0)).toBeNull(); + expect(selectedSubagentSnapshot(subagentsContent(), 1)?.id).toBe("run-1"); + }); + + it("maps visual table lines back to selectable rows for mouse clicks", () => { + const content = subagentsContent(); + const offsets = subagentPaneSelectableLineOffsets(content); + + expect(offsets.length).toBe(5); + expect(subagentIndexForPaneLine(content, offsets[0]!)).toBe(0); + expect(subagentIndexForPaneLine(content, offsets[1]!)).toBe(1); + expect(subagentIndexForPaneLine(content, offsets[3]!)).toBe(3); + expect(subagentIndexForPaneLine(content, offsets[4]! + 1)).toBe(4); + expect(subagentIndexForPaneLine(content, offsets[0]! - 2)).toBeNull(); + }); + + it("builds a focused transcript without unrelated subagent output", () => { + const events: AgentChatEventEnvelope[] = [ + { + sessionId: "s1", + timestamp: "2026-01-01T12:00:00.000Z", + sequence: 1, + event: { type: "subagent_started", taskId: "run-1", parentToolUseId: "spawn-1", description: "running" }, + }, + { + sessionId: "s1", + timestamp: "2026-01-01T12:00:01.000Z", + sequence: 2, + event: { type: "tool_call", itemId: "tool-1", parentItemId: "spawn-1", tool: "read_file", args: { path: "src/app.tsx" } }, + }, + { + sessionId: "s1", + timestamp: "2026-01-01T12:00:02.000Z", + sequence: 3, + event: { type: "subagent_result", taskId: "other", parentToolUseId: "spawn-2", status: "completed", summary: "wrong transcript" }, + }, + ]; + + const transcript = buildSubagentTranscriptEvents({ + events, + activeSession: session, + snapshot: { + id: "run-1", + name: "running", + kind: "subagent", + status: "running", + summary: "checking files", + parentToolUseId: "spawn-1", + }, + }); + + expect(transcript.map((entry) => entry.event.type)).toEqual(["text", "text", "tool_call"]); + expect(transcript.map((entry) => JSON.stringify(entry.event)).join("\n")).toContain("read_file"); + expect(transcript.map((entry) => JSON.stringify(entry.event)).join("\n")).not.toContain("wrong transcript"); + }); + + it("matches lifecycle events by agentId when no parent tool id is available", () => { + const transcript = buildSubagentTranscriptEvents({ + events: [ + { + sessionId: "s1", + timestamp: "2026-01-01T12:00:00.000Z", + sequence: 1, + event: { + type: "subagent_started", + taskId: "task-1", + agentId: "agent-1", + parentToolUseId: null, + description: "Investigate issue", + }, + }, + ], + activeSession: session, + snapshot: { + id: "agent-1", + name: "Investigate issue", + kind: "subagent", + status: "running", + summary: "", + parentToolUseId: null, + }, + }); + + expect(transcript.map((entry) => JSON.stringify(entry.event)).join("\n")).toContain("Subagent started: Investigate issue"); + }); +}); diff --git a/apps/ade-cli/src/tuiClient/adeApi.ts b/apps/ade-cli/src/tuiClient/adeApi.ts index 55f63d2fa..42ae37afa 100644 --- a/apps/ade-cli/src/tuiClient/adeApi.ts +++ b/apps/ade-cli/src/tuiClient/adeApi.ts @@ -16,6 +16,8 @@ import type { AgentChatCodexSandbox, AgentChatContextUsage, AgentChatCursorConfigValue, + AgentChatDispatchSteerMode, + AgentChatDispatchSteerResult, AgentChatDroidPermissionMode, AgentChatEventEnvelope, AgentChatFileRef, @@ -27,10 +29,14 @@ import type { AgentChatSession, AgentChatSessionSummary, AgentChatSlashCommand, + AgentChatSlashCommandsArgs, + AgentChatSteerResult, CodexThreadGoal, } from "../../../desktop/src/shared/types/chat"; import type { AiSettingsStatus, OpenCodeRuntimeSnapshot } from "../../../desktop/src/shared/types/config"; +import type { DiffLineStats } from "../../../desktop/src/shared/types/git"; import type { LaneSummary } from "../../../desktop/src/shared/types/lanes"; +import type { PrLaneSummary } from "../../../desktop/src/shared/types/prs"; import { discoverClaudeSlashCommands } from "../../../desktop/src/main/services/chat/claudeSlashCommandDiscovery"; import { discoverCodexSlashCommands } from "../../../desktop/src/main/services/chat/codexSlashCommandDiscovery"; import type { AdeCodeConnection, ChatHistorySnapshot, CreatedChat, NavigateRequest, NavigateResult } from "./types"; @@ -44,6 +50,15 @@ export async function listLanes(connection: AdeCodeConnection): Promise> { + return await connection.action>("diff", "listLaneDiffStats", { + ...(laneIds ? { laneIds } : {}), + }); +} + export async function listChatSessions( connection: AdeCodeConnection, laneId?: string | null, @@ -52,6 +67,10 @@ export async function listChatSessions( return await connection.actionList("chat", "listSessions", argsList); } +export async function listPrsByLane(connection: AdeCodeConnection): Promise { + return await connection.action("pr", "listPrsByLane", {}); +} + export async function getChatHistory( connection: AdeCodeConnection, sessionId: string, @@ -62,10 +81,12 @@ export async function getChatHistory( export async function getSlashCommands( connection: AdeCodeConnection, - sessionId: string | null, + args: string | null | AgentChatSlashCommandsArgs, ): Promise { - if (!sessionId) return []; - return await connection.action("chat", "getSlashCommands", { sessionId }); + if (!args) return []; + const requestArgs = typeof args === "string" ? { sessionId: args } : args; + if (!requestArgs.sessionId && !requestArgs.laneId) return []; + return await connection.action("chat", "getSlashCommands", requestArgs); } export async function getContextUsage( @@ -230,6 +251,45 @@ export async function sendChatMessage( ]); } +export async function steerChatMessage( + connection: AdeCodeConnection, + sessionId: string, + text: string, + attachments: AgentChatFileRef[] = [], +): Promise { + return await connection.action("chat", "steer", { + sessionId, + text, + ...(attachments.length ? { attachments } : {}), + }); +} + +export async function cancelSteerMessage( + connection: AdeCodeConnection, + sessionId: string, + steerId: string, +): Promise { + await connection.action("chat", "cancelSteer", { sessionId, steerId }); +} + +export async function editSteerMessage( + connection: AdeCodeConnection, + sessionId: string, + steerId: string, + text: string, +): Promise { + await connection.action("chat", "editSteer", { sessionId, steerId, text }); +} + +export async function dispatchSteerMessage( + connection: AdeCodeConnection, + sessionId: string, + steerId: string, + mode: AgentChatDispatchSteerMode, +): Promise { + return await connection.action("chat", "dispatchSteer", { sessionId, steerId, mode }); +} + export async function approveToolUse(args: { connection: AdeCodeConnection; sessionId: string; diff --git a/apps/ade-cli/src/tuiClient/aggregate.ts b/apps/ade-cli/src/tuiClient/aggregate.ts new file mode 100644 index 000000000..8b3a33f96 --- /dev/null +++ b/apps/ade-cli/src/tuiClient/aggregate.ts @@ -0,0 +1,540 @@ +import path from "node:path"; +import type { + AgentChatEvent, + AgentChatEventEnvelope, + AgentChatSessionSummary, +} from "../../../desktop/src/shared/types/chat"; +import type { LocalNotice } from "./types"; +import { + chatEventLineId, + renderChatLines, + type RenderedChatLine, +} from "./format"; +import { workEventItemId, workEventParentItemId } from "./workEventIds"; + +export type WorkToolStatus = "running" | "ok" | "failed"; + +export type WorkTool = { + itemId: string; + tool: string; + arg: string; + status: WorkToolStatus; + durationMs?: number; +}; + +export type PlanStep = { + text: string; + status: "pending" | "in_progress" | "completed" | "failed"; +}; + +export type PendingSteer = { + steerId: string; + text: string; +}; + +export type AggregatedBlock = + | { kind: "user-bubble"; id: string; line: RenderedChatLine } + | { kind: "assistant-text"; id: string; line: RenderedChatLine; precededByHeavy?: boolean } + | { kind: "work-block"; id: string; turnId: string | null; tools: WorkTool[]; live: boolean; durationMs?: number } + | { kind: "memory"; id: string; turnId: string | null; live: boolean; hitCount?: number; text?: string } + | { kind: "compaction"; id: string; turnId: string | null; trigger: "manual" | "auto"; live: boolean; preTokens?: number } + | { kind: "queued-steer"; id: string; turnId: string | null; steerId: string; text: string } + | { kind: "plan"; id: string; turnId: string | null; steps: PlanStep[]; current: number; total: number; live: boolean } + | { kind: "approval"; id: string; line: RenderedChatLine } + | { kind: "error"; id: string; line: RenderedChatLine } + | { kind: "notice"; id: string; line: RenderedChatLine }; + +function turnIdOf(event: AgentChatEvent): string | null { + return (event as { turnId?: string }).turnId ?? null; +} + +function safeMs(value: string): number { + const parsed = Date.parse(value); + return Number.isNaN(parsed) ? 0 : parsed; +} + +function singleArg(value: unknown, max = 60): string { + const text = (() => { + if (typeof value === "string") return value; + try { + return JSON.stringify(value); + } catch { + return String(value); + } + })(); + return (text ?? "").replace(/\s+/g, " ").trim().slice(0, max); +} + +function diffStats(diff: string): string { + const lines = diff.split(/\r?\n/); + let adds = 0; + let dels = 0; + for (const line of lines) { + if (/^\+[^+]/.test(line)) adds += 1; + else if (/^-[^-]/.test(line)) dels += 1; + } + return `+${adds} −${dels}`; +} + +function appendTool(block: Extract, event: AgentChatEvent, envelope: AgentChatEventEnvelope): void { + if (event.type === "tool_call") { + block.tools.push({ + itemId: event.itemId, + tool: event.tool, + arg: singleArg(event.args), + status: "running", + }); + return; + } + if (event.type === "tool_result") { + const existing = block.tools.find((tool) => tool.itemId === event.itemId); + const status: WorkToolStatus = event.status === "failed" ? "failed" : event.status === "running" ? "running" : "ok"; + if (existing) { + existing.status = status; + return; + } + block.tools.push({ + itemId: event.itemId, + tool: event.tool, + arg: singleArg(event.result), + status, + }); + return; + } + if (event.type === "command") { + const failed = event.status === "failed" || (event.exitCode ?? 0) !== 0; + const status: WorkToolStatus = event.status === "running" ? "running" : failed ? "failed" : "ok"; + const existing = block.tools.find((tool) => tool.itemId === event.itemId); + if (existing) { + existing.status = status; + if (typeof event.durationMs === "number") existing.durationMs = event.durationMs; + existing.arg = event.command; + return; + } + block.tools.push({ + itemId: event.itemId, + tool: "bash", + arg: event.command, + status, + durationMs: event.durationMs ?? undefined, + }); + return; + } + if (event.type === "file_change") { + const status: WorkToolStatus = event.status === "failed" ? "failed" : event.status === "running" ? "running" : "ok"; + const stats = diffStats(event.diff); + const arg = `${path.basename(event.path)} ${stats}`; + const existing = block.tools.find((tool) => tool.itemId === event.itemId); + if (existing) { + existing.status = status; + existing.arg = arg; + return; + } + block.tools.push({ itemId: event.itemId, tool: "edit", arg, status }); + return; + } + // Unknown — ignore. + void envelope; +} + +function isExpandedFailureEvent(event: AgentChatEvent): boolean { + if (event.type === "tool_result") return event.status === "failed"; + if (event.type === "file_change") return event.status === "failed"; + if (event.type === "command") return event.status === "failed" || (event.exitCode ?? 0) !== 0; + return false; +} + +function parseMemoryHits(text: string | undefined): number | undefined { + if (!text) return undefined; + const match = /(\d+)\s*hits?/i.exec(text); + if (!match) return undefined; + const value = Number.parseInt(match[1]!, 10); + return Number.isFinite(value) ? value : undefined; +} + +function findLastBlock( + blocks: AggregatedBlock[], + kind: K, + turnId: string | null, +): Extract | null { + for (let index = blocks.length - 1; index >= 0; index -= 1) { + const candidate = blocks[index]!; + if (candidate.kind !== kind) continue; + const candidateTurn = (candidate as { turnId?: string | null }).turnId ?? null; + if (candidateTurn !== turnId) continue; + return candidate as Extract; + } + return null; +} + +// Event types that have already contributed to aggregate-level blocks or are +// intentionally hidden. Keep unmatched system_notice events visible. +const SILENCED_EVENT_TYPES = new Set([ + "tool_call", + "tool_result", + "command", + "file_change", + "reasoning", + "plan", + "done", + "user_message", + "text", + "approval_request", + "error", + "tokens", + "codex_token_usage", + "codex_goal_updated", + "codex_goal_cleared", + "pending_input_resolved", +]); + +function isSubagentTimelineEvent(event: AgentChatEvent): boolean { + const type = String((event as { type?: unknown }).type ?? ""); + return type === "subagent_started" + || type === "subagent_progress" + || type === "subagent_result" + || type === "subagent.started" + || type === "subagent.progress" + || type === "subagent.completed" + || type === "teammate.idle" + || type === "task.completed"; +} + +function stringField(value: unknown): string | null { + return typeof value === "string" && value.trim().length > 0 ? value.trim() : null; +} + +function subagentParentItemId(event: AgentChatEvent): string | null { + if (!isSubagentTimelineEvent(event)) return null; + return stringField((event as { parentToolUseId?: unknown }).parentToolUseId); +} + +function isSubagentChildWorkEvent( + event: AgentChatEvent, + subagentParentItemIds: ReadonlySet, + subagentChildItemIds: ReadonlySet, +): boolean { + const parentItemId = workEventParentItemId(event); + if (parentItemId && subagentParentItemIds.has(parentItemId)) return true; + const itemId = workEventItemId(event); + return itemId != null && subagentChildItemIds.has(itemId); +} + +type SteerLifecycleNotice = Extract & { + steerId: string; + message: string; +}; + +function isSteerLifecycleNotice(event: AgentChatEvent): event is SteerLifecycleNotice { + const message = event.type === "system_notice" ? event.message.trim().toLowerCase() : ""; + return event.type === "system_notice" + && Boolean(event.steerId) + && ( + message === "message queued" + || message === "delivering queued message" + || message === "queued message cancelled" + ); +} + +export function derivePendingSteers(events: AgentChatEventEnvelope[]): PendingSteer[] { + const steerMap = new Map(); + const resolvedSteerIds = new Set(); + for (const envelope of events) { + const event = envelope.event; + if (event.type === "user_message" && event.steerId) { + if (event.deliveryState === "queued") { + if (!resolvedSteerIds.has(event.steerId)) { + steerMap.set(event.steerId, { steerId: event.steerId, text: event.displayText ?? event.text }); + } + } else { + steerMap.delete(event.steerId); + resolvedSteerIds.add(event.steerId); + } + continue; + } + if (isSteerLifecycleNotice(event) && event.message.trim().toLowerCase() !== "message queued") { + steerMap.delete(event.steerId); + resolvedSteerIds.add(event.steerId); + } + } + return Array.from(steerMap.values()); +} + +export function aggregateChatBlocks(args: { + events: AgentChatEventEnvelope[]; + notices: LocalNotice[]; + activeSession: AgentChatSessionSummary | null; + expandedLineIds?: Set; + maxBlocks?: number; +}): AggregatedBlock[] { + const lines = renderChatLines({ + events: args.events, + notices: args.notices, + activeSession: args.activeSession, + expandedLineIds: args.expandedLineIds, + maxLines: Number.MAX_SAFE_INTEGER, + }); + const linesById = new Map(); + for (const line of lines) linesById.set(line.id, line); + + const blocks: AggregatedBlock[] = []; + const turnStart = new Map(); + const pendingSteerIds = new Set(derivePendingSteers(args.events).map((steer) => steer.steerId)); + const subagentParentItemIds = new Set(); + for (const envelope of args.events) { + const parentItemId = subagentParentItemId(envelope.event); + if (parentItemId) subagentParentItemIds.add(parentItemId); + } + const subagentChildItemIds = new Set(); + if (subagentParentItemIds.size > 0) { + for (const envelope of args.events) { + const parentItemId = workEventParentItemId(envelope.event); + const itemId = workEventItemId(envelope.event); + if (parentItemId && itemId && subagentParentItemIds.has(parentItemId)) { + subagentChildItemIds.add(itemId); + } + } + } + + type TimelineEntry = + | { kind: "event"; timestamp: number; index: number; envelope: AgentChatEventEnvelope } + | { kind: "notice"; timestamp: number; index: number; notice: LocalNotice }; + + const timeline: TimelineEntry[] = [ + ...args.events.map((envelope, index): TimelineEntry => ({ + kind: "event", + timestamp: safeMs(envelope.timestamp), + index, + envelope, + })), + ...args.notices.map((notice, index): TimelineEntry => ({ + kind: "notice", + timestamp: safeMs(notice.timestamp), + index, + notice, + })), + ].sort((a, b) => { + if (a.timestamp !== b.timestamp) return a.timestamp - b.timestamp; + if (a.kind !== b.kind) return a.kind === "event" ? -1 : 1; + return a.index - b.index; + }); + + const passthrough = (id: string, kind: "user-bubble" | "assistant-text" | "approval" | "error" | "notice"): void => { + const line = linesById.get(id); + if (!line) return; + blocks.push({ kind, id, line } as AggregatedBlock); + }; + + for (const entry of timeline) { + if (entry.kind === "notice") { + const line = linesById.get(entry.notice.id); + if (!line) continue; + blocks.push({ + kind: line.tone === "error" ? "error" : "notice", + id: entry.notice.id, + line, + }); + continue; + } + const { envelope, index } = entry; + const event = envelope.event; + const id = chatEventLineId(envelope, index); + const turnId = turnIdOf(event); + if (turnId && !turnStart.has(turnId)) turnStart.set(turnId, entry.timestamp); + + if (isSubagentTimelineEvent(event)) { + continue; + } + + if (event.type === "user_message") { + if (event.steerId && event.deliveryState === "queued") { + if (pendingSteerIds.has(event.steerId)) { + blocks.push({ + kind: "queued-steer", + id, + turnId, + steerId: event.steerId, + text: event.displayText ?? event.text, + }); + } + continue; + } + passthrough(id, "user-bubble"); + continue; + } + if (event.type === "text") { + passthrough(id, "assistant-text"); + continue; + } + if (event.type === "reasoning") { + continue; + } + if (event.type === "tool_call" || event.type === "tool_result" || event.type === "command" || event.type === "file_change") { + if (isSubagentChildWorkEvent(event, subagentParentItemIds, subagentChildItemIds)) { + continue; + } + const last = blocks[blocks.length - 1]; + let workBlock: Extract; + if (last && last.kind === "work-block" && last.turnId === turnId) { + workBlock = last; + } else { + workBlock = { kind: "work-block", id, turnId, tools: [], live: true }; + blocks.push(workBlock); + } + appendTool(workBlock, event, envelope); + if ((args.expandedLineIds?.has(id) ?? false) && isExpandedFailureEvent(event)) { + passthrough(id, "error"); + } + continue; + } + if (event.type === "plan") { + const completed = event.steps.filter((step) => step.status === "completed").length; + const inProgress = event.steps.findIndex((step) => step.status === "in_progress"); + const current = inProgress >= 0 ? inProgress + 1 : completed; + const total = event.steps.length; + const stepData: PlanStep[] = event.steps.map((step) => ({ text: step.text, status: step.status })); + const existing = findLastBlock(blocks, "plan", turnId); + if (existing) { + existing.steps = stepData; + existing.current = current; + existing.total = total; + existing.live = true; + continue; + } + blocks.push({ + kind: "plan", + id, + turnId, + steps: stepData, + current, + total, + live: true, + }); + continue; + } + if (event.type === "system_notice" && (event as { noticeKind?: string }).noticeKind === "memory") { + const message = (event as { message?: string }).message ?? ""; + const hitCount = parseMemoryHits(message); + const existing = findLastBlock(blocks, "memory", turnId); + if (existing) { + existing.hitCount = hitCount ?? existing.hitCount; + existing.text = message || existing.text; + existing.live = false; + continue; + } + blocks.push({ + kind: "memory", + id, + turnId, + live: false, + hitCount, + text: message, + }); + continue; + } + if (event.type === "context_compact") { + blocks.push({ + kind: "compaction", + id, + turnId, + trigger: event.trigger, + live: false, + preTokens: event.preTokens, + }); + continue; + } + if (event.type === "codex_context_compaction") { + const existing = findLastBlock(blocks, "compaction", turnId); + if (existing && event.state === "completed") { + existing.live = false; + existing.trigger = event.trigger; + continue; + } + blocks.push({ + kind: "compaction", + id, + turnId, + trigger: event.trigger, + live: event.state === "started", + }); + continue; + } + if (isSteerLifecycleNotice(event)) { + continue; + } + if (event.type === "activity") { + // Activity rows are low-signal transcript metadata; keep the main chat quiet. + continue; + } + if (event.type === "approval_request") { + passthrough(id, "approval"); + continue; + } + if (event.type === "error") { + passthrough(id, "error"); + continue; + } + if (event.type === "status") { + const startMs = turnId ? turnStart.get(turnId) : undefined; + const durationMs = startMs !== undefined ? entry.timestamp - startMs : undefined; + if (event.turnStatus !== "started") { + for (const block of blocks) { + const blockTurn = (block as { turnId?: string | null }).turnId ?? null; + if (blockTurn !== turnId) continue; + if (block.kind === "work-block" || block.kind === "plan" || block.kind === "compaction") { + block.live = false; + if (durationMs !== undefined && block.kind === "work-block") { + block.durationMs = block.durationMs ?? durationMs; + } + } + } + } + if (event.turnStatus === "failed" || event.turnStatus === "interrupted") { + passthrough(id, "error"); + } + continue; + } + if (event.type === "done") { + const startMs = turnId ? turnStart.get(turnId) : undefined; + const durationMs = startMs !== undefined ? entry.timestamp - startMs : undefined; + for (const block of blocks) { + const blockTurn = (block as { turnId?: string | null }).turnId ?? null; + if (blockTurn !== turnId) continue; + if (block.kind === "work-block" || block.kind === "plan" || block.kind === "compaction") { + block.live = false; + if (durationMs !== undefined && block.kind === "work-block") { + block.durationMs = block.durationMs ?? durationMs; + } + } + } + continue; + } + if (SILENCED_EVENT_TYPES.has(event.type)) { + // Already handled above or intentionally skipped (tokens, codex_*). + continue; + } + // Everything else (todo_update, cloud_*, etc.) becomes a notice. + const line = linesById.get(id); + if (!line) continue; + blocks.push({ + kind: line.tone === "error" ? "error" : line.tone === "approval" ? "approval" : "notice", + id, + line, + }); + } + + // Mark assistant-text blocks that follow a heavy work block for top spacing. + for (let index = 1; index < blocks.length; index += 1) { + const current = blocks[index]!; + if (current.kind !== "assistant-text") continue; + const prev = blocks[index - 1]!; + if (prev.kind === "work-block") { + current.precededByHeavy = true; + } + } + + if (args.maxBlocks && blocks.length > args.maxBlocks) { + return blocks.slice(-args.maxBlocks); + } + return blocks; +} diff --git a/apps/ade-cli/src/tuiClient/app.tsx b/apps/ade-cli/src/tuiClient/app.tsx index f4d991e20..55e74ea5a 100644 --- a/apps/ade-cli/src/tuiClient/app.tsx +++ b/apps/ade-cli/src/tuiClient/app.tsx @@ -29,12 +29,16 @@ import type { CodexThreadGoal, } from "../../../desktop/src/shared/types/chat"; import type { AiSettingsStatus, OpenCodeRuntimeSnapshot } from "../../../desktop/src/shared/types/config"; +import type { DiffLineStats } from "../../../desktop/src/shared/types/git"; import type { LaneSummary } from "../../../desktop/src/shared/types/lanes"; import { DEFAULT_CODEX_REASONING_EFFORT, approveToolUse, + cancelSteerMessage, createChatSession, discoverProjectSlashCommands, + dispatchSteerMessage, + editSteerMessage, getAvailableModels, getAiSettingsStatus, getChatHistory, @@ -46,10 +50,12 @@ import { interruptChat, latestGoal, latestTokenStats, + listLaneDiffStats, listClaudePlugins, listClaudeOutputStyles, listChatSessions, listLanes, + listPrsByLane, navigateDesktop, newestSession, renameChat, @@ -57,32 +63,43 @@ import { respondToInput, sendChatMessage, setClaudeOutputStyle, + steerChatMessage, tagChat, updateChatModel, type TokenStats, } from "./adeApi"; +import { derivePendingSteers } from "./aggregate"; import { paletteCommands, parseCommand } from "./commands"; import { connectToAde } from "./connection"; -import { Drawer, visibleDrawerChatCount, visibleDrawerLaneCount } from "./components/Drawer"; -import { ChatView } from "./components/ChatView"; +import { Drawer, visibleDrawerChatCount, visibleDrawerLaneCount, type DrawerPrSummary } from "./components/Drawer"; +import { ChatView, computeChatScrollMaxOffset, renderChatTranscriptPlainText } from "./components/ChatView"; import { Header } from "./components/Header"; import { LANE_DETAIL_ACTIONS, RightPane } from "./components/RightPane"; -import { SlashPalette } from "./components/SlashPalette"; -import { MentionPalette } from "./components/MentionPalette"; +import { SlashPalette, SLASH_PALETTE_ROWS } from "./components/SlashPalette"; +import { MentionPalette, MENTION_PALETTE_ROWS } from "./components/MentionPalette"; import { ApprovalPrompt } from "./components/ApprovalPrompt"; import { ModelStatus } from "./components/ModelStatus"; import { FooterControls } from "./components/FooterControls"; import { theme } from "./theme"; -import { chooseInitialLane } from "./project"; +import { resolveTuiChatRefreshTarget } from "./project"; import { resolveDrawerChatSelection } from "./drawerSelection"; +import { sortLanesForStackGraph } from "./laneTree"; import { latestExpandableFailureId, renderObject, summarizeDiffChanges } from "./format"; import { startTuiHeartbeat, type TuiHeartbeat } from "./heartbeat"; -import { isImageFilePath, latestOpenableImageTarget } from "./imageTargets"; +import { isImageFilePath, latestOpenableImageTarget, readClipboardImageAttachment, readImageDimensions } from "./imageTargets"; import { appendReservedTuiEvent, reserveTuiEventDedupKey, syncTuiEventDedupKeys } from "./eventDedup"; import { loadAdeCodeState, saveAdeCodeState } from "./state"; +import { SpinTickProvider } from "./spinTick"; import { buildLinearToolRequest } from "./linearCommands"; import { buildPendingInputAnswers, latestPendingApproval } from "./pendingInput"; import { claudeHomePath, defaultKeybindingsPath, dispatchKeybinding, openKeybindingsFile, readClaudeKeybindingsFile, type KeybindingDispatchState, type TuiKeybindingAction } from "./keybindings"; +import { + buildSubagentPaneRows, + buildSubagentTranscriptEvents, + clampSubagentSelection, + subagentIndexForPaneLine, + selectedSubagentSnapshot, +} from "./subagentPane"; import { readClaudeStatusLineConfig, runClaudeStatusLineCommand } from "./statusline"; import type { AdeCodeConnection, @@ -102,11 +119,11 @@ import type { const PURPLE = theme.color.accent; const EFFORTS = ["low", "medium", "high", "xhigh", "max"]; const PROVIDER_OPTIONS: Array<{ value: AdeCodeProvider; label: string }> = [ - { value: "codex", label: "Codex" }, { value: "claude", label: "Claude" }, - { value: "opencode", label: "OpenCode" }, + { value: "codex", label: "Codex" }, { value: "cursor", label: "Cursor" }, { value: "droid", label: "Droid" }, + { value: "opencode", label: "OpenCode" }, ]; const PROVIDERS = new Set(PROVIDER_OPTIONS.map((provider) => provider.value)); const CODEX_PRESETS = ["default", "plan", "full-auto", "config-toml"] as const; @@ -115,9 +132,14 @@ const OPENCODE_PERMISSION_OPTIONS = ["plan", "edit", "full-auto"] as const; const DROID_PERMISSION_OPTIONS = ["read-only", "auto-low", "auto-medium", "auto-high"] as const; const SETTINGS_AI_ROUTE = "/settings?tab=ai#ai-providers"; type PaneFocus = "drawer" | "chat" | "details"; -type FooterControl = "drawer" | "details"; +export type FooterControl = "drawer" | "details" | "agents"; type DrawerLaneAction = "new-lane"; type DrawerChatAction = "new-chat"; + +export function footerControlsForAvailability(agentsAvailable: boolean): FooterControl[] { + return agentsAvailable ? ["agents", "drawer", "details"] : ["drawer", "details"]; +} + const DESKTOP_COMMAND_ROUTES: Record = { "/app-control": "/app-control", "/browser": "/browser", @@ -208,6 +230,16 @@ function fallbackModelStatePatch(provider: AdeCodeProvider): Pick ({ + id: descriptor.id, + modelId: descriptor.id, + displayName: descriptor.displayName, + isDefault: descriptor.id === getDefaultModelDescriptor(provider)?.id, + reasoningEfforts: descriptor.reasoningTiers?.map((effort) => ({ effort, description: effort })), + })); +} + function modelReasoningEfforts(modelState: AdeCodeModelState, models: AgentChatModelInfo[]): string[] { if (modelState.provider === "cursor" || modelState.provider === "droid") return []; const model = models.find((entry) => entry.id === modelState.modelId || entry.modelId === modelState.modelId); @@ -290,6 +322,14 @@ function permissionSummary(modelState: AdeCodeModelState): string { return cursorModeLabel(modelState.cursorModeId); } +function permissionOptionsDetail(modelState: AdeCodeModelState): string { + if (modelState.provider === "codex") return CODEX_PRESETS.join(" · "); + if (modelState.provider === "claude") return "default · plan · auto · bypass"; + if (modelState.provider === "opencode") return OPENCODE_PERMISSION_OPTIONS.join(" · "); + if (modelState.provider === "droid") return DROID_PERMISSION_OPTIONS.join(" · "); + return CURSOR_AVAILABLE_MODE_IDS.map((modeId) => cursorModeLabel(modeId)).join(" · "); +} + function applyProviderPermissionMode(modelState: AdeCodeModelState): Partial { if (modelState.provider === "codex") { const preset = resolveCodexPreset(modelState); @@ -394,21 +434,72 @@ function formatContextUsage(usage: AgentChatContextUsage | null): string { .join("\n"); } -function subagentSnapshotsFromEvents(events: AgentChatEventEnvelope[]): SubagentSnapshot[] { +export function subagentSnapshotsFromEvents(events: AgentChatEventEnvelope[]): SubagentSnapshot[] { const snapshots = new Map(); for (const envelope of events) { const event = envelope.event as Record; const type = typeof event.type === "string" ? event.type : ""; - const id = typeof event.taskId === "string" - ? event.taskId - : typeof event.agentId === "string" - ? event.agentId - : null; - if (!id || !type.startsWith("subagent")) continue; - const existing = snapshots.get(id); + + if (type === "teammate.idle") { + const teamName = typeof event.teamName === "string" ? event.teamName : ""; + const teammateName = typeof event.teammateName === "string" ? event.teammateName : ""; + if (!teammateName) continue; + const id = `teammate:${teamName}:${teammateName}`; + const existing = snapshots.get(id); + snapshots.set(id, { + id, + name: teamName ? `${teamName}/${teammateName}` : teammateName, + kind: "teammate", + status: "running", + summary: existing?.summary ?? "idle", + turnId: typeof event.turnId === "string" ? event.turnId : existing?.turnId, + startedAt: existing?.startedAt ?? envelope.timestamp, + tokens: existing?.tokens, + durationMs: existing?.durationMs, + lastToolName: existing?.lastToolName, + }); + continue; + } + + if (type === "task.completed") { + const teamName = typeof event.teamName === "string" ? event.teamName : ""; + const teammateName = typeof event.teammateName === "string" ? event.teammateName : ""; + const subject = typeof event.subject === "string" ? event.subject : ""; + if (!teammateName) continue; + const id = `teammate:${teamName}:${teammateName}`; + const existing = snapshots.get(id); + snapshots.set(id, { + id, + name: existing?.name ?? (teamName ? `${teamName}/${teammateName}` : teammateName), + kind: "teammate", + status: "completed", + summary: subject || existing?.summary || "", + turnId: typeof event.turnId === "string" ? event.turnId : existing?.turnId, + startedAt: existing?.startedAt ?? envelope.timestamp, + endedAt: envelope.timestamp, + tokens: existing?.tokens, + durationMs: existing?.durationMs, + lastToolName: existing?.lastToolName, + }); + continue; + } + + if (!type.startsWith("subagent")) continue; + const taskId = typeof event.taskId === "string" && event.taskId.trim() ? event.taskId.trim() : null; + const agentId = typeof event.agentId === "string" && event.agentId.trim() ? event.agentId.trim() : null; + const id = agentId ?? taskId; + if (!id) continue; + const existing = snapshots.get(id) ?? (taskId ? snapshots.get(taskId) : undefined); + if (taskId && id !== taskId) snapshots.delete(taskId); const agentType = typeof event.agentType === "string" ? event.agentType : "subagent"; const usage = event.usage && typeof event.usage === "object" ? event.usage as Record : {}; - const background = event.background === true || existing?.kind === "background"; + const parentToolUseId = typeof event.parentToolUseId === "string" && event.parentToolUseId.trim() + ? event.parentToolUseId.trim() + : existing?.parentToolUseId ?? null; + const startedAt = existing?.startedAt ?? envelope.timestamp; + const endedAt = type === "subagent_result" || type === "subagent.completed" ? envelope.timestamp : existing?.endedAt; + const parsedDurationMs = endedAt && startedAt ? Date.parse(endedAt) - Date.parse(startedAt) : Number.NaN; + const fallbackDurationMs = Number.isFinite(parsedDurationMs) ? Math.max(0, parsedDurationMs) : existing?.durationMs; const summary = typeof event.summary === "string" ? event.summary : typeof event.finalSummary === "string" @@ -421,11 +512,16 @@ function subagentSnapshotsFromEvents(events: AgentChatEventEnvelope[]): Subagent const base: SubagentSnapshot = { id, name: typeof event.description === "string" ? event.description : existing?.name ?? agentType, - kind: background ? "background" : "subagent", + kind: "subagent", status: existing?.status ?? "running", summary, + parentToolUseId, + turnId: typeof event.turnId === "string" ? event.turnId : existing?.turnId ?? null, + background: event.background === true || existing?.background === true, + startedAt, + endedAt, tokens: typeof usage.totalTokens === "number" ? usage.totalTokens : typeof event.tokens === "number" ? event.tokens : existing?.tokens, - durationMs: typeof usage.durationMs === "number" ? usage.durationMs : existing?.durationMs, + durationMs: typeof usage.durationMs === "number" ? usage.durationMs : fallbackDurationMs, lastToolName: typeof event.lastToolName === "string" ? event.lastToolName : existing?.lastToolName, }; if (type === "subagent_result" || type === "subagent.completed") { @@ -438,6 +534,120 @@ function subagentSnapshotsFromEvents(events: AgentChatEventEnvelope[]): Subagent return [...snapshots.values()]; } +function isLaneWorktreeAvailable(lane: LaneSummary | null | undefined): boolean { + const root = lane?.worktreePath?.trim(); + if (!root) return false; + try { + return fs.statSync(root).isDirectory(); + } catch { + return false; + } +} + +function laneWorktreeUnavailableMessage(lane: LaneSummary | null | undefined): string | null { + if (!lane) return "No active lane is available."; + if (isLaneWorktreeAvailable(lane)) return null; + const pathLabel = lane.worktreePath?.trim() || "unknown path"; + return `Lane "${lane.name}" is missing its worktree at ${pathLabel}. Restore or recreate the lane before starting a chat.`; +} + +function seedLaneDetails( + lane: LaneSummary, + worktreeAvailable = isLaneWorktreeAvailable(lane), + run: NonNullable["run"]> | null = null, +): Extract { + return { + kind: "lane-details", + lane, + git: { staged: 0, unstaged: 0, total: 0, ahead: 0, behind: 0, remote: null, additions: 0, deletions: 0 }, + files: [], + pr: null, + run, + showFiles: false, + selectedActionIndex: 0, + worktreeAvailable, + }; +} + +function latestRunToolSummary(events: AgentChatEventEnvelope[]): string | null { + for (let index = events.length - 1; index >= 0; index -= 1) { + const event = events[index]?.event as Record | undefined; + if (!event) continue; + if (event.type === "command" && typeof event.command === "string") { + return `bash · ${event.command.replace(/\s+/g, " ").trim()}`; + } + if ((event.type === "tool_call" || event.type === "tool_result") && typeof event.tool === "string") { + return event.tool; + } + if (event.type === "file_change" && typeof event.path === "string") { + return `edit · ${event.path}`; + } + } + return null; +} + +function buildLaneRunSummary( + laneId: string, + activeSession: AgentChatSessionSummary | null, + events: AgentChatEventEnvelope[], + tokenSummary: string | null, +): NonNullable["run"]> | null { + if (!activeSession || activeSession.laneId !== laneId) return null; + const running = activeSession.status === "active"; + const elapsedFrom = running ? activeSession.startedAt : activeSession.lastActivityAt; + const elapsedMs = Number.isFinite(Date.parse(elapsedFrom)) ? Date.now() - Date.parse(elapsedFrom) : null; + return { + status: running ? "running" : "idle", + provider: normalizeProvider(activeSession.provider), + elapsedMs, + tokenSummary, + toolSummary: latestRunToolSummary(events) ?? activeSession.lastOutputPreview, + }; +} + +type ContextDefaultArgs = { + draftChatActive: boolean; + activeSession: AgentChatSessionSummary | null; + activeLane: LaneSummary | null; + liveAgentCount: number; + highlightedDrawerLane: LaneSummary | null; + drawerMode: "chats" | "lanes"; + subagentSnapshots: SubagentSnapshot[]; + provider: AdeCodeProvider; + newChatSetup: { laneId: string; laneLabel: string; rows: SetupPaneRow[] } | null; + unavailableLaneIds: ReadonlySet; +}; + +function resolveContextDefault(args: ContextDefaultArgs): RightPaneContent { + if (args.drawerMode === "lanes" && args.highlightedDrawerLane) { + return seedLaneDetails(args.highlightedDrawerLane, !args.unavailableLaneIds.has(args.highlightedDrawerLane.id)); + } + if ( + args.draftChatActive + && args.newChatSetup + && !args.unavailableLaneIds.has(args.newChatSetup.laneId) + ) { + return { + kind: "new-chat-setup", + laneId: args.newChatSetup.laneId, + laneLabel: args.newChatSetup.laneLabel, + rows: args.newChatSetup.rows, + }; + } + if (args.activeSession && args.liveAgentCount > 0) { + return { + kind: "subagents", + tab: "subagents", + snapshots: args.subagentSnapshots, + provider: args.provider, + }; + } + if (args.activeLane) { + return seedLaneDetails(args.activeLane, !args.unavailableLaneIds.has(args.activeLane.id)); + } + return { kind: "empty" }; +} + function formatOutputStyles(styles: Awaited>, activeStyle?: string | null): string { if (!styles.length) return "No Claude output styles were found."; const activeKey = activeStyle?.trim().toLowerCase() ?? ""; @@ -589,24 +799,6 @@ function commandAvailable(command: string): boolean { return result.status === 0; } -function clipboardImageTarget(workspaceRoot: string, extension = "png"): string { - const dir = path.join(workspaceRoot, ".ade", "cache", "ade-code-clipboard"); - fs.mkdirSync(dir, { recursive: true }); - return path.join(dir, `clipboard-${Date.now()}.${extension}`); -} - -function powershellQuoted(value: string): string { - return `'${value.replace(/'/g, "''")}'`; -} - -function nonEmptyFile(filePath: string): boolean { - try { - return fs.statSync(filePath).size > 0; - } catch { - return false; - } -} - function readClipboardText(): string | null { const candidates = process.platform === "darwin" ? [["pbpaste"]] @@ -621,50 +813,22 @@ function readClipboardText(): string | null { return null; } -function readClipboardImageAttachment(workspaceRoot: string): AgentChatFileRef | null { - if (process.platform === "darwin" && commandAvailable("pngpaste")) { - const target = clipboardImageTarget(workspaceRoot); - const result = spawnSync("pngpaste", [target], { stdio: "ignore" }); - if (result.status === 0 && nonEmptyFile(target)) return { path: target, type: "image" }; - } - if (process.platform === "darwin" && commandAvailable("pbpaste")) { - const target = clipboardImageTarget(workspaceRoot); - const result = spawnSync("pbpaste", ["-Prefer", "image"], { encoding: "buffer", maxBuffer: 30 * 1024 * 1024 }); - if (result.status === 0 && result.stdout.length) { - fs.writeFileSync(target, result.stdout); - if (nonEmptyFile(target)) return { path: target, type: "image" }; - } - } - if (process.platform === "win32" && commandAvailable("powershell")) { - const target = clipboardImageTarget(workspaceRoot); - const command = [ - "Add-Type -AssemblyName System.Windows.Forms;", - "Add-Type -AssemblyName System.Drawing;", - "$image = [System.Windows.Forms.Clipboard]::GetImage();", - `if ($image -ne $null) { $image.Save(${powershellQuoted(target)}, [System.Drawing.Imaging.ImageFormat]::Png) }`, - ].join(" "); - const result = spawnSync("powershell", ["-NoProfile", "-Command", command], { stdio: "ignore" }); - if (result.status === 0 && nonEmptyFile(target)) return { path: target, type: "image" }; - } - if (process.platform === "linux") { - const target = clipboardImageTarget(workspaceRoot); - const commands = commandAvailable("wl-paste") - ? [["wl-paste", "-t", "image/png"]] - : commandAvailable("xclip") - ? [["xclip", "-selection", "clipboard", "-t", "image/png", "-o"]] - : []; - for (const [command, ...args] of commands) { - const result = spawnSync(command, args, { encoding: "buffer", maxBuffer: 30 * 1024 * 1024 }); - if (result.status === 0 && result.stdout.length) { - fs.writeFileSync(target, result.stdout); - if (nonEmptyFile(target)) return { path: target, type: "image" }; - } - } +function writeClipboardText(text: string): boolean { + const candidates = process.platform === "darwin" + ? [["pbcopy"]] + : process.platform === "win32" + ? [["clip"]] + : [["wl-copy"], ["xclip", "-selection", "clipboard"]]; + for (const [command, ...args] of candidates) { + if (!commandAvailable(command)) continue; + const result = spawnSync(command, args, { + input: text, + encoding: "utf8", + maxBuffer: 1024 * 1024, + }); + if (result.status === 0) return true; } - const clipboardText = readClipboardText(); - const clipboardPath = clipboardText?.split(/\r?\n/).map((line) => line.trim()).find((line) => line && fs.existsSync(line)); - if (clipboardPath && isImageFilePath(clipboardPath)) return { path: clipboardPath, type: "image" }; - return null; + return false; } function editPromptInExternalEditor(initialText: string): string | null { @@ -720,6 +884,8 @@ function buildSetupRows(args: { models: AgentChatModelInfo[]; includeRefresh: boolean; includeApply: boolean; + outputStyle?: string | null; + outputStyleEditable?: boolean; }): SetupPaneRow[] { const efforts = modelReasoningEfforts(args.modelState, args.models); const descriptor = args.modelState.modelId ? getModelById(args.modelState.modelId) : undefined; @@ -750,21 +916,27 @@ function buildSetupRows(args: { kind: "permission", label: "Permissions", value: permissionSummary(args.modelState), - detail: args.modelState.provider === "codex" - ? `${args.modelState.codexApprovalPolicy} / ${args.modelState.codexSandbox}` - : args.modelState.provider === "cursor" - ? "Cursor mode" - : "provider native", + detail: permissionOptionsDetail(args.modelState), cyclable: true, }, ]; - if (args.modelState.provider === "codex") { + rows.push({ + kind: "codex-fast", + label: "Fast mode", + value: fastSupported && args.modelState.codexFastMode ? "on" : "off", + detail: "on · off", + disabled: !fastSupported, + cyclable: true, + }); + if (args.modelState.provider === "claude") { rows.push({ - kind: "codex-fast", - label: "Fast mode", - value: fastSupported ? (args.modelState.codexFastMode ? "on" : "off") : "unsupported", - detail: "Codex service tier", - disabled: !fastSupported, + kind: "output-style", + label: "Output style", + value: args.outputStyle?.trim() || "default", + detail: args.outputStyleEditable === false + ? "active Claude chat only" + : "default · concise · verbose", + disabled: args.outputStyleEditable === false, cyclable: true, }); } @@ -785,7 +957,7 @@ function buildSetupRows(args: { if (args.includeApply) { rows.push({ kind: "apply", - label: "Use this setup", + label: "Use these settings", value: "ready", detail: "returns focus to the chat composer", }); @@ -805,6 +977,11 @@ function setupRowsForRuntime(rows: SetupPaneRow[], mode: RuntimeMode | "connecti : row); } +function defaultSetupSelectionIndex(rows: SetupPaneRow[]): number { + const applyIndex = rows.findIndex((row) => row.kind === "apply"); + return applyIndex >= 0 ? applyIndex : 0; +} + function providerConnectionDetail(status: AiSettingsStatus | null, provider: Exclude): ProviderReadinessRow { const connection = status?.providerConnections?.[provider]; const modelCount = status?.models?.[provider]?.length ?? 0; @@ -937,6 +1114,21 @@ function printableInput(input: string): string { return input.replace(/[\u0000-\u001f\u007f]/g, ""); } +export function deletePreviousPromptWord(value: string): string { + let index = value.length; + while (index > 0 && /\s/.test(value[index - 1] ?? "")) index -= 1; + while (index > 0 && !/\s/.test(value[index - 1] ?? "")) index -= 1; + return value.slice(0, index); +} + +export function isPromptWordBackspace(input: string, key: { ctrl?: boolean; meta?: boolean; backspace?: boolean; delete?: boolean }): boolean { + if (key.ctrl && input === "w") return true; + if ((key.ctrl || key.meta) && (key.backspace || key.delete)) return true; + if (key.meta && (input === "\u007f" || input === "\b" || input === "\x1b\u007f" || input === "\x1b\b")) return true; + if (key.ctrl && (input === "\u007f" || input === "\b" || input === "h")) return true; + return false; +} + function inputBeforeLineBreak(input: string): string | null { const index = input.search(/[\r\n]/); return index === -1 ? null : input.slice(0, index); @@ -1016,12 +1208,121 @@ function useTerminalDimensions(): [number, number] { return dimensions; } +function useTerminalAlternateScroll(): void { + useEffect(() => { + if (!process.stdin.isTTY || !process.stdout.isTTY) return; + process.stdout.write("\x1b[?1007h"); + return () => { + process.stdout.write("\x1b[?1007l"); + }; + }, []); +} + +type TerminalMouseInput = { + kind: "wheel" | "click" | "other"; + x: number | null; + y: number | null; + direction?: "up" | "down" | "left" | "right"; +}; + +function decodeMouseButton(code: number, x: number | null, y: number | null, pressed: boolean): TerminalMouseInput { + if (!pressed) { + return { kind: "other", x, y }; + } + if (!(code & 64) && (code & 3) === 0) return { kind: "click", x, y }; + if (!(code & 64)) return { kind: "other", x, y }; + const wheelButton = code & 3; + if (wheelButton === 0) return { kind: "wheel", direction: "up", x, y }; + if (wheelButton === 1) return { kind: "wheel", direction: "down", x, y }; + if (wheelButton === 2) return { kind: "wheel", direction: "left", x, y }; + return { kind: "wheel", direction: "right", x, y }; +} + +export function parseTerminalMouseInput(input: string): TerminalMouseInput | null { + const normalized = input.replace(/^\x1b+/, ""); + const sgr = normalized.match(/\[<(\d+);(\d+);(\d+)([mM])/); + if (sgr) { + return decodeMouseButton( + Number(sgr[1]), + Number(sgr[2]), + Number(sgr[3]), + sgr[4] === "M", + ); + } + const rxvt = normalized.match(/\[(\d+);(\d+);(\d+)M/); + if (rxvt) { + return decodeMouseButton( + Number(rxvt[1]), + Number(rxvt[2]), + Number(rxvt[3]), + true, + ); + } + const x10Index = normalized.indexOf("[M"); + if (x10Index !== -1 && normalized.length >= x10Index + 5) { + return decodeMouseButton( + normalized.charCodeAt(x10Index + 2) - 32, + normalized.charCodeAt(x10Index + 3) - 32, + normalized.charCodeAt(x10Index + 4) - 32, + true, + ); + } + return null; +} + +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; + if (!Number.isFinite(value)) return value > 0 ? safeMax : 0; + return Math.max(0, Math.min(Math.floor(value), safeMax)); +} + +function useTerminalMouseTracking(): void { + useEffect(() => { + if (!process.stdin.isTTY || !process.stdout.isTTY) return; + const disabled = /^(0|false|no|off)$/i.test(process.env.ADE_TUI_MOUSE ?? ""); + if (disabled) return; + process.stdout.write("\x1b[?1000h\x1b[?1002h\x1b[?1006h\x1b[?1015h"); + return () => { + process.stdout.write("\x1b[?1015l\x1b[?1006l\x1b[?1002l\x1b[?1000l"); + }; + }, []); +} + +const DRAWER_PANE_WIDTH = 32; +const MIN_CENTER_PANE_WIDTH = 24; +const MIN_RIGHT_PANE_WIDTH = 30; +const RIGHT_PANE_MAX_WIDTH = 42; + +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 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; + return Math.max( + MIN_RIGHT_PANE_WIDTH, + Math.min(maxWidth, Math.floor(columns * widthFraction), maxRightWidth), + ); +} + +function resolveCenterPaneWidth(columns: number, drawerOpen: boolean, rightPaneWidth: number): number { + return Math.max( + MIN_CENTER_PANE_WIDTH, + columns - (drawerOpen ? DRAWER_PANE_WIDTH : 0) - rightPaneWidth, + ); +} + export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath }: AdeCodeAppProps) { const { exit } = useApp(); const [columns, rows] = useTerminalDimensions(); + useTerminalAlternateScroll(); + useTerminalMouseTracking(); const [connection, setConnection] = useState(null); const [mode, setMode] = useState("connecting"); const [lanes, setLanes] = useState([]); + const [prByLaneId, setPrByLaneId] = useState>({}); + const [diffByLaneId, setDiffByLaneId] = useState>({}); const [sessions, setSessions] = useState([]); const [activeLaneId, setActiveLaneId] = useState(null); const [activeSessionId, setActiveSessionId] = useState(null); @@ -1083,19 +1384,31 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } const promptHistoryRef = useRef([]); const promptHistoryIndexRef = useRef(null); const promptHistoryDraftRef = useRef(""); + const rightPaneKindRef = useRef("empty"); const lastLocalSendAtRef = useRef(0); const eventCountRef = useRef(0); const eventDedupKeysRef = useRef>(new Set()); const eventDedupKeyOrderRef = useRef([]); const chatScrollOffsetRowsRef = useRef(0); + const chatScrollMaxOffsetRef = useRef(0); + const newChatPreviewLaneIdRef = useRef(null); const heartbeatRef = useRef(null); const draftSeededFromHistoryRef = useRef(false); + const initialNewChatPreviewRef = useRef(true); const attachProbeInFlightRef = useRef(false); - const lastChatByLaneRef = useRef>(new Map(Object.entries(loadAdeCodeState().lastChatByLane))); + const [initialAdeCodeState] = useState(loadAdeCodeState); + const lastChatByLaneRef = useRef>(new Map(Object.entries(initialAdeCodeState.lastChatByLane))); + const lastLaneIdRef = useRef(initialAdeCodeState.lastLaneId); const lastChatByLaneWriteTimerRef = useRef(null); const pendingNewChatTitleRef = useRef(null); - - const persistLastChatByLane = useCallback(() => { + const lastUserOpenedPaneRef = useRef(null); + const activeSessionRef = useRef(null); + const modelStateRef = useRef(initialModelState()); + const providerModelsCacheRef = useRef>(new Map()); + const pendingModelCommitTimerRef = useRef(null); + const pendingModelCommitStateRef = useRef(null); + + const persistAdeCodeState = useCallback(() => { if (lastChatByLaneWriteTimerRef.current) { clearTimeout(lastChatByLaneWriteTimerRef.current); } @@ -1105,42 +1418,65 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } for (const [laneId, sessionId] of lastChatByLaneRef.current) { lastChatByLane[laneId] = sessionId; } - saveAdeCodeState({ lastChatByLane }); + saveAdeCodeState({ lastChatByLane, lastLaneId: lastLaneIdRef.current }); }, 500); }, []); const setChatScrollOffset = useCallback((value: number | ((previous: number) => number)) => { setChatScrollOffsetRows((previous) => { - const next = Math.max(0, typeof value === "function" ? value(previous) : value); + const raw = typeof value === "function" ? value(previous) : value; + const next = clampChatScrollOffsetRows(raw, chatScrollMaxOffsetRef.current); chatScrollOffsetRowsRef.current = next; return next; }); }, []); + const clearTranscriptPreview = useCallback(() => { + eventDedupKeysRef.current.clear(); + eventDedupKeyOrderRef.current = []; + eventCountRef.current = 0; + setEvents([]); + setClearedAt(null); + setCurrentGoal(null); + setContextPercent(null); + setTokenSummary(null); + setStatusLineStats(null); + setStreaming(false); + }, []); + const selectActiveLaneId = useCallback((laneId: string | null) => { if (activeLaneIdRef.current !== laneId) setChatScrollOffset(0); activeLaneIdRef.current = laneId; setActiveLaneId(laneId); - }, [setChatScrollOffset]); + if (laneId && lastLaneIdRef.current !== laneId) { + lastLaneIdRef.current = laneId; + persistAdeCodeState(); + } + }, [persistAdeCodeState, setChatScrollOffset]); const selectActiveSessionId = useCallback((sessionId: string | null) => { if (activeSessionIdRef.current !== sessionId) { setChatScrollOffset(0); setCurrentGoal(null); + lastUserOpenedPaneRef.current = null; + } + if (!sessionId) { + clearTranscriptPreview(); } if (sessionId) { + newChatPreviewLaneIdRef.current = null; draftChatActiveRef.current = false; setDraftChatActive(false); setSelectedDrawerChatAction(null); const laneId = activeLaneIdRef.current; if (laneId && lastChatByLaneRef.current.get(laneId) !== sessionId) { lastChatByLaneRef.current.set(laneId, sessionId); - persistLastChatByLane(); + persistAdeCodeState(); } } activeSessionIdRef.current = sessionId; setActiveSessionId(sessionId); - }, [persistLastChatByLane, setChatScrollOffset]); + }, [clearTranscriptPreview, persistAdeCodeState, setChatScrollOffset]); const setDraftChatMode = useCallback((active: boolean) => { setChatScrollOffset(0); @@ -1261,6 +1597,17 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } () => lanes.find((lane) => lane.id === activeLaneId) ?? null, [activeLaneId, lanes], ); + const unavailableLaneIds = useMemo(() => { + const ids = new Set(); + for (const lane of lanes) { + if (!isLaneWorktreeAvailable(lane)) ids.add(lane.id); + } + return ids; + }, [lanes]); + const drawerLane = useMemo( + () => lanes.find((lane) => lane.id === drawerLaneId) ?? null, + [drawerLaneId, lanes], + ); const activeSession = useMemo( () => sessions.find((session) => session.sessionId === activeSessionId) ?? null, [activeSessionId, sessions], @@ -1268,6 +1615,98 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } const activeCommandProvider = activeSession?.provider ?? modelState.provider; const latestFailedLineId = useMemo(() => latestExpandableFailureId(events), [events]); const subagentSnapshots = useMemo(() => subagentSnapshotsFromEvents(events), [events]); + const liveAgentCount = useMemo( + () => subagentSnapshots.filter((snap) => snap.status === "running").length, + [subagentSnapshots], + ); + const subagentPaneAvailable = Boolean(activeSession && !draftChatActive && subagentSnapshots.length > 0); + const footerControls = useMemo( + () => footerControlsForAvailability(subagentPaneAvailable), + [subagentPaneAvailable], + ); + const cycleFooterControl = useCallback((direction: 1 | -1) => { + const controls: FooterControl[] = footerControls.length ? footerControls : ["drawer", "details"]; + const current = footerControlRef.current; + const currentIndex = current ? controls.indexOf(current) : -1; + const startIndex = currentIndex >= 0 ? currentIndex : direction > 0 ? -1 : 0; + const nextIndex = (startIndex + direction + controls.length) % controls.length; + selectFooterControl(controls[nextIndex] ?? "drawer"); + }, [footerControls, selectFooterControl]); + useEffect(() => { + if (footerControl === "agents" && !subagentPaneAvailable) { + selectFooterControl(null); + } + }, [footerControl, selectFooterControl, subagentPaneAvailable]); + useEffect(() => { + if (rightPaneKindRef.current !== rightPane.kind) { + if (rightPane.kind === "subagents") { + setRightSelectionIndex(0); + } + rightPaneKindRef.current = rightPane.kind; + } + }, [rightPane.kind]); + useEffect(() => { + if (subagentPaneAvailable || rightPane.kind !== "subagents") return; + lastUserOpenedPaneRef.current = null; + setRightPane({ kind: "empty" }); + setRightOpen(false); + if (activePaneRef.current === "details") { + focusChat(); + } + }, [focusChat, rightPane.kind, subagentPaneAvailable]); + useEffect(() => { + if (rightPane.kind !== "subagents") return; + setRightSelectionIndex((index) => clampSubagentSelection(rightPane, index)); + }, [rightPane]); + const openSubagentsPane = useCallback((): boolean => { + if (!subagentPaneAvailable) return false; + const previousPane = activePaneRef.current; + stashActiveInput(); + selectFooterControl(null); + if (previousPane !== "details") { + paneBeforeDetailsRef.current = previousPane; + } + setFormDiscardArmed(false); + setPrompt(""); + setRightPane({ + kind: "subagents", + tab: "subagents", + snapshots: subagentSnapshots, + provider: (activeSession?.provider ?? modelState.provider) as AdeCodeProvider, + }); + setRightSelectionIndex(0); + setRightOpen(true); + setPaneFocus("details"); + return true; + }, [ + activeSession?.provider, + modelState.provider, + selectFooterControl, + setPaneFocus, + stashActiveInput, + subagentPaneAvailable, + subagentSnapshots, + ]); + const toggleSubagentsPane = useCallback((): boolean => { + if (!subagentPaneAvailable) return true; + selectFooterControl(null); + if (rightOpen && rightPane.kind === "subagents") { + setRightOpen(false); + lastUserOpenedPaneRef.current = null; + focusChat(); + return true; + } + lastUserOpenedPaneRef.current = "subagents"; + openSubagentsPane(); + return true; + }, [ + focusChat, + openSubagentsPane, + rightOpen, + rightPane.kind, + selectFooterControl, + subagentPaneAvailable, + ]); const promptHistory = useMemo(() => events .map((envelope) => envelope.event) .filter((event): event is Extract => event.type === "user_message") @@ -1282,9 +1721,17 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } setVimModeEnabled(readClaudeVimMode(project.workspaceRoot)); setVimMode("insert"); }, [project.workspaceRoot]); + const orderedDrawerLanes = useMemo( + () => sortLanesForStackGraph(lanes), + [lanes], + ); const drawerLaneRows = useMemo( - () => lanes.slice(0, visibleDrawerLaneCount(rows, lanes.length)), - [lanes, rows], + () => orderedDrawerLanes.slice(0, visibleDrawerLaneCount(rows, orderedDrawerLanes.length)), + [orderedDrawerLanes, rows], + ); + const diffLaneIdsKey = useMemo( + () => lanes.filter((lane) => !lane.archivedAt).map((lane) => lane.id).sort().join("\n"), + [lanes], ); const drawerLaneSessions = useMemo( () => sessions.filter((session) => session.laneId === drawerLaneId), @@ -1316,6 +1763,7 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } : [] ), [activeCommandProvider, activePane, prompt, slashCommands]); const pendingApproval = useMemo(() => latestPendingApproval(events), [events]); + const pendingSteers = useMemo(() => derivePendingSteers(events), [events]); const goalBannerText = useMemo(() => formatGoalBannerLine(currentGoal), [currentGoal]); const activeFormField = rightPane.kind === "form" ? rightPane.fields[formFieldIndex] ?? rightPane.fields[0] ?? null @@ -1323,18 +1771,58 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } const statusLineRows = statusLineText ? Math.min(3, statusLineText.split(/\r?\n/).filter(Boolean).length || 1) : 0; const statusRows = statusLineRows; const goalBannerRows = goalBannerText ? 1 : 0; - const chatRowBudget = Math.max(4, rows - 12 - statusRows - goalBannerRows); + const chatRowBudget = Math.max(4, rows - 8 - statusRows - goalBannerRows); + const rightPaneMaxWidth = rightPane.kind === "model-setup" ? 88 : RIGHT_PANE_MAX_WIDTH; + const rightPaneWidth = resolveRightPaneWidth(columns, rightOpen, drawerOpen, rightPaneMaxWidth); + const centerWidth = resolveCenterPaneWidth(columns, drawerOpen, rightPaneWidth); + const selectedAgentSnapshot = useMemo(() => { + if (!rightOpen || activePane !== "details" || rightPane.kind !== "subagents") return null; + return selectedSubagentSnapshot(rightPane, rightSelectionIndex); + }, [activePane, rightOpen, rightPane, rightSelectionIndex]); + const displayEvents = useMemo(() => ( + selectedAgentSnapshot + ? buildSubagentTranscriptEvents({ events, activeSession, snapshot: selectedAgentSnapshot }) + : events + ), [activeSession, events, selectedAgentSnapshot]); + const displayNotices = useMemo(() => (selectedAgentSnapshot ? [] : notices), [notices, selectedAgentSnapshot]); + const displayStreaming = selectedAgentSnapshot ? selectedAgentSnapshot.status === "running" : streaming; + const chatScrollMaxOffset = useMemo(() => computeChatScrollMaxOffset({ + events: displayEvents, + notices: displayNotices, + activeSession, + expandedLineIds, + maxRows: chatRowBudget, + streaming: displayStreaming, + width: centerWidth, + }), [activeSession, centerWidth, chatRowBudget, displayEvents, displayNotices, displayStreaming, expandedLineIds]); + chatScrollMaxOffsetRef.current = chatScrollMaxOffset; + const effectiveChatScrollOffsetRows = clampChatScrollOffsetRows(chatScrollOffsetRows, chatScrollMaxOffset); + chatScrollOffsetRowsRef.current = effectiveChatScrollOffsetRows; const providerReadinessRows = useMemo( () => buildProviderReadinessRows(aiStatus, storedApiKeyProviders, openCodeDiagnostics), [aiStatus, openCodeDiagnostics, storedApiKeyProviders], ); const newChatSetupRows = useMemo( - () => setupRowsForRuntime(buildSetupRows({ modelState, models, includeRefresh: false, includeApply: true }), mode), + () => setupRowsForRuntime(buildSetupRows({ + modelState, + models, + includeRefresh: false, + includeApply: true, + outputStyle: "default", + outputStyleEditable: false, + }), mode), [mode, modelState, models], ); const modelSetupRows = useMemo( - () => setupRowsForRuntime(buildSetupRows({ modelState, models, includeRefresh: true, includeApply: false }), mode), - [mode, modelState, models], + () => setupRowsForRuntime(buildSetupRows({ + modelState, + models, + includeRefresh: true, + includeApply: false, + outputStyle: activeSession?.claudeOutputStyle ?? "default", + outputStyleEditable: Boolean(activeSession?.sessionId && activeSession.provider === "claude"), + }), mode), + [activeSession?.claudeOutputStyle, activeSession?.provider, activeSession?.sessionId, mode, modelState, models], ); useEffect(() => { @@ -1345,6 +1833,92 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } activeSessionIdRef.current = activeSessionId; }, [activeSessionId]); + useEffect(() => { + activeSessionRef.current = activeSession; + }, [activeSession]); + + useEffect(() => { + modelStateRef.current = modelState; + }, [modelState]); + + useEffect(() => { + chatScrollMaxOffsetRef.current = chatScrollMaxOffset; + setChatScrollOffsetRows((previous) => { + const next = clampChatScrollOffsetRows(previous, chatScrollMaxOffset); + chatScrollOffsetRowsRef.current = next; + return next; + }); + }, [chatScrollMaxOffset]); + + // Context-aware default for the right pane. Runs whenever one of the inputs + // changes — but leaves the pane alone while a slash command (sticky) or any + // other non-default content is showing. The sticky marker is cleared on chat + // switch (in selectActiveSessionId) and on explicit close (Esc / pane:close). + const highlightedDrawerLane = useMemo(() => { + if (drawerSection !== "lanes") return null; + const id = selectedDrawerLaneId ?? drawerLaneId ?? activeLaneId; + if (!id) return null; + return lanes.find((lane) => lane.id === id) ?? null; + }, [activeLaneId, drawerLaneId, drawerSection, lanes, selectedDrawerLaneId]); + + useEffect(() => { + // If the user explicitly opened a pane via a slash command, leave it alone. + if (lastUserOpenedPaneRef.current !== null) return; + // Form panes (rename, new-lane, pr-open) are user-driven; never overwrite. + if (rightPane.kind === "form") return; + const next = resolveContextDefault({ + draftChatActive: draftChatActiveRef.current, + activeSession, + activeLane, + liveAgentCount, + highlightedDrawerLane, + drawerMode: drawerSection, + subagentSnapshots, + provider: (activeSession?.provider ?? modelState.provider) as AdeCodeProvider, + unavailableLaneIds, + newChatSetup: (drawerLaneId ?? activeLaneId) + ? { + laneId: drawerLaneId ?? activeLaneId!, + laneLabel: drawerLane?.name ?? activeLane?.name ?? drawerLaneId ?? activeLaneId!, + rows: newChatSetupRows, + } + : null, + }); + setRightPane((prev) => { + // Avoid stomping on lane-details that has been hydrated with git data; + // only refresh when the lane reference itself changed. + if ( + prev.kind === "lane-details" + && next.kind === "lane-details" + && prev.lane.id === next.lane.id + && prev.worktreeAvailable === next.worktreeAvailable + ) { + return prev; + } + // Avoid replacing a populated subagents view with an empty seed. + if (prev.kind === "subagents" && next.kind === "subagents") return prev; + if (prev.kind === next.kind && next.kind === "empty") return prev; + if (prev.kind === "new-chat-setup" && next.kind === "new-chat-setup" && prev.laneId === next.laneId) return prev; + return next; + }); + }, [ + activeLane, + activeLaneId, + activeSession, + draftChatActive, + drawerLane, + drawerLaneId, + drawerSection, + highlightedDrawerLane, + liveAgentCount, + modelState.provider, + newChatSetupRows, + rightPane.kind, + selectedDrawerChatAction, + subagentSnapshots, + unavailableLaneIds, + ]); + useEffect(() => { if (rightPane.kind === "new-chat-setup") { setRightPane((prev) => prev.kind === "new-chat-setup" @@ -1367,9 +1941,19 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } } : prev); } else if (rightPane.kind === "subagents") { - setRightPane((prev) => prev.kind === "subagents" ? { ...prev, snapshots: subagentSnapshots } : prev); + const provider = activeSession?.provider ?? modelState.provider; + setRightPane((prev) => prev.kind === "subagents" + ? { ...prev, snapshots: subagentSnapshots, provider: provider as AdeCodeProvider } + : prev); + } else if (rightPane.kind === "lane-details") { + setRightPane((prev) => prev.kind === "lane-details" + ? { + ...prev, + run: buildLaneRunSummary(prev.lane.id, activeSession, events, tokenSummary), + } + : prev); } - }, [activeLane?.name, activeLaneId, aiStatusCheckedAt, mode, modelSetupRows, modelState.provider, newChatSetupRows, providerReadinessRows, rightPane.kind, subagentSnapshots]); + }, [activeLane?.name, activeLaneId, activeSession, aiStatusCheckedAt, events, mode, modelSetupRows, modelState.provider, newChatSetupRows, providerReadinessRows, rightPane.kind, subagentSnapshots, tokenSummary]); useEffect(() => { const { config } = readClaudeStatusLineConfig(project.workspaceRoot); @@ -1497,14 +2081,18 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } }; }, [activeLane?.branchRef, activeLane?.name, activeLaneId, activeSession?.claudeOutputStyle, activeSession?.reasoningEffort, activeSession?.sessionId, activeSession?.title, activeSessionId, contextPercent, modelState, models, project.projectRoot, project.workspaceRoot, statusLineStats, tokenSummary, vimMode, vimModeEnabled]); + const rightPaneLaneId = rightPane.kind === "lane-details" ? rightPane.lane.id : null; + useEffect(() => { - if (activePane !== "details" || !rightOpen) return; - if (!activeLane || !activeLaneId) return; + if (!rightOpen) return; if (rightPane.kind !== "empty" && rightPane.kind !== "lane-details") return; + const lane = rightPane.kind === "lane-details" + ? lanes.find((candidate) => candidate.id === rightPane.lane.id) ?? rightPane.lane + : highlightedDrawerLane ?? activeLane; + if (!lane) return; let cancelled = false; - const lane = activeLane; - const laneId = activeLaneId; + const laneId = lane.id; const refresh = async () => { const conn = connectionRef.current; @@ -1539,6 +2127,7 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } } } const files = [...fileMap.values()]; + const laneDiffStats = diffByLaneId[laneId]; const activePr = prsRes[0] ?? null; let pr: { number: number; state: "open" | "closed" | "merged"; url: string; checksPassed: number; checksTotal: number } | null = null; @@ -1575,6 +2164,7 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } setRightPane((prev) => { if (cancelled) return prev; if (prev.kind !== "lane-details" && prev.kind !== "empty") return prev; + if (prev.kind === "lane-details" && prev.lane.id !== laneId) return prev; const previousIndex = prev.kind === "lane-details" ? prev.selectedActionIndex : 0; const previousShowFiles = prev.kind === "lane-details" ? prev.showFiles : false; const maxIndex = LANE_DETAIL_ACTIONS.length - 1 + (pr ? 1 : 0); @@ -1584,15 +2174,19 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } git: { staged: staged.length, unstaged: unstaged.length, - total: files.length, + total: laneDiffStats?.files ?? files.length, ahead, behind, remote, + additions: laneDiffStats?.additions ?? 0, + deletions: laneDiffStats?.deletions ?? 0, }, files, pr, + run: prev.kind === "lane-details" ? prev.run ?? null : null, showFiles: previousShowFiles, selectedActionIndex: Math.max(0, Math.min(previousIndex, maxIndex)), + worktreeAvailable: !unavailableLaneIds.has(lane.id), }; }); } catch { @@ -1608,7 +2202,7 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } cancelled = true; clearInterval(interval); }; - }, [activeLane, activeLaneId, activePane, rightOpen, rightPane.kind]); + }, [activeLane, diffByLaneId, highlightedDrawerLane, lanes, rightOpen, rightPane.kind, rightPaneLaneId, unavailableLaneIds]); useEffect(() => { if (!drawerLaneId || !lanes.some((lane) => lane.id === drawerLaneId)) { @@ -1702,11 +2296,18 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } setAiStatusCheckedAt(new Date().toISOString()); }, []); - const loadProviderModels = useCallback(async (provider: AdeCodeProvider, options: { applyDefault?: boolean } = {}) => { + const loadProviderModels = useCallback(async (provider: AdeCodeProvider, options: { applyDefault?: boolean; force?: boolean } = {}) => { const conn = connectionRef.current; - const nextModels = conn - ? await getAvailableModels(conn, provider).catch(() => []) - : []; + const cached = providerModelsCacheRef.current.get(provider); + let nextModels = cached ?? registryModelsForProvider(provider); + if (options.force === true || !cached) { + try { + nextModels = conn ? await getAvailableModels(conn, provider) : registryModelsForProvider(provider); + providerModelsCacheRef.current.set(provider, nextModels); + } catch { + nextModels = cached ?? registryModelsForProvider(provider); + } + } setModels(nextModels); if (options.applyDefault !== false) { const model = nextModels.find((entry) => entry.isDefault) ?? nextModels[0] ?? null; @@ -1731,6 +2332,9 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } setPrompt(content.fields[0]?.initialValue ?? ""); setRightPane(content); setRightOpen(true); + // Forms are explicit user actions; mark sticky so the context default + // resolver doesn't overwrite them. + lastUserOpenedPaneRef.current = "form"; setPaneFocus("details"); }, [setPaneFocus, stashActiveInput]); @@ -1747,13 +2351,27 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } }, [openForm]); const openNewChatSetup = useCallback((title?: string | null) => { - if (!activeLaneIdRef.current) { + const laneId = activeLaneIdRef.current; + const lane = lanes.find((entry) => entry.id === laneId) ?? activeLane; + if (!laneId || !lane) { setRightPane({ kind: "details", title: "New chat", body: "No active lane is available." }); focusDetails(); return; } + const unavailableMessage = laneWorktreeUnavailableMessage(lane); + if (unavailableMessage) { + setDraftChatMode(false); + selectActiveSessionId(null); + setSelectedDrawerChatId(null); + setSelectedDrawerChatAction(null); + setRightPane(seedLaneDetails(lane, false)); + setRightOpen(true); + addNotice(unavailableMessage, "error"); + return; + } const trimmedTitle = title?.trim() || null; pendingNewChatTitleRef.current = trimmedTitle; + newChatPreviewLaneIdRef.current = laneId; draftSeededFromHistoryRef.current = true; const previousPane = activePaneRef.current; stashActiveInput(); @@ -1762,25 +2380,27 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } } setDraftChatMode(true); selectActiveSessionId(null); + // New-chat-setup is part of the context default; let the resolver drive it. + lastUserOpenedPaneRef.current = null; eventDedupKeysRef.current.clear(); eventDedupKeyOrderRef.current = []; setEvents([]); setClearedAt(null); chatDraftRef.current = ""; setPrompt(""); - setRightSelectionIndex(0); + setRightSelectionIndex(defaultSetupSelectionIndex(newChatSetupRows)); setFormDiscardArmed(false); setRightPane({ kind: "new-chat-setup", - laneId: activeLaneIdRef.current, - laneLabel: activeLane?.name ?? activeLaneIdRef.current, + laneId, + laneLabel: lane.name, rows: newChatSetupRows, }); setRightOpen(true); setPaneFocus("details"); void refreshAiSetupStatus().catch(() => undefined); void loadProviderModels(modelState.provider, { applyDefault: false }).catch(() => undefined); - }, [activeLane?.name, focusDetails, loadProviderModels, modelState.provider, newChatSetupRows, refreshAiSetupStatus, selectActiveSessionId, setDraftChatMode, setPaneFocus, stashActiveInput]); + }, [activeLane, addNotice, focusDetails, lanes, loadProviderModels, modelState.provider, newChatSetupRows, refreshAiSetupStatus, selectActiveSessionId, setDraftChatMode, setPaneFocus, stashActiveInput]); const openModelSetup = useCallback((options: { forceRefresh?: boolean } = {}) => { const previousPane = activePaneRef.current; @@ -1798,6 +2418,8 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } desktopAttached: mode === "attached", }); setRightOpen(true); + // /model is sticky until explicitly closed. + lastUserOpenedPaneRef.current = "model-setup"; setPrompt(""); setPaneFocus("details"); void refreshAiSetupStatus({ force: options.forceRefresh === true }).catch((err) => { @@ -1836,6 +2458,14 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } || suggestion.insertText.toLowerCase().includes(query) || suggestion.detail?.toLowerCase().includes(query) )); + const attachedSuggestions = selectedMentions + .filter((suggestion) => suggestion.attachment && suggestion.filePath) + .filter((suggestion) => ( + !query + || suggestion.label.toLowerCase().includes(query) + || suggestion.insertText.toLowerCase().includes(query) + || suggestion.detail?.toLowerCase().includes(query) + )); const loadRemoteSuggestions = async () => { const remote: MentionSuggestion[] = []; @@ -1895,7 +2525,7 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } })); } if (cancelled) return; - const next = [...localSuggestions, ...remote].slice(0, 10); + const next = [...localSuggestions, ...remote, ...attachedSuggestions].slice(0, 10); setMentionSuggestions(next); setMentionIndex((index) => Math.min(index, Math.max(0, next.length - 1))); }; @@ -1903,24 +2533,37 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } return () => { cancelled = true; }; - }, [activeMentionRange, lanes, sessions]); + }, [activeMentionRange, lanes, selectedMentions, sessions]); const refreshState = useCallback(async () => { const conn = connectionRef.current; if (!conn) return; const nextLanes = await listLanes(conn); - const nextLane = nextLanes.find((lane) => lane.id === activeLaneIdRef.current) - ?? chooseInitialLane(nextLanes, project); - const nextLaneId = nextLane?.id ?? null; const nextSessions = await listChatSessions(conn); - const laneSessions = nextSessions.filter((session) => session.laneId === nextLaneId); const draftMode = draftChatActiveRef.current; - const seedSession = draftMode ? newestSession(laneSessions) : null; - const nextSession = draftMode - ? null - : nextSessions.find((session) => session.sessionId === activeSessionIdRef.current) - ?? newestSession(laneSessions); + const target = resolveTuiChatRefreshTarget({ + lanes: nextLanes, + sessions: nextSessions, + context: project, + lastLaneId: lastLaneIdRef.current, + activeLaneId: activeLaneIdRef.current, + activeSessionId: activeSessionIdRef.current, + draftChatActive: draftMode, + initialNewChatPreview: initialNewChatPreviewRef.current, + newChatPreviewLaneId: newChatPreviewLaneIdRef.current, + selectedDrawerChatAction, + drawerLaneId, + }); + const nextLane = target.lane; + const nextLaneId = target.laneId; + const nextSession = target.session; const nextSessionId = nextSession?.sessionId ?? null; + const seedSession = target.seedSession; + const launchToNewChatPreview = target.launchToNewChatPreview; + const previewMode = target.previewMode; + if (previewMode) { + newChatPreviewLaneIdRef.current = nextLaneId; + } let nextEvents: AgentChatEventEnvelope[] = []; if (nextSessionId) { const history = await getChatHistory(conn, nextSessionId); @@ -1947,10 +2590,20 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } const configSession = nextSession ?? (!draftSeededFromHistoryRef.current ? seedSession : null); const nextProvider = configSession?.provider ?? modelState.provider ?? "codex"; const commandSessionId = nextSessionId ?? configSession?.sessionId ?? null; - const remoteCommands = commandSessionId ? await getSlashCommands(conn, commandSessionId).catch(() => []) : []; + const commandArgs = commandSessionId + ? { sessionId: commandSessionId } + : nextLaneId + ? { laneId: nextLaneId, provider: nextProvider } + : null; + const remoteCommands = commandArgs ? await getSlashCommands(conn, commandArgs).catch(() => []) : []; const projectCommands = discoverProjectSlashCommands(nextLane?.worktreePath || project.workspaceRoot); const nextCommands = remoteCommands.length ? remoteCommands : projectCommands; - const nextModels = await getAvailableModels(conn, nextProvider).catch(() => []); + const provider = normalizeProvider(nextProvider); + const cachedModels = providerModelsCacheRef.current.get(provider); + const nextModels = cachedModels ?? registryModelsForProvider(provider); + if (!cachedModels) { + void loadProviderModels(provider, { applyDefault: false }).catch(() => undefined); + } const activeModel = nextModels.find((model) => model.modelId === configSession?.modelId || model.id === configSession?.modelId) ?? nextModels.find((model) => model.isDefault) ?? null; @@ -1962,8 +2615,19 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } setEvents(nextEvents); setSlashCommands(nextCommands); setModels(nextModels); + if (launchToNewChatPreview) { + initialNewChatPreviewRef.current = false; + newChatPreviewLaneIdRef.current = nextLaneId; + setDraftChatMode(false); + setDrawerSection("chats"); + setDrawerLaneId(nextLaneId); + setSelectedDrawerLaneId(nextLaneId); + setSelectedDrawerLaneAction(null); + setSelectedDrawerChatId(null); + setSelectedDrawerChatAction(nextLaneId ? "new-chat" : null); + setRightOpen(true); + } if (configSession && (!draftMode || !draftSeededFromHistoryRef.current)) { - const provider = normalizeProvider(nextProvider); setModelState((prev) => ({ ...prev, provider, @@ -1985,7 +2649,7 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } })); if (draftMode) draftSeededFromHistoryRef.current = true; } - }, [clearedAt, modelState.provider, project, selectActiveLaneId, selectActiveSessionId]); + }, [clearedAt, drawerLaneId, loadProviderModels, modelState.provider, project, selectActiveLaneId, selectActiveSessionId, selectedDrawerChatAction, setDraftChatMode]); const commitModelStateToSession = useCallback(async (nextState: AdeCodeModelState) => { const conn = connectionRef.current; @@ -2012,6 +2676,22 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } await refreshState(); }, [refreshState]); + const scheduleModelStateCommit = useCallback((nextState: AdeCodeModelState) => { + pendingModelCommitStateRef.current = nextState; + if (pendingModelCommitTimerRef.current) { + clearTimeout(pendingModelCommitTimerRef.current); + } + pendingModelCommitTimerRef.current = setTimeout(() => { + pendingModelCommitTimerRef.current = null; + const pending = pendingModelCommitStateRef.current; + pendingModelCommitStateRef.current = null; + if (!pending) return; + void commitModelStateToSession(pending).catch((err) => { + addNotice(err instanceof Error ? err.message : String(err), "error"); + }); + }, 200); + }, [addNotice, commitModelStateToSession]); + useEffect(() => { let cancelled = false; void (async () => { @@ -2026,7 +2706,8 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } setConnection(conn); setMode(conn.mode); draftSeededFromHistoryRef.current = false; - setDraftChatMode(true); + newChatPreviewLaneIdRef.current = null; + setDraftChatMode(false); selectActiveSessionId(null); eventDedupKeysRef.current.clear(); eventDedupKeyOrderRef.current = []; @@ -2049,8 +2730,13 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } for (const [laneId, sessionId] of lastChatByLaneRef.current) { lastChatByLane[laneId] = sessionId; } - saveAdeCodeState({ lastChatByLane }); + saveAdeCodeState({ lastChatByLane, lastLaneId: lastLaneIdRef.current }); + } + if (pendingModelCommitTimerRef.current) { + clearTimeout(pendingModelCommitTimerRef.current); + pendingModelCommitTimerRef.current = null; } + pendingModelCommitStateRef.current = null; const conn = connectionRef.current; connectionRef.current = null; void conn?.close().catch(() => {}); @@ -2088,9 +2774,21 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } if (event.type === "status" && event.turnStatus === "started") setStreaming(true); if (event.type === "done" || (event.type === "status" && event.turnStatus === "completed")) setStreaming(false); if (event.type === "subagent_started" || event.type === "subagent.started") { - setRightOpen(true); - setRightPane((prev) => prev.kind === "subagents" ? prev : { kind: "subagents", tab: "subagents", snapshots: [] }); - setPaneFocus("details"); + // Auto-open only when nothing important is showing. Otherwise the + // footer chip surfaces the live agent count without disrupting the user. + setRightPane((prev) => { + if (prev.kind === "subagents") return prev; + if (prev.kind !== "empty" && prev.kind !== "lane-details") return prev; + setRightOpen(true); + setPaneFocus("details"); + const provider = (activeSessionRef.current?.provider ?? modelStateRef.current.provider) as AdeCodeProvider; + return { + kind: "subagents", + tab: "subagents", + snapshots: [], + provider, + }; + }); } }); }, [clearedAt, connection, refreshState, setPaneFocus]); @@ -2105,6 +2803,71 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } return () => clearInterval(timer); }, [connection, refreshState]); + useEffect(() => { + if (!connection) { + setDiffByLaneId({}); + return; + } + const laneIds = diffLaneIdsKey.split("\n").filter(Boolean); + if (laneIds.length === 0) { + setDiffByLaneId({}); + return; + } + + let cancelled = false; + const refreshDiffStats = async () => { + try { + const next = await listLaneDiffStats(connection, laneIds); + if (!cancelled) setDiffByLaneId(next); + } catch { + // Diff stats can be expensive and transiently fail while lanes are moving. + // Keep the previous cache rather than flickering the drawer. + } + }; + void refreshDiffStats(); + const timer = setInterval(() => { + void refreshDiffStats(); + }, 10_000); + return () => { + cancelled = true; + clearInterval(timer); + }; + }, [connection, diffLaneIdsKey]); + + useEffect(() => { + if (!connection) { + setPrByLaneId({}); + return; + } + let cancelled = false; + const refreshPrsByLane = async () => { + try { + const prs = await listPrsByLane(connection); + if (cancelled) return; + const next: Record = {}; + for (const pr of prs) { + next[pr.laneId] = { + number: pr.number, + state: pr.state, + checksPassed: pr.checksPassed, + checksTotal: pr.checksTotal, + }; + } + setPrByLaneId(next); + } catch { + // PR checks are rate-limit sensitive; keep the previous cache on transient failures. + } + }; + void refreshPrsByLane(); + const timer = setInterval(() => { + void refreshPrsByLane(); + }, 30_000); + return () => { + cancelled = true; + clearInterval(timer); + }; + }, [connection]); + useEffect(() => { if (!connection || mode === "attached" || forceEmbedded) return; const timer = setInterval(() => { @@ -2144,6 +2907,17 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } const laneId = activeLaneIdRef.current; if (!conn || !laneId) return null; if (activeSessionIdRef.current) return activeSessionIdRef.current; + const lane = lanes.find((entry) => entry.id === laneId) ?? null; + const unavailableMessage = laneWorktreeUnavailableMessage(lane); + if (unavailableMessage) { + if (lane) { + setRightPane(seedLaneDetails(lane, false)); + setRightOpen(true); + } + setDraftChatMode(false); + addNotice(unavailableMessage, "error"); + return null; + } const normalized = { ...modelState, ...applyProviderPermissionMode(modelState) }; const created = await createChatSession({ connection: conn, @@ -2169,7 +2943,7 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } selectActiveSessionId(created.id); await refreshState(); return created.id; - }, [modelState, refreshState, selectActiveSessionId, setDraftChatMode]); + }, [addNotice, lanes, modelState, refreshState, selectActiveSessionId, setDraftChatMode]); const resolvePendingApproval = useCallback(async ( approval: PendingApproval, @@ -2219,12 +2993,102 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } await refreshState(); }, [addNotice, refreshState]); + const copyVisibleTranscript = useCallback((): boolean => { + const rightPaneWidth = resolveRightPaneWidth(columns, rightOpen, drawerOpen); + const transcript = renderChatTranscriptPlainText({ + events: displayEvents, + notices: displayNotices, + activeSession, + expandedLineIds, + maxRows: chatRowBudget, + scrollOffsetRows: effectiveChatScrollOffsetRows, + width: resolveCenterPaneWidth(columns, drawerOpen, rightPaneWidth), + }).trim(); + if (!transcript) { + addNotice("No visible chat text to copy.", "info"); + return true; + } + if (!writeClipboardText(transcript)) { + addNotice("Could not find a clipboard command for this terminal.", "error"); + return true; + } + addNotice("Copied visible chat text.", "success"); + return true; + }, [ + activeSession, + addNotice, + chatRowBudget, + effectiveChatScrollOffsetRows, + columns, + displayEvents, + displayNotices, + drawerOpen, + expandedLineIds, + rightOpen, + rightPane.kind, + ]); + + const sendOrSteerChatMessage = useCallback(async ( + sessionId: string, + text: string, + attachments: AgentChatFileRef[] = [], + ) => { + const conn = connectionRef.current; + if (!conn) return; + const steerActiveTurn = async (): Promise => { + const result = await steerChatMessage(conn, sessionId, text, attachments); + if (result.queued) { + addNotice("Staged message — sends after the current turn.", "info"); + } + }; + const activeTurnVisible = ( + (streaming && sessionId === activeSessionIdRef.current) + || sessions.some((session) => session.sessionId === sessionId && session.status === "active") + ); + if (activeTurnVisible) { + setStreaming(true); + try { + await steerActiveTurn(); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + if (!/No active turn to steer/i.test(message)) throw error; + try { + await sendChatMessage(conn, sessionId, text, attachments); + } catch (sendError) { + const sendMessage = sendError instanceof Error ? sendError.message : String(sendError); + if (!/turn is already active|already active/i.test(sendMessage)) throw sendError; + await steerActiveTurn(); + } + } + await refreshState(); + return; + } + setStreaming(true); + try { + await sendChatMessage(conn, sessionId, text, attachments); + await refreshState(); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + if (/turn is already active|already active/i.test(message)) { + await steerActiveTurn(); + await refreshState(); + return; + } + throw error; + } + }, [addNotice, refreshState, sessions, streaming]); + const runRightCommand = useCallback(async (name: string, args: string) => { const conn = connectionRef.current; if (!conn) return; const laneId = activeLaneIdRef.current; const sessionId = activeSessionIdRef.current; focusDetails(); + // Slash-opened panes are sticky: mark before dispatching so the + // context-default effect won't overwrite. Cleared on chat switch or + // explicit close (Esc / pane:close). Commands like /new chat and + // /new lane re-enter their own flows below which clear this marker. + lastUserOpenedPaneRef.current = "details"; if (name === "/help") { setRightPane({ kind: "help", title: "Help" }); @@ -2377,9 +3241,7 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } const command = `/plugin ${args.trim()}`; setRightPane({ kind: "details", title: "Plugins", body: `Running ${command} in the active Claude session.` }); lastLocalSendAtRef.current = Date.now(); - setStreaming(true); - await sendChatMessage(conn, sessionId, command); - await refreshState(); + await sendOrSteerChatMessage(sessionId, command); return; } const plugins = await listClaudePlugins(conn, sessionId); @@ -2394,6 +3256,15 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } openNewChatSetup(args); return; } + if (name === "/steer") { + const body = pendingSteers.length + ? pendingSteers + .map((steer, index) => `${index + 1}. ${steer.text}`) + .join("\n") + : "No staged steer messages are waiting."; + setRightPane({ kind: "details", title: "Staged messages", body }); + return; + } if (name === "/new lane") { if (!args) { openNewLaneForm(); @@ -2565,7 +3436,7 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } const targetSessionId = await ensureActiveSession(); const issueContext = `Linear issue context:\n${renderObject(issue, 28)}`; if (targetSessionId) { - await sendChatMessage(conn, targetSessionId, issueContext); + await sendOrSteerChatMessage(targetSessionId, issueContext); } setRightPane({ kind: "details", title: "Linear pull", body: issueContext }); return; @@ -2748,7 +3619,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, models, openForm, openModelSetup, openNewChatSetup, openNewLaneForm, project, refreshState, selectActiveLaneId, selectActiveSessionId, sessions, setChatScrollOffset]); + }, [activeLane?.name, activeSession?.provider, activeSession?.sessionId, activeSession?.title, addNotice, ensureActiveSession, focusDetails, lanes, mode, modelState.modelId, models, openForm, openModelSetup, openNewChatSetup, openNewLaneForm, pendingSteers, project, refreshState, selectActiveLaneId, selectActiveSessionId, sendOrSteerChatMessage, sessions, setChatScrollOffset]); const runInlineCommand = useCallback(async (name: string, args: string) => { const conn = connectionRef.current; @@ -2768,6 +3639,10 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } addNotice("Local transcript view cleared. The durable chat remains in ADE.", "info"); return; } + if (name === "/copy") { + copyVisibleTranscript(); + return; + } if (name === "/end") { if (!sessionId) { addNotice("No active chat is selected.", "error"); @@ -2869,6 +3744,43 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } addNotice(`Memory saved: ${renderObject(result, 4).replace(/\n/g, " ")}`, "success"); return; } + if (name.startsWith("/steer")) { + if (!sessionId) { + addNotice("No active chat is selected.", "error"); + return; + } + const latestSteer = pendingSteers[pendingSteers.length - 1] ?? null; + if (!latestSteer) { + addNotice("No staged steer message is waiting.", "info"); + return; + } + if (name === "/steer cancel") { + await cancelSteerMessage(conn, sessionId, latestSteer.steerId); + addNotice("Removed staged steer message.", "success"); + await refreshState(); + return; + } + if (name === "/steer edit") { + if (!args.trim()) { + addNotice("Usage: /steer edit ", "error"); + return; + } + await editSteerMessage(conn, sessionId, latestSteer.steerId, args.trim()); + addNotice("Updated staged steer message.", "success"); + await refreshState(); + return; + } + if (name === "/steer send" || name === "/steer interrupt") { + if (activeSession?.provider !== "claude") { + addNotice("Only Claude staged messages support send-now and interrupt dispatch.", "error"); + return; + } + await dispatchSteerMessage(conn, sessionId, latestSteer.steerId, name === "/steer send" ? "inline" : "interrupt"); + addNotice(name === "/steer send" ? "Sent staged message into the active Claude turn." : "Interrupting Claude to run the staged message.", "info"); + await refreshState(); + return; + } + } if (name === "/open") { const target = sessionId ? { kind: "chat" as const, sessionId, laneId } @@ -2914,7 +3826,7 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } addNotice(result.message ?? "Desktop route unavailable from this runtime.", "error"); } } - }, [activeSession?.provider, addNotice, exit, loadProviderModels, modelState.provider, project, refreshAiSetupStatus, refreshState, setChatScrollOffset, socketPath]); + }, [activeSession?.provider, addNotice, copyVisibleTranscript, exit, loadProviderModels, modelState.provider, pendingSteers, project, refreshAiSetupStatus, refreshState, setChatScrollOffset, socketPath]); const submitRightForm = useCallback(async ( form: Extract, @@ -2950,6 +3862,7 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } setDrawerSection("lanes"); setRightOpen(false); setRightPane({ kind: "empty" }); + lastUserOpenedPaneRef.current = null; focusAfterDetails(); addNotice(`Created lane ${created.name}.`, "success"); await refreshState(); @@ -2966,6 +3879,7 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } await renameChat(conn, sessionId, title); setRightOpen(false); setRightPane({ kind: "empty" }); + lastUserOpenedPaneRef.current = null; focusAfterDetails(); addNotice(`Renamed chat to "${title}".`, "success"); await refreshState(); @@ -3016,14 +3930,17 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } const submitPrompt = useCallback(async (value: string) => { const text = value.trim(); - if (!text && rightPane.kind !== "form") return; + const promptAttachments: AgentChatFileRef[] = selectedMentions + .filter((mention) => ( + mention.kind === "file" + && mention.filePath + && (mention.attachment || (mention.insertText.length > 0 && text.includes(mention.insertText))) + )) + .map((mention) => ({ type: isImageFilePath(mention.filePath!) ? "image" : "file", path: mention.filePath! })); + if (!text && rightPane.kind !== "form" && !promptAttachments.length) return; const conn = connectionRef.current; if (!conn) return; try { - if (streaming && !text.startsWith("/") && rightPane.kind !== "form") { - addNotice("This chat is still responding. Press ctrl-c to interrupt before sending another message.", "info"); - return; - } setPrompt(""); promptRef.current = ""; setChatScrollOffset(0); @@ -3078,9 +3995,7 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } } const sessionId = await ensureActiveSession(); if (sessionId) { - setStreaming(true); - await sendChatMessage(conn, sessionId, selected.name); - await refreshState(); + await sendOrSteerChatMessage(sessionId, selected.name); } return; } @@ -3113,19 +4028,15 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } return; } lastLocalSendAtRef.current = Date.now(); - const attachments: AgentChatFileRef[] = selectedMentions - .filter((mention) => mention.kind === "file" && mention.filePath && text.includes(mention.insertText)) - .map((mention) => ({ type: isImageFilePath(mention.filePath!) ? "image" : "file", path: mention.filePath! })); - setStreaming(true); - await sendChatMessage(conn, sessionId, text, attachments); - await refreshState(); + await sendOrSteerChatMessage(sessionId, text || "Use the attached image.", promptAttachments); + setSelectedMentions((prev) => prev.filter((mention) => !mention.attachment)); } catch (err) { const message = err instanceof Error ? err.message : String(err); setStreaming(false); setError(message); addNotice(message, "error"); } - }, [activeCommandProvider, activeFormField, addNotice, answerPendingInput, ensureActiveSession, formValues, pendingApproval, refreshState, resolvePendingApproval, rightPane, runInlineCommand, runRightCommand, selectedMentions, setChatScrollOffset, slashCommands, slashIndex, slashRows, streaming, submitRightForm]); + }, [activeCommandProvider, activeFormField, addNotice, answerPendingInput, ensureActiveSession, formValues, pendingApproval, resolvePendingApproval, rightPane, runInlineCommand, runRightCommand, selectedMentions, sendOrSteerChatMessage, setChatScrollOffset, slashCommands, slashIndex, slashRows, submitRightForm]); const insertMention = useCallback((suggestion: MentionSuggestion) => { const range = activeMention(prompt); @@ -3148,40 +4059,32 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } const applyModelState = useCallback((updater: (prev: AdeCodeModelState) => AdeCodeModelState) => { setModelState((prev) => { const next = updater(prev); - void commitModelStateToSession(next).catch((err) => { - addNotice(err instanceof Error ? err.message : String(err), "error"); - }); + scheduleModelStateCommit(next); return next; }); - }, [addNotice, commitModelStateToSession]); + }, [scheduleModelStateCommit]); - const selectProvider = useCallback(async (provider: AdeCodeProvider) => { - const conn = connectionRef.current; - const nextModels = conn ? await getAvailableModels(conn, provider).catch(() => []) : []; - setModels(nextModels); - const model = nextModels.find((entry) => entry.isDefault) ?? nextModels[0] ?? null; + const selectProvider = useCallback((provider: AdeCodeProvider) => { + const immediateModels = providerModelsCacheRef.current.get(provider) ?? registryModelsForProvider(provider); + setModels(immediateModels); + const model = immediateModels.find((entry) => entry.isDefault) ?? immediateModels[0] ?? null; applyModelState((prev) => ({ ...prev, ...(model ? modelStatePatchForModel(provider, model) : fallbackModelStatePatch(provider)), })); - }, [applyModelState]); + void loadProviderModels(provider, { applyDefault: false }).catch(() => undefined); + }, [applyModelState, loadProviderModels]); const cycleProvider = useCallback((delta: number) => { const index = Math.max(0, PROVIDER_OPTIONS.findIndex((entry) => entry.value === modelState.provider)); const next = PROVIDER_OPTIONS[(index + delta + PROVIDER_OPTIONS.length) % PROVIDER_OPTIONS.length]?.value ?? "codex"; - void selectProvider(next).catch((err) => addNotice(err instanceof Error ? err.message : String(err), "error")); - }, [addNotice, modelState.provider, selectProvider]); + selectProvider(next); + }, [modelState.provider, selectProvider]); const cycleModel = useCallback((delta: number) => { const candidates = models.length ? models - : listModelDescriptorsForProvider(modelState.provider).map((descriptor) => ({ - id: descriptor.id, - modelId: descriptor.id, - displayName: descriptor.displayName, - isDefault: descriptor.id === getDefaultModelDescriptor(modelState.provider)?.id, - reasoningEfforts: descriptor.reasoningTiers?.map((effort) => ({ effort, description: effort })), - })); + : registryModelsForProvider(modelState.provider); if (!candidates.length) return; const index = Math.max(0, candidates.findIndex((entry) => entry.id === modelState.modelId || entry.modelId === modelState.modelId)); const nextModel = candidates[(index + delta + candidates.length) % candidates.length] ?? candidates[0]!; @@ -3278,6 +4181,25 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } applyModelState((prev) => ({ ...prev, codexFastMode: !prev.codexFastMode })); return; } + if (row.kind === "output-style") { + const sessionId = activeSessionIdRef.current; + if (!conn || !sessionId) return; + void (async () => { + const styles = await listClaudeOutputStyles(conn, sessionId); + const names = styles.map((style) => style.name).filter(Boolean); + if (!names.length) { + addNotice("No Claude output styles were found.", "info"); + return; + } + const current = activeSessionRef.current?.claudeOutputStyle ?? row.value ?? "default"; + const index = Math.max(0, names.findIndex((name) => name.toLowerCase() === current.toLowerCase())); + const next = names[(index + direction + names.length) % names.length] ?? names[0]!; + await setClaudeOutputStyle(conn, sessionId, next); + addNotice(`Claude output style set to ${next}.`, "success"); + await refreshState(); + })().catch((err) => addNotice(err instanceof Error ? err.message : String(err), "error")); + return; + } if (row.kind === "refresh-status") { void refreshAiSetupStatus({ force: true }) .then(() => addNotice("AI provider status refreshed.", "success")) @@ -3296,10 +4218,10 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } if (row.kind === "apply") { setRightOpen(false); setRightPane({ kind: "empty" }); + lastUserOpenedPaneRef.current = null; focusChat(); - addNotice(`New chat ready in ${activeLane?.name ?? activeLaneIdRef.current ?? "current lane"}.`, "success"); } - }, [activeLane?.name, addNotice, applyModelState, cycleModel, cyclePermission, cycleProvider, cycleReasoning, focusChat, refreshAiSetupStatus]); + }, [addNotice, applyModelState, cycleModel, cyclePermission, cycleProvider, cycleReasoning, focusChat, refreshAiSetupStatus, refreshState]); const recallPromptHistory = useCallback((direction: "previous" | "next"): boolean => { const history = promptHistoryRef.current; @@ -3357,8 +4279,6 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } focusChat(); } const insertText = `@${path.basename(attachment.path)}`; - const current = promptRef.current || chatDraftRef.current; - const nextPrompt = current.trim() ? `${current} ${insertText}` : insertText; setSelectedMentions((prev) => { if (prev.some((entry) => entry.filePath === attachment.path)) return prev; return [...prev, { @@ -3367,11 +4287,9 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } insertText, detail: attachment.path, filePath: attachment.path, + attachment: true, }].slice(-12); }); - chatDraftRef.current = nextPrompt; - promptRef.current = nextPrompt; - setPrompt(nextPrompt); addNotice("Attached clipboard image.", "success"); return true; }, [addNotice, focusChat, project.workspaceRoot]); @@ -3508,9 +4426,16 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } toggleDetailsPane(); return true; } + if (action === "pane:agents") { + toggleSubagentsPane(); + return true; + } if (action === "pane:close") { if (rightOpen) { setRightOpen(false); + // Explicit close clears the slash-command sticky marker so the next + // open recomputes to the context default. + lastUserOpenedPaneRef.current = null; setRightPane((prev) => prev.kind === "form" ? { kind: "empty" } : prev); focusAfterDetails(); } else if (drawerOpen) { @@ -3540,7 +4465,10 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } || action === "footer:clearSelection" || action === "settings:close" ) { - if (rightOpen) setRightOpen(false); + if (rightOpen) { + setRightOpen(false); + lastUserOpenedPaneRef.current = null; + } if (drawerOpen) setDrawerOpen(false); selectFooterControl(null); focusChat(); @@ -3555,7 +4483,8 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } return true; } if (action === "footer:up" || action === "footer:down") { - selectFooterControl(action === "footer:up" ? null : (footerControlRef.current ?? "drawer")); + if (action === "footer:up") selectFooterControl(null); + else selectFooterControl(footerControls[0] ?? "drawer"); return true; } if ( @@ -3639,14 +4568,107 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } setChatScrollOffset(0); return true; } + if (action === "selection:copy") { + return copyVisibleTranscript(); + } if (action.startsWith("selection:")) { return reportUnavailable(); } return reportUnavailable(); - }, [addNotice, applyModelState, attachClipboardImage, chatRowBudget, cyclePaneFocus, cyclePermission, cycleReasoning, drawerOpen, exit, focusAfterDetails, focusChat, focusDetails, modelState.provider, openHistorySearch, openModelSetup, prompt, recallPromptHistory, refreshState, rightOpen, selectFooterControl, setChatScrollOffset, submitPrompt, toggleDetailsPane]); + }, [addNotice, applyModelState, attachClipboardImage, chatRowBudget, copyVisibleTranscript, cycleFooterControl, cyclePaneFocus, cyclePermission, cycleReasoning, drawerOpen, exit, focusAfterDetails, focusChat, focusDetails, footerControls, modelState.provider, openHistorySearch, openModelSetup, prompt, recallPromptHistory, refreshState, rightOpen, selectFooterControl, setChatScrollOffset, submitPrompt, toggleDetailsPane, toggleSubagentsPane]); useInput((input, key) => { + const mouse = parseTerminalMouseInput(input); + if (mouse) { + 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); + const inTranscriptRows = mouse.y == null || mouse.y > 2; + if (mouse.kind === "wheel" && inCenterPane && inTranscriptRows) { + if (mouse.direction === "up") { + setChatScrollOffset((offset) => offset + 3); + } else if (mouse.direction === "down") { + setChatScrollOffset((offset) => offset - 3); + } + } else if (mouse.kind === "click" && rightWidth > 0 && rightPane.kind === "subagents" && mouse.x != null && mouse.y != null) { + const rightStart = columns - rightWidth + 1; + if (mouse.x >= rightStart) { + const subagentPaneTop = 4 + goalBannerRows; + const nextIndex = subagentIndexForPaneLine(rightPane, mouse.y - subagentPaneTop); + if (nextIndex != null) { + setRightSelectionIndex(nextIndex); + } + setChatScrollOffset(0); + setRightOpen(true); + setPaneFocus("details"); + } + } + return; + } + const pane = activePaneRef.current; + if (pane === "chat") { + const pageUp = Boolean((key as { pageUp?: boolean }).pageUp); + const pageDown = Boolean((key as { pageDown?: boolean }).pageDown); + const home = Boolean((key as { home?: boolean }).home); + const end = Boolean((key as { end?: boolean }).end); + const paletteOpen = (activeMentionRange != null && mentionSuggestions.length > 0) || slashRows.length > 0; + const pageRows = Math.max(1, chatRowBudget - 2); + if (!paletteOpen && key.downArrow && effectiveChatScrollOffsetRows <= 0) { + selectFooterControl(footerControls[0] ?? "drawer"); + return; + } + if (pageUp || (key.ctrl && input === "u")) { + setChatScrollOffset((offset) => offset + (key.ctrl ? 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)); + return; + } + if (home) { + setChatScrollOffset(Number.MAX_SAFE_INTEGER); + return; + } + if (end) { + setChatScrollOffset(0); + return; + } + if (activeMentionRange && mentionSuggestions.length) { + if (key.upArrow) { + setMentionIndex((index) => (index <= 0 ? mentionSuggestions.length - 1 : index - 1)); + return; + } + if (key.downArrow) { + setMentionIndex((index) => (index + 1) % mentionSuggestions.length); + return; + } + if (key.tab) { + insertMention(mentionSuggestions[mentionIndex] ?? mentionSuggestions[0]!); + return; + } + } + if (slashRows.length) { + if (key.upArrow) { + setSlashIndex((index) => (index <= 0 ? slashRows.length - 1 : index - 1)); + return; + } + if (key.downArrow) { + setSlashIndex((index) => (index + 1) % slashRows.length); + return; + } + if (key.tab) { + insertSlashCommand(); + return; + } + } + if (!paletteOpen && (key.upArrow || key.downArrow)) { + setChatScrollOffset((offset) => offset + (key.upArrow ? 1 : -1)); + return; + } + } const keybindingContext = pane === "details" ? rightPane.kind === "help" ? "Help" : "Select" : pane === "drawer" ? "Tabs" : "Chat"; @@ -3675,6 +4697,11 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } return; } + if (key.tab && !key.shift && pane !== "drawer") { + focusDrawer(); + return; + } + if (key.ctrl && input === "o") { if (drawerOpen && pane === "drawer") { setDrawerOpen(false); @@ -3696,13 +4723,23 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } } if (key.ctrl && input === "p") { - focusDetails(); + toggleDetailsPane(); + return; + } + + if (key.ctrl && input === "a") { + toggleSubagentsPane(); return; } if (footerActive) { + if (prompt.length > 0 && isPromptWordBackspace(input, key)) { + selectFooterControl(null); + handlePromptChange(deletePreviousPromptWord(prompt)); + return; + } if (key.leftArrow || key.rightArrow) { - selectFooterControl(footerControlRef.current === "drawer" ? "details" : "drawer"); + cycleFooterControl(key.rightArrow ? 1 : -1); return; } if (key.upArrow || key.escape) { @@ -3712,6 +4749,8 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } if (key.return) { if (footerControlRef.current === "drawer") { toggleDrawerPane(); + } else if (footerControlRef.current === "agents") { + toggleSubagentsPane(); } else { toggleDetailsPane(); } @@ -3732,6 +4771,11 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } } } + if (textInputActive && prompt.length > 0 && isPromptWordBackspace(input, key)) { + handlePromptChange(deletePreviousPromptWord(prompt)); + return; + } + if (pane === "chat" && textInputActive && key.ctrl && input === "r") { openHistorySearch(); return; @@ -3754,6 +4798,20 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } return; } + if ( + pane === "chat" + && textInputActive + && !key.ctrl + && !key.meta + && prompt.length === 0 + && !activeMentionRange + && !slashRows.length + && (key.upArrow || key.downArrow) + ) { + recallPromptHistory(key.upArrow ? "previous" : "next"); + return; + } + if (pane === "chat" && textInputActive && vimModeEnabled && !key.ctrl && !key.meta) { if (key.escape) { setVimMode("normal"); @@ -3802,14 +4860,25 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } setRightPane({ kind: "empty" }); } setRightOpen(false); + lastUserOpenedPaneRef.current = null; focusAfterDetails(); return; } if (pane === "drawer") { + if (drawerSection === "chats") { + setDrawerSection("lanes"); + setSelectedDrawerChatAction(null); + setSelectedDrawerChatId(null); + return; + } setDrawerOpen(false); focusChat(); return; } + if (pane === "chat" && streaming && prompt.trim() && activeSession?.provider === "codex") { + void submitPrompt(prompt); + return; + } setPrompt(""); return; } @@ -3854,10 +4923,26 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } return; } - if (pane === "details" && rightOpen && rightPane.kind === "subagents" && key.tab) { - const tabs = ["subagents", "teammates", "background"] as const; - const index = tabs.indexOf(rightPane.tab); - setRightPane({ ...rightPane, tab: tabs[(index + 1) % tabs.length] ?? "subagents" }); + if ( + pane === "details" + && rightOpen + && rightPane.kind === "subagents" + && (key.upArrow || key.downArrow || key.return || input === "k") + ) { + const rows = buildSubagentPaneRows(rightPane); + if (key.upArrow || key.downArrow) { + const delta = key.upArrow ? -1 : 1; + setRightSelectionIndex((index) => rows.length ? (index + delta + rows.length) % rows.length : 0); + setChatScrollOffset(0); + return; + } + if (key.return) { + setChatScrollOffset(0); + return; + } + if (input === "k") { + addNotice("Subagent kill is not wired in this TUI yet.", "info"); + } return; } @@ -3868,13 +4953,17 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } && (key.upArrow || key.downArrow || key.leftArrow || key.rightArrow || key.return) ) { const rows = rightPane.rows; - const providerRowCount = rightPane.kind === "model-setup" ? rightPane.providerRows.length : 0; - const totalRows = rows.length + providerRowCount; + const totalRows = rows.length; if (key.upArrow || key.downArrow) { const delta = key.upArrow ? -1 : 1; setRightSelectionIndex((index) => totalRows ? (index + delta + totalRows) % totalRows : 0); return; } + if (rightPane.kind === "new-chat-setup" && key.return) { + const applyRow = rows.find((entry) => entry.kind === "apply"); + if (applyRow) handleSetupRow(applyRow, 1); + return; + } if (rightSelectionIndex >= rows.length) { return; } @@ -3886,7 +4975,8 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } if (pane === "details" && rightOpen && rightPane.kind === "lane-details") { const laneDetails = rightPane; - const maxIndex = LANE_DETAIL_ACTIONS.length - 1 + (laneDetails.pr ? 1 : 0); + const worktreeMissing = laneDetails.worktreeAvailable === false; + const maxIndex = worktreeMissing ? 0 : LANE_DETAIL_ACTIONS.length - 1 + (laneDetails.pr ? 1 : 0); if (key.upArrow) { setRightPane((prev) => prev.kind === "lane-details" ? { ...prev, selectedActionIndex: Math.max(0, prev.selectedActionIndex - 1) } @@ -3904,6 +4994,10 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } return; } if (key.return) { + if (worktreeMissing) { + addNotice(laneWorktreeUnavailableMessage(laneDetails.lane) ?? "Lane worktree is unavailable.", "error"); + return; + } const index = laneDetails.selectedActionIndex; if (index < LANE_DETAIL_ACTIONS.length) { const action = LANE_DETAIL_ACTIONS[index]; @@ -4057,6 +5151,14 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } setChatScrollOffset(0); return; } + if (!prompt.trim() && key.upArrow) { + recallPromptHistory("previous"); + return; + } + if (!prompt.trim() && key.downArrow) { + recallPromptHistory("next"); + return; + } } if (pane === "chat" && key.upArrow && activeMentionRange && mentionSuggestions.length) { @@ -4084,7 +5186,7 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } return; } if (pane === "chat" && key.downArrow && !activeMentionRange && !slashRows.length) { - selectFooterControl(footerControlRef.current ?? "drawer"); + selectFooterControl(footerControlRef.current ?? footerControls[0] ?? "drawer"); setPaneFocus("chat"); return; } @@ -4097,39 +5199,56 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } if (drawerSection === "lanes") { const nextIndex = Math.max(0, selectedLaneIndex - 1); const lane = drawerLaneRows[nextIndex] ?? null; - setSelectedDrawerLaneAction(lane ? null : "new-lane"); - setSelectedDrawerLaneId(lane?.id ?? null); - } else if (selectedChatIndex <= 0) { - setDrawerSection("lanes"); - const lastLane = drawerLaneRows[drawerLaneRows.length - 1] ?? null; - setSelectedDrawerLaneAction("new-lane"); - setSelectedDrawerLaneId(lastLane?.id ?? null); + if (lane) { + setSelectedDrawerLaneAction(null); + setSelectedDrawerLaneId(lane.id); + setDrawerLaneId(lane.id); + selectActiveLaneId(lane.id); + selectActiveSessionId(null); + } } else { const nextIndex = Math.max(0, selectedChatIndex - 1); const session = drawerVisibleLaneSessions[nextIndex] ?? null; setSelectedDrawerChatAction(session ? null : "new-chat"); setSelectedDrawerChatId(session?.sessionId ?? null); + if (session) { + selectActiveLaneId(session.laneId); + selectActiveSessionId(session.sessionId); + setDraftChatMode(false); + } else { + newChatPreviewLaneIdRef.current = drawerLaneId ?? activeLaneIdRef.current; + selectActiveSessionId(null); + } } return; } if (pane === "drawer" && drawerOpen && key.downArrow) { if (drawerSection === "lanes") { - if (selectedLaneIndex >= drawerLaneRows.length) { - setDrawerSection("chats"); - const firstSession = drawerVisibleLaneSessions[0] ?? null; - setSelectedDrawerChatAction(firstSession ? null : "new-chat"); - setSelectedDrawerChatId(firstSession?.sessionId ?? null); - } else { - const nextIndex = Math.min(drawerLaneRows.length, selectedLaneIndex + 1); - const lane = drawerLaneRows[nextIndex] ?? null; - setSelectedDrawerLaneAction(lane ? null : "new-lane"); - setSelectedDrawerLaneId(lane?.id ?? null); + const nextIndex = Math.min(drawerLaneRows.length, selectedLaneIndex + 1); + const lane = drawerLaneRows[nextIndex] ?? null; + if (lane) { + setSelectedDrawerLaneAction(null); + setSelectedDrawerLaneId(lane.id); + setDrawerLaneId(lane.id); + selectActiveLaneId(lane.id); + selectActiveSessionId(null); + } else if (drawerLaneRows.length > 0) { + setSelectedDrawerLaneAction("new-lane"); + setSelectedDrawerLaneId(null); } } else { const nextIndex = Math.min(drawerVisibleLaneSessions.length, selectedChatIndex + 1); const session = drawerVisibleLaneSessions[nextIndex] ?? null; setSelectedDrawerChatAction(session ? null : "new-chat"); setSelectedDrawerChatId(session?.sessionId ?? null); + if (session) { + selectActiveLaneId(session.laneId); + selectActiveSessionId(session.sessionId); + setDraftChatMode(false); + } else { + newChatPreviewLaneIdRef.current = drawerLaneId ?? activeLaneIdRef.current; + selectActiveSessionId(null); + } } return; } @@ -4137,7 +5256,6 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } if (drawerSection === "lanes") { if (selectedDrawerLaneAction === "new-lane" || selectedLaneIndex >= drawerLaneRows.length) { openNewLaneForm(); - setRightOpen(true); return; } const lane = drawerLaneRows[selectedLaneIndex]; @@ -4146,16 +5264,31 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } setDrawerLaneId(lane.id); setSelectedDrawerLaneId(lane.id); setSelectedDrawerLaneAction(null); + const unavailableMessage = laneWorktreeUnavailableMessage(lane); + if (unavailableMessage) { + setDraftChatMode(false); + selectActiveSessionId(null); + setSelectedDrawerChatId(null); + setSelectedDrawerChatAction(null); + setRightPane(seedLaneDetails(lane, false)); + setRightOpen(true); + addNotice(unavailableMessage, "error"); + return; + } const laneSessions = sessions.filter((entry) => entry.laneId === lane.id); const lastSessionId = lastChatByLaneRef.current.get(lane.id); const session = laneSessions.find((s) => s.sessionId === lastSessionId) ?? newestSession(laneSessions); - selectActiveSessionId(session?.sessionId ?? null); + if (session) { + selectActiveSessionId(session.sessionId); + } else { + newChatPreviewLaneIdRef.current = lane.id; + selectActiveSessionId(null); + } setSelectedDrawerChatId(session?.sessionId ?? null); setSelectedDrawerChatAction(session ? null : "new-chat"); setDrawerSection("chats"); - addNotice(`Switched to lane ${lane.name}.`, "success"); } } else { if (selectedDrawerChatAction === "new-chat" || selectedChatIndex >= drawerVisibleLaneSessions.length) { @@ -4177,6 +5310,18 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } return; } + if (pane === "drawer" && drawerOpen && !key.ctrl && !key.meta && input) { + const suffix = printableInput(input); + if (suffix) { + const draft = `${chatDraftRef.current}${suffix}`; + focusChat(); + chatDraftRef.current = draft; + promptRef.current = draft; + setPrompt(draft); + } + return; + } + if (pane === "chat" && key.return && !prompt.trim() && latestFailedLineId && !pendingApproval && rightPane.kind !== "form" && !slashRows.length) { setExpandedLineIds((prev) => { const next = new Set(prev); @@ -4232,11 +5377,43 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } setPrompt(value); }, [activeFormField, focusDetails, rightPane]); - const centerWidth = Math.max(40, columns - (drawerOpen ? 30 : 0) - (rightOpen ? 40 : 0)); + const attachedImageChips = useMemo(() => { + return selectedMentions + .filter((mention) => ( + mention.kind === "file" + && mention.filePath + && isImageFilePath(mention.filePath) + && (mention.attachment || prompt.includes(mention.insertText)) + )) + .map((mention) => { + const dimensions = mention.filePath ? readImageDimensions(mention.filePath) : null; + return { + key: mention.filePath ?? mention.insertText, + label: mention.label, + dimensions: dimensions ? `${dimensions.width}x${dimensions.height}` : null, + }; + }); + }, [prompt, selectedMentions]); + + const rightPaneVisible = rightPaneWidth > 0; const laneName = activeLane?.name ?? "main"; const promptFocused = (activePane === "chat" && footerControl == null) || (activePane === "details" && rightPane.kind === "form"); const drawerFooterSelected = footerControl === "drawer"; const detailsFooterSelected = footerControl === "details"; + const agentsFooterSelected = footerControl === "agents"; + const rightPaneShowsAgents = rightPaneVisible && rightPane.kind === "subagents"; + const showMentionPalette = activeMentionRange != null && mentionSuggestions.length > 0; + const showSlashPalette = prompt.startsWith("/") && slashRows.length > 0; + const modelStatusOverlayRows = statusRows + + (draftChatActive || (vimModeEnabled && !hideVimModeIndicator) || (modelState.provider === "codex" && modelState.codexFastMode) ? 1 : 0); + const paletteBottomRows = 5 + + modelStatusOverlayRows + + (attachedImageChips.length ? 1 : 0) + + (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 paletteOverlayWidth = Math.max(MIN_CENTER_PANE_WIDTH, centerWidth); if (error && !connection) { return ( @@ -4248,90 +5425,146 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } } return ( - -
- {goalBannerText ? ( - - {goalBannerText} - {streaming ? {" · streaming"} : null} - - ) : null} - - {drawerOpen ? ( - + + +
+ {goalBannerText ? ( + + {goalBannerText} + {streaming ? {" · streaming"} : null} + ) : null} - - {pendingApproval?.highStakes ? ( - - ) : ( - <> - - - - )} + + {drawerOpen ? ( + + ) : null} + + {pendingApproval?.highStakes ? ( + + ) : ( + <> + + + + )} + + {rightPaneVisible ? ( + + ) : null} - {rightOpen ? ( - + {showMentionPalette ? ( + + + ) : null} + {!showMentionPalette && showSlashPalette ? ( + + + + ) : null} + {error ? {error} : null} + {attachedImageChips.length ? ( + + {attachedImageChips.map((chip) => ( + + + {chip.label} + {chip.dimensions ? {` ${chip.dimensions}`} : null} + + ))} + + ) : null} + + + {prompt} + + {!prompt ? {" ^V paste image"} : null} + {streaming && !goalBannerText ? {" · streaming"} : null} + + + - - - {error ? {error} : null} - - - {prompt} - - {streaming && !goalBannerText ? {" · streaming"} : null} - - - - + ); } diff --git a/apps/ade-cli/src/tuiClient/commands.ts b/apps/ade-cli/src/tuiClient/commands.ts index fe6ff835f..967e30182 100644 --- a/apps/ade-cli/src/tuiClient/commands.ts +++ b/apps/ade-cli/src/tuiClient/commands.ts @@ -16,11 +16,17 @@ export const BUILTIN_COMMANDS: BuiltinCommand[] = [ { name: "/pull", description: "Pull the active lane branch", placement: "inline" }, { name: "/stage all", description: "Stage all changes in the active lane", placement: "inline" }, { name: "/clear", description: "Clear the local terminal transcript view", placement: "inline" }, + { name: "/copy", description: "Copy the visible chat transcript", placement: "inline" }, { name: "/end", description: "End the active chat runtime", placement: "inline" }, { name: "/login", description: "Sign in to the active CLI-backed provider from this terminal", placement: "inline" }, { name: "/open", description: "Open this ADE context in desktop", placement: "inline" }, { name: "/quit", description: "Exit ade code", placement: "inline" }, { name: "/remember", description: "Write durable ADE memory", placement: "inline", argumentHint: "" }, + { name: "/steer cancel", description: "Remove the latest staged steer message", placement: "inline" }, + { name: "/steer edit", description: "Edit the latest staged steer message", placement: "inline", argumentHint: "" }, + { name: "/steer send", description: "Send the latest staged steer into a Claude turn", placement: "inline", providers: ["claude"] }, + { name: "/steer interrupt", description: "Interrupt Claude and run the latest staged steer", placement: "inline", providers: ["claude"] }, + { name: "/steer", description: "Show staged steer messages", placement: "right" }, { name: "/new lane", description: "Create a new lane", placement: "right" }, { name: "/new chat", description: "Create a new chat", placement: "right", argumentHint: "[title]" }, { name: "/rename", description: "Rename the active chat", placement: "right", argumentHint: "[title]" }, @@ -224,7 +230,7 @@ export function paletteCommands( } return a.name.localeCompare(b.name); }); - return filtered.slice(0, 60); + return filtered.slice(0, 80); } export function commandPlacement(command: ParsedCommand): CommandPlacement { diff --git a/apps/ade-cli/src/tuiClient/components/ApprovalPrompt.tsx b/apps/ade-cli/src/tuiClient/components/ApprovalPrompt.tsx index 8be16b5ce..054c8f24b 100644 --- a/apps/ade-cli/src/tuiClient/components/ApprovalPrompt.tsx +++ b/apps/ade-cli/src/tuiClient/components/ApprovalPrompt.tsx @@ -1,6 +1,25 @@ import React from "react"; import { Box, Text } from "ink"; import type { PendingApproval } from "../types"; +import { theme } from "../theme"; + +function ApproveChip({ + k, + label, + color, + highlighted, +}: { + k: string; + label: string; + color: string; + highlighted?: boolean; +}) { + return ( + + [{k}] {label} + + ); +} export function ApprovalPrompt({ approval, @@ -11,38 +30,57 @@ export function ApprovalPrompt({ }) { if (!approval) return null; const question = approval.request?.questions[0] ?? null; + const highStakes = approval.highStakes; + const borderColor = highStakes ? theme.color.error : theme.color.attention; + const headerColor = highStakes ? theme.color.error : theme.color.attention; let title: string; - if (approval.mode === "question") title = "Input requested"; - else if (approval.highStakes) title = "High-stakes approval required"; - else title = "Approval required"; + if (approval.mode === "question") title = "INPUT REQUESTED"; + else if (highStakes) title = "HIGH-STAKES APPROVAL REQUIRED"; + else title = "APPROVAL REQUIRED"; - let footer: string; - if (approval.mode === "question") footer = "Type an answer, option number/value, deny, or cancel."; - else if (approval.highStakes) footer = "Type approve or deny, then press enter."; - else footer = "Press a to approve, d to deny."; + const showChips = approval.mode !== "question" && !highStakes; const card = ( - {title} - {question?.question ?? approval.description} + + ⚠ {title} + + {question?.question ?? approval.description} {question?.options?.length ? ( {question.options.slice(0, 6).map((option, index) => ( - + {index + 1}. {option.label}{option.description ? ` - ${option.description}` : ""} ))} ) : null} - {footer} + + {showChips ? ( + + + + + + ) : null} + + + + {approval.mode === "question" + ? "Type an answer, option number/value, deny, or cancel." + : highStakes + ? 'Type "approve" or "deny", then press enter.' + : "Press a to approve, d to deny."} + + ); if (!modal) return card; diff --git a/apps/ade-cli/src/tuiClient/components/ChatView.tsx b/apps/ade-cli/src/tuiClient/components/ChatView.tsx index 6381c7480..0b531f39f 100644 --- a/apps/ade-cli/src/tuiClient/components/ChatView.tsx +++ b/apps/ade-cli/src/tuiClient/components/ChatView.tsx @@ -3,23 +3,35 @@ import { Box, Text } from "ink"; import type { AgentChatEventEnvelope, AgentChatSessionSummary } from "../../../../desktop/src/shared/types/chat"; import type { LaneSummary } from "../../../../desktop/src/shared/types/lanes"; import type { LocalNotice } from "../types"; -import { renderChatLines, type AssistantMarkdownBlock, type RenderedChatLine } from "../format"; +import { + parseInlineRuns, + type AssistantMarkdownBlock, + type InlineRun, + type RenderedChatLine, +} from "../format"; +import { aggregateChatBlocks, type AggregatedBlock, type PlanStep, type WorkTool } from "../aggregate"; import { theme } from "../theme"; +import { useBrailleSpin, useDotPulse, useSpinFrame } from "../spinTick"; import { AdeWordmark } from "./AdeWordmark"; import { laneIconGlyph } from "./Header"; +import type { AdeCodeProvider } from "../types"; const HERO_TARGET_HALO_WIDTH = 56; const HERO_MIN_HALO_WIDTH = 28; const HERO_WORDMARK_MIN_USABLE = 24; const DEFAULT_VIEW_WIDTH = 88; +const BLANK_ROW_TEXT = " "; type RenderedChatRow = { id: string; text: string; - tone: RenderedChatLine["tone"] | "indicator"; + tone: RenderedChatLine["tone"] | "indicator" | "work" | "memory" | "plan" | "footer"; color?: string; dim?: boolean; bold?: boolean; + italic?: boolean; + rail?: string | null; + runs?: InlineRun[]; }; function textWidth(value: string): number { @@ -38,6 +50,16 @@ function alignRight(value: string, width: number): string { return `${repeat(" ", width - textWidth(value))}${value}`; } +function maxRenderedLineWidth(lines: string[]): number { + return lines.reduce((max, line) => Math.max(max, textWidth(line)), 0); +} + +function truncateEnd(value: string, max: number): string { + if (textWidth(value) <= max) return value; + if (max <= 1) return value.slice(0, Math.max(0, max)); + return `${[...value].slice(0, max - 1).join("")}…`; +} + function hardWrapWord(word: string, width: number): string[] { if (width <= 1) return [word]; const chars = [...word]; @@ -93,17 +115,98 @@ function wrapText(value: string, width: number, firstPrefix = "", restPrefix = f return rows; } +function runsPlainText(runs: InlineRun[]): string { + return runs.map((run) => run.text).join(""); +} + +function wrapInlineRuns(runs: InlineRun[], width: number, firstPrefix: string, restPrefix: string): InlineRun[][] { + type Segment = { text: string; style: Omit; isSpace: boolean }; + const segments: Segment[] = []; + for (const run of runs) { + const parts = run.text.split(/(\s+)/); + const style: Omit = {}; + if (run.bold) style.bold = true; + if (run.italic) style.italic = true; + if (run.code) style.code = true; + if (run.link) style.link = true; + for (const part of parts) { + if (!part) continue; + const isSpace = /^\s+$/.test(part); + segments.push({ text: isSpace ? " " : part, style, isSpace }); + } + } + + const lines: InlineRun[][] = []; + let currentRuns: InlineRun[] = []; + let currentWidth = textWidth(firstPrefix); + let prefix = firstPrefix; + let limit = width; + + const flush = () => { + while (currentRuns.length) { + const tail = currentRuns[currentRuns.length - 1]!; + if (/^\s+$/.test(tail.text)) currentRuns.pop(); + else break; + } + const row: InlineRun[] = prefix ? [{ text: prefix }, ...currentRuns] : currentRuns; + lines.push(row); + currentRuns = []; + currentWidth = textWidth(restPrefix); + prefix = restPrefix; + }; + + for (const seg of segments) { + if (seg.isSpace) { + if (currentRuns.length) { + currentRuns.push({ text: seg.text, ...seg.style }); + currentWidth += 1; + } + continue; + } + const segWidth = textWidth(seg.text); + if (currentRuns.length && currentWidth + segWidth > limit) { + flush(); + } + if (segWidth > limit - currentWidth) { + const room = Math.max(1, limit - currentWidth); + const chunks = hardWrapWord(seg.text, room); + for (let chunkIndex = 0; chunkIndex < chunks.length; chunkIndex += 1) { + const chunk = chunks[chunkIndex]!; + currentRuns.push({ text: chunk, ...seg.style }); + currentWidth += textWidth(chunk); + if (chunkIndex < chunks.length - 1) flush(); + } + continue; + } + currentRuns.push({ text: seg.text, ...seg.style }); + currentWidth += segWidth; + } + if (currentRuns.length) flush(); + if (!lines.length) lines.push([{ text: firstPrefix }]); + return lines; +} + function HeroDivider({ width }: { width: number }) { return {"─".repeat(Math.max(4, width))}; } -function HeroMetaRow({ label, value, color }: { label: string; value: string; color?: string }) { +function HeroMetaRow({ + label, + value, + color, + valueWidth, +}: { + label: string; + value: string; + color?: string; + valueWidth: number; +}) { + const labelWidth = 9; + const paddedValue = padRight(truncateEnd(value, Math.max(1, valueWidth)), Math.max(1, valueWidth)); return ( - - {label} - - {value} + {padRight(label, labelWidth)} + {paddedValue} ); } @@ -112,29 +215,42 @@ export function BootHero({ projectName, laneName, lane, + provider, + modelDisplay, width = DEFAULT_VIEW_WIDTH, + worktreeAvailable = true, }: { projectName: string; laneName: string; lane?: LaneSummary | null; + provider?: AdeCodeProvider | null; + modelDisplay?: string | null; width?: number; + worktreeAvailable?: boolean; }) { const laneColor = theme.lane(lane ?? null); const laneGlyph = laneIconGlyph(lane?.icon ?? null); const trimmedProject = projectName.trim(); const projectLabel = trimmedProject || "—"; const branchLabel = lane?.branchRef?.trim() || "—"; + const brand = provider ? theme.provider(provider) : null; + const modelLabel = brand + ? `${brand.glyph} ${brand.label}${modelDisplay ? ` · ${modelDisplay}` : ""}` + : "—"; - // Outer halo border + inner card border = 4 chars of horizontal chrome. - // Card border 2 + paddingX 4 + inner paddingX 2 = 8 chars between halo edge - // and content. Clamp so we don't blow out narrow terminals. const haloWidth = Math.max(HERO_MIN_HALO_WIDTH, Math.min(HERO_TARGET_HALO_WIDTH, width - 2)); 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; return ( + + + {"·".repeat(Math.max(8, haloWidth - 12))} + + A · D · E )} - - ade code - · v0.1 - + AGENTIC DEVELOPMENT ENVIRONMENT - - - - - type to chat + + + + - - / - commands - @ - files - ? - help - + + + {worktreeAvailable ? ( + + type + {" to chat "} + / + {" cmds "} + @ + {" files "} + ? + {" help"} + + ) : ( + + worktree missing + {" restore lane before chat"} + + )} + + + {"·".repeat(Math.max(8, haloWidth - 12))} + + ); } -function markdownRows(blocks: AssistantMarkdownBlock[], width: number, id: string): RenderedChatRow[] { - const rows: RenderedChatRow[] = []; - const pushWrapped = ( - text: string, - firstPrefix = "", - restPrefix = firstPrefix, - options: Partial = {}, - ) => { - for (const wrapped of wrapText(text, width, firstPrefix, restPrefix)) { - rows.push({ id, tone: "assistant", text: wrapped, color: theme.color.fg, ...options }); +// ───────────────────────────────────────────────────────────────────────────── +// Markdown rendering +// ───────────────────────────────────────────────────────────────────────────── + +function inlineRowsFromText( + text: string, + width: number, + id: string, + firstPrefix: string, + restPrefix: string, + options: { color?: string; dim?: boolean; bold?: boolean; italic?: boolean } = {}, +): RenderedChatRow[] { + const runs = parseInlineRuns(text); + const hasFormatting = runs.some((run) => run.bold || run.italic || run.code || run.link); + if (!hasFormatting) { + return wrapText(text, width, firstPrefix, restPrefix).map((line) => ({ + id, + tone: "assistant" as const, + text: line, + color: options.color ?? theme.color.fg, + dim: options.dim, + bold: options.bold, + italic: options.italic, + })); + } + const lineRuns = wrapInlineRuns(runs, width, firstPrefix, restPrefix); + return lineRuns.map((lineSegments) => ({ + id, + tone: "assistant" as const, + text: runsPlainText(lineSegments), + runs: lineSegments, + color: options.color ?? theme.color.fg, + dim: options.dim, + bold: options.bold, + italic: options.italic, + })); +} + +function tableRows( + block: Extract, + width: number, + id: string, +): RenderedChatRow[] { + const columns = Math.max(block.headers.length, ...block.rows.map((row) => row.length)); + if (columns === 0) return []; + const headerCells = padCells(block.headers, columns); + const bodyCells = block.rows.map((row) => padCells(row, columns)); + + const intrinsic = new Array(columns).fill(0); + for (let col = 0; col < columns; col += 1) { + const headerLen = textWidth(headerCells[col] ?? ""); + intrinsic[col] = Math.max(intrinsic[col]!, headerLen); + for (const row of bodyCells) { + intrinsic[col] = Math.max(intrinsic[col]!, textWidth(row[col] ?? "")); } + } + + const minCell = 3; + const borderChars = columns + 1; + const padChars = columns * 2; + const available = Math.max(columns * (minCell + 2) + borderChars, width - 2); + let target = available - borderChars - padChars; + if (target < columns * minCell) target = columns * minCell; + const intrinsicSum = intrinsic.reduce((acc, value) => acc + value, 0) || 1; + const colWidths = new Array(columns).fill(minCell); + let remaining = target; + for (let col = 0; col < columns; col += 1) { + const share = Math.max(minCell, Math.floor((intrinsic[col]! / intrinsicSum) * target)); + colWidths[col] = share; + remaining -= share; + } + // Distribute leftover space to the widest columns. + let pointer = 0; + while (remaining > 0) { + colWidths[pointer % columns]! += 1; + remaining -= 1; + pointer += 1; + } + + const buildRule = (left: string, mid: string, right: string): string => { + const segments = colWidths.map((w) => repeat("─", w + 2)); + return `${left}${segments.join(mid)}${right}`; + }; + + const wrapCells = (cells: string[]): string[][] => { + return cells.map((cell, col) => { + const w = colWidths[col]!; + if (textWidth(cell) <= w) return [cell]; + return wrapText(cell, w); + }); }; + const renderCellRow = (cells: string[]): string[] => { + const wrappedCells = wrapCells(cells); + const height = Math.max(1, ...wrappedCells.map((lines) => lines.length)); + const lines: string[] = []; + for (let line = 0; line < height; line += 1) { + const parts: string[] = []; + for (let col = 0; col < columns; col += 1) { + const cellLine = wrappedCells[col]?.[line] ?? ""; + parts.push(` ${padRight(cellLine, colWidths[col]!)} `); + } + lines.push(`│${parts.join("│")}│`); + } + return lines; + }; + + const out: RenderedChatRow[] = []; + const push = (text: string, opts: Partial = {}) => { + out.push({ id, tone: "assistant", text, color: theme.color.borderActive, rail: null, ...opts }); + }; + push(buildRule("┌", "┬", "┐")); + for (const line of renderCellRow(headerCells)) push(line, { color: theme.color.fg, bold: true }); + push(buildRule("├", "┼", "┤")); + for (const row of bodyCells) { + for (const line of renderCellRow(row)) push(line, { color: theme.color.fg }); + } + push(buildRule("└", "┴", "┘")); + return out; +} + +function padCells(cells: string[], width: number): string[] { + const out = cells.slice(0, width); + while (out.length < width) out.push(""); + return out; +} + +function markdownRows(blocks: AssistantMarkdownBlock[], width: number, id: string): RenderedChatRow[] { + const rows: RenderedChatRow[] = []; + for (const block of blocks) { - if (rows.length) rows.push({ id, tone: "assistant", text: "" }); + if (rows.length) rows.push(spacerRow(`${id}:markdown-spacer:${rows.length}`, "assistant")); if (block.kind === "heading") { - pushWrapped(block.text, "", "", { color: theme.color.accent, bold: true }); + rows.push(...inlineRowsFromText(block.text, width, id, "", "", { color: theme.color.accent, bold: true })); continue; } if (block.kind === "bullet") { - pushWrapped(block.text, "• ", " "); + rows.push(...inlineRowsFromText(block.text, width, id, "• ", " ")); continue; } if (block.kind === "numbered") { const prefix = `${block.number}. `; - pushWrapped(block.text, prefix, repeat(" ", textWidth(prefix))); + rows.push(...inlineRowsFromText(block.text, width, id, prefix, repeat(" ", textWidth(prefix)))); continue; } if (block.kind === "quote") { - pushWrapped(block.text, "> ", "> ", { dim: true }); + rows.push(...inlineRowsFromText(block.text, width, id, "> ", "> ", { dim: true })); continue; } if (block.kind === "code") { @@ -232,47 +477,246 @@ function markdownRows(blocks: AssistantMarkdownBlock[], width: number, id: strin rows.push({ id, tone: "assistant", text: " └", color: theme.color.border, dim: true }); continue; } + if (block.kind === "table") { + rows.push(...tableRows(block, width, id)); + continue; + } if (block.kind === "hr") { rows.push({ id, tone: "assistant", text: repeat("─", Math.min(width, 72)), color: theme.color.border, dim: true }); continue; } - pushWrapped(block.text); + rows.push(...inlineRowsFromText(block.text, width, id, "", "")); } return rows; } -function rowsForLine(line: RenderedChatLine, prevTone: RenderedChatLine["tone"] | null, width: number): RenderedChatRow[] { - const isChatTurn = line.tone === "user" || line.tone === "assistant"; - const speakerChanged = prevTone !== line.tone; - const showSpacer = isChatTurn && speakerChanged && prevTone !== null; +// ───────────────────────────────────────────────────────────────────────────── +// Block renderers +// ───────────────────────────────────────────────────────────────────────────── + +const LINK_COLOR = theme.color.info; +const MEMORY_COLOR = theme.color.tool; +const WORK_STATUS_COLOR: Record = { + running: theme.color.violet, + ok: theme.color.running, + failed: theme.color.error, +}; + +function formatDurationMs(ms: number | undefined): string | null { + if (typeof ms !== "number" || !Number.isFinite(ms) || ms < 0) return null; + if (ms < 1000) return `${Math.round(ms)}ms`; + const seconds = ms / 1000; + if (seconds < 60) return `${seconds.toFixed(1)}s`; + const minutes = Math.floor(seconds / 60); + const remSeconds = Math.round(seconds - minutes * 60); + return `${minutes}m ${remSeconds}s`; +} + +function workBlockRows( + block: Extract, + width: number, + brailleFrame: string, + spinFrame: string, +): RenderedChatRow[] { + if (!block.tools.length) return []; + const out: RenderedChatRow[] = []; + const total = block.tools.length; + const ok = block.tools.filter((tool) => tool.status === "ok").length; + const failed = block.tools.filter((tool) => tool.status === "failed").length; + + const headerText = block.live + ? `▸ working · ${total} tool${total === 1 ? "" : "s"} so far ${brailleFrame}` + : (() => { + const parts: string[] = [`▸ ${total} tool${total === 1 ? "" : "s"}`]; + if (ok > 0) parts.push(`${ok} ok`); + if (failed > 0) parts.push(`${failed} failed`); + const dur = formatDurationMs(block.durationMs); + if (dur) parts.push(dur); + return parts.join(" · "); + })(); + + out.push({ + id: block.id, + tone: "work", + text: headerText, + color: theme.color.t3, + rail: null, + }); + + type Display = { tool: WorkTool; faded: boolean }; + let displays: Display[]; + if (block.live) { + const recent = block.tools.slice(-3).reverse(); + displays = recent.map((tool, index) => ({ tool, faded: index === recent.length - 1 && total > 3 })); + } else { + const failedTools = block.tools.filter((tool) => tool.status === "failed"); + if (failedTools.length === 0) { + const lastOk = [...block.tools].reverse().find((tool) => tool.status === "ok"); + displays = lastOk ? [{ tool: lastOk, faded: false }] : []; + } else { + displays = failedTools.map((tool) => ({ tool, faded: false })); + if (failed < total) { + const lastOk = [...block.tools].reverse().find((tool) => tool.status === "ok"); + if (lastOk) displays.push({ tool: lastOk, faded: false }); + } + } + } + + for (const { tool, faded } of displays) { + const glyph = tool.status === "failed" ? "✗" : tool.status === "running" ? spinFrame : "✓"; + const statusColor = faded ? theme.color.t5 : WORK_STATUS_COLOR[tool.status]; + const dur = formatDurationMs(tool.durationMs); + const nameColor = faded ? theme.color.t5 : theme.color.t1; + const argColor = faded ? theme.color.t5 : theme.color.t3; + const tailColor = faded ? theme.color.t5 : theme.color.t4; + const argText = tool.arg ? ` · ${tool.arg}` : ""; + const tailText = dur ? ` ${dur}` : ""; + const runs: InlineRun[] = [ + { text: " " }, + { text: glyph, color: statusColor }, + { text: ` ${tool.tool}`, color: nameColor }, + ]; + if (argText) runs.push({ text: argText, color: argColor }); + if (tailText) runs.push({ text: tailText, color: tailColor }); + const lineText = ` ${glyph} ${tool.tool}${argText}${tailText}`; + out.push({ + id: `${block.id}:${tool.itemId}`, + tone: "work", + text: lineText, + runs, + rail: null, + }); + } + return out; +} + +function memoryRows(block: Extract, brailleFrame: string): RenderedChatRow[] { + const text = block.live + ? `· memory ${brailleFrame}` + : `· memory${typeof block.hitCount === "number" ? ` · ${block.hitCount} hit${block.hitCount === 1 ? "" : "s"}` : ""}`; + return [{ + id: block.id, + tone: "memory", + text, + color: MEMORY_COLOR, + rail: null, + }]; +} + +function compactionRows(block: Extract, brailleFrame: string): RenderedChatRow[] { + const preTokens = typeof block.preTokens === "number" ? ` · before ${block.preTokens.toLocaleString()} tokens` : ""; + const text = block.live + ? `⟳ compacting context · ${block.trigger} ${brailleFrame}` + : `⟳ context compacted · ${block.trigger}${preTokens}`; + return [{ + id: block.id, + tone: "work", + text, + color: theme.color.violet, + bold: block.live, + rail: null, + }]; +} + +function queuedSteerRows(block: Extract, width: number): RenderedChatRow[] { + const out: RenderedChatRow[] = [{ + id: block.id, + tone: "work", + text: "staged message · sends after turn", + color: theme.color.violet, + bold: true, + rail: null, + }]; + for (const line of wrapText(block.text, Math.max(8, width - 2), " ", " ")) { + out.push({ + id: `${block.id}:text`, + tone: "work", + text: line, + color: theme.color.t2, + rail: null, + }); + } + return out; +} + +function planRows(block: Extract, spinFrame: string): RenderedChatRow[] { + const out: RenderedChatRow[] = []; + out.push({ + id: block.id, + tone: "plan", + text: `PLAN · ${block.current}/${block.total}`, + color: theme.color.violet, + bold: true, + rail: null, + }); + for (const step of block.steps.slice(0, 12)) { + let glyph: string = "○"; + let color: string = theme.color.t4; + if (step.status === "completed") { + glyph = "✓"; + color = theme.color.running; + } else if (step.status === "in_progress") { + glyph = block.live ? spinFrame : "●"; + color = theme.color.running; + } else if (step.status === "failed") { + glyph = "✗"; + color = theme.color.error; + } + out.push({ + id: `${block.id}:${step.text}`, + tone: "plan", + text: `${glyph} ${step.text}`, + color, + rail: null, + }); + } + return out; +} + +function modelWorkingRows(dots: string): RenderedChatRow[] { + return [{ + id: "model-working", + tone: "work", + text: `✦ model working${dots}`, + color: theme.color.violet, + bold: true, + rail: null, + }]; +} + +// ───────────────────────────────────────────────────────────────────────────── +// Passthrough renderers (user-bubble, assistant-text, approval, error, notice) +// ───────────────────────────────────────────────────────────────────────────── + +function passthroughRows(line: RenderedChatLine, width: number): RenderedChatRow[] { const rows: RenderedChatRow[] = []; const push = (row: Omit) => rows.push({ id: line.id, ...row }); - if (showSpacer) push({ tone: line.tone, text: "" }); if (line.tone === "user") { - const bubbleWidth = Math.max(12, Math.min(width - 4, 78)); - const contentWidth = Math.max(1, bubbleWidth - 4); - if (line.header) push({ tone: "user", text: alignRight(line.header, width), dim: true }); + const maxContentWidth = Math.max(8, Math.min(width - 8, 74)); + const preliminaryRows = wrapText(line.body, maxContentWidth); + const contentWidth = Math.max(8, Math.min(maxContentWidth, maxRenderedLineWidth(preliminaryRows))); + const bubbleWidth = contentWidth + 4; + if (line.header) push({ tone: "user", text: alignRight(line.header, width), dim: true, rail: null }); const bodyRows = wrapText(line.body, contentWidth); - push({ tone: "user", text: alignRight(`╭${repeat("─", bubbleWidth - 2)}╮`, width), color: theme.color.accent }); + push({ tone: "user", text: alignRight(`╭${repeat("─", bubbleWidth - 2)}╮`, width), color: theme.color.accent, rail: null }); for (const bodyRow of bodyRows) { - push({ tone: "user", text: alignRight(`│ ${padRight(bodyRow, contentWidth)} │`, width), color: theme.color.fg }); + push({ tone: "user", text: alignRight(`│ ${padRight(bodyRow, contentWidth)} │`, width), color: theme.color.fg, rail: null }); } - push({ tone: "user", text: alignRight(`╰${repeat("─", bubbleWidth - 2)}╯`, width), color: theme.color.accent }); + push({ tone: "user", text: alignRight(`╰${repeat("─", bubbleWidth - 2)}╯`, width), color: theme.color.accent, rail: null }); return rows; } - if (line.tone === "tool" || line.tone === "error") { - const isErrorTone = line.tone === "error"; + if (line.tone === "error") { for (const text of line.body.split(/\r?\n/)) { for (const wrapped of wrapText(text, width, " ", " ")) { - push({ tone: line.tone, text: wrapped, color: isErrorTone ? theme.color.danger : theme.color.tool, dim: !isErrorTone }); + push({ tone: "error", text: wrapped, color: theme.color.danger }); } } return rows; } - if (line.tone === "reasoning" || line.tone === "notice" || line.tone === "approval") { + if (line.tone === "notice" || line.tone === "approval" || line.tone === "tool" || line.tone === "reasoning") { if (line.header) push({ tone: line.tone, text: line.header, color: theme.tone(line.tone), dim: true }); for (const wrapped of wrapText(line.body, width)) { push({ tone: line.tone, text: wrapped, color: theme.tone(line.tone), dim: line.tone !== "approval" }); @@ -292,44 +736,244 @@ function rowsForLine(line: RenderedChatLine, prevTone: RenderedChatLine["tone"] return rows; } -function rowsForLines(lines: RenderedChatLine[], width: number): RenderedChatRow[] { - return lines.flatMap((line, index) => rowsForLine(line, index > 0 ? lines[index - 1]!.tone : null, width)); +function rowsForBlock( + block: AggregatedBlock, + width: number, + brailleFrame: string, + spinFrame: string, +): RenderedChatRow[] { + switch (block.kind) { + case "user-bubble": + case "approval": + case "error": + case "notice": + return passthroughRows(block.line, width); + case "assistant-text": { + const rows = passthroughRows(block.line, width); + if (block.precededByHeavy) { + rows.unshift(spacerRow(`${block.id}:leading-spacer`, "assistant")); + } + return rows; + } + case "work-block": + return workBlockRows(block, width, brailleFrame, spinFrame); + case "memory": + return memoryRows(block, brailleFrame); + case "compaction": + return compactionRows(block, brailleFrame); + case "queued-steer": + return queuedSteerRows(block, width); + case "plan": + return planRows(block, spinFrame); + default: + return []; + } } -function sliceRows(rows: RenderedChatRow[], maxRows?: number, scrollOffsetRows = 0): RenderedChatRow[] { - if (!maxRows || maxRows <= 0 || rows.length <= maxRows) return rows; - let offset = Math.max(0, Math.min(scrollOffsetRows, rows.length - maxRows)); - for (let attempt = 0; attempt < 2; attempt += 1) { - const end = rows.length - offset; - const start = Math.max(0, end - maxRows); - const hasOlder = start > 0; - const hasNewer = end < rows.length; - const contentRows = Math.max(1, maxRows - (hasOlder ? 1 : 0) - (hasNewer ? 1 : 0)); - const nextEnd = rows.length - offset; - const nextStart = Math.max(0, nextEnd - contentRows); - const nextHasOlder = nextStart > 0; - const nextHasNewer = nextEnd < rows.length; - if (nextHasOlder === hasOlder && nextHasNewer === hasNewer) { - const visible = rows.slice(nextStart, nextEnd); - return [ - ...(nextHasOlder ? [{ id: "older-indicator", tone: "indicator" as const, text: "↑ older messages", dim: true }] : []), - ...visible, - ...(nextHasNewer ? [{ id: "newer-indicator", tone: "indicator" as const, text: "↓ newer messages", dim: true }] : []), - ]; +function rowsForBlocks( + blocks: AggregatedBlock[], + width: number, + brailleFrame: string, + spinFrame: string, +): RenderedChatRow[] { + const rows: RenderedChatRow[] = []; + let prevKind: AggregatedBlock["kind"] | null = null; + for (const block of blocks) { + if (prevKind && shouldInsertSpacer(prevKind, block.kind)) { + rows.push(spacerRow(`${block.id}:spacer`)); } - offset = Math.max(0, Math.min(offset, rows.length - contentRows)); + rows.push(...rowsForBlock(block, width, brailleFrame, spinFrame)); + prevKind = block.kind; + } + return rows; +} + +function shouldInsertSpacer(prev: AggregatedBlock["kind"], next: AggregatedBlock["kind"]): boolean { + if (prev === next) return false; + return true; +} + +function maxScrollOffsetForRows(rowCount: number, maxRows?: number): number { + if (!maxRows || maxRows <= 0 || rowCount <= maxRows) return 0; + return Math.max(0, rowCount - Math.max(1, maxRows - 1)); +} + +function spacerRow( + id: string, + tone: RenderedChatRow["tone"] = "indicator", +): RenderedChatRow { + return { id, tone, text: BLANK_ROW_TEXT, dim: true, rail: null }; +} + +function sliceRows(rows: RenderedChatRow[], maxRows?: number, scrollOffsetRows = 0): RenderedChatRow[] { + if (!maxRows || maxRows <= 0) return rows; + const viewportRows = Math.max(1, maxRows); + if (rows.length <= viewportRows) { + return [ + ...rows, + ...Array.from({ length: viewportRows - rows.length }, (_, index) => ( + spacerRow(`scroll-filler:${rows.length + index}`) + )), + ]; + } + const offset = Math.max(0, Math.min(scrollOffsetRows, maxScrollOffsetForRows(rows.length, viewportRows))); + const end = Math.max(1, rows.length - offset); + const hasNewer = offset > 0; + let contentRows = Math.max(1, viewportRows - (hasNewer ? 1 : 0)); + let start = Math.max(0, end - contentRows); + const hasOlder = start > 0; + if (hasOlder) { + contentRows = Math.max(1, viewportRows - 1 - (hasNewer ? 1 : 0)); + start = Math.max(0, end - contentRows); + } + const visible = rows.slice(start, end); + const result: RenderedChatRow[] = []; + if (hasOlder) { + result.push({ id: "older-indicator", tone: "indicator", text: "↑ older messages", dim: true, rail: null }); + } + result.push(...visible); + while (result.length < viewportRows - (hasNewer ? 1 : 0)) { + result.push(spacerRow(`scroll-filler:${result.length}`)); + } + if (hasNewer) { + result.push({ id: "newer-indicator", tone: "indicator", text: "↓ newer messages", dim: true, rail: null }); + } + return result; +} + +function railColorForTone(tone: RenderedChatRow["tone"]): string | null { + switch (tone) { + case "assistant": + return theme.color.t1; + case "tool": + return theme.color.tool; + case "reasoning": + return theme.color.violet; + case "notice": + return theme.color.t4; + case "error": + return theme.color.danger; + case "approval": + return theme.color.attention; + default: + return null; } - return rows.slice(-maxRows); +} + +function InlineSpans({ runs }: { runs: InlineRun[] }) { + return ( + <> + {runs.map((run, index) => { + const key = `${index}:${run.text}`; + if (run.code) { + return ( + + {run.text} + + ); + } + if (run.link) { + return ( + + {run.text} + + ); + } + return ( + + {run.text} + + ); + })} + + ); } function ChatRow({ row }: { row: RenderedChatRow }) { + const railColor = row.rail === undefined ? railColorForTone(row.tone) : row.rail; + const plainText = row.text || BLANK_ROW_TEXT; return ( - - {row.text} + + {railColor ? {"▎ "} : null} + {row.runs ? : plainText} ); } +function renderedRowText(row: RenderedChatRow): string { + if (!row.runs && row.text === BLANK_ROW_TEXT) return ""; + return row.runs ? runsPlainText(row.runs) : row.text; +} + +function isPaginationIndicatorRow(row: RenderedChatRow): boolean { + return row.id === "older-indicator" || row.id === "newer-indicator"; +} + +export function renderChatTranscriptPlainText({ + events, + notices, + activeSession, + expandedLineIds, + maxRows, + scrollOffsetRows = 0, + width = DEFAULT_VIEW_WIDTH, +}: { + events: AgentChatEventEnvelope[]; + notices: LocalNotice[]; + activeSession: AgentChatSessionSummary | null; + expandedLineIds?: Set; + maxRows?: number; + scrollOffsetRows?: number; + width?: number; +}): string { + const blocks = aggregateChatBlocks({ + events, + notices, + activeSession, + expandedLineIds, + }); + const innerWidth = Math.max(24, width - 4); + return sliceRows(rowsForBlocks(blocks, innerWidth, "·", "◐"), maxRows, scrollOffsetRows) + .filter((row) => !isPaginationIndicatorRow(row)) + .map(renderedRowText) + .join("\n") + .trimEnd(); +} + +export function computeChatScrollMaxOffset({ + events, + notices, + activeSession, + expandedLineIds, + maxRows, + streaming = false, + width = DEFAULT_VIEW_WIDTH, +}: { + events: AgentChatEventEnvelope[]; + notices: LocalNotice[]; + activeSession: AgentChatSessionSummary | null; + expandedLineIds?: Set; + maxRows?: number; + streaming?: boolean; + width?: number; +}): number { + const blocks = aggregateChatBlocks({ + events, + notices, + activeSession, + expandedLineIds, + }); + if (!blocks.length && !streaming) return 0; + const innerWidth = Math.max(24, width - 4); + const rowCount = rowsForBlocks(blocks, innerWidth, "·", "◐").length + (streaming ? modelWorkingRows("").length : 0); + return maxScrollOffsetForRows(rowCount, maxRows); +} + export function ChatView({ events, notices, @@ -337,6 +981,10 @@ export function ChatView({ projectName, laneName, lane, + provider, + modelDisplay, + streaming = false, + worktreeAvailable = true, expandedLineIds, maxRows, scrollOffsetRows = 0, @@ -348,18 +996,46 @@ export function ChatView({ projectName: string; laneName: string; lane?: LaneSummary | null; + provider?: AdeCodeProvider | null; + modelDisplay?: string | null; + streaming?: boolean; + worktreeAvailable?: boolean; expandedLineIds?: Set; maxRows?: number; scrollOffsetRows?: number; width?: number; }) { - const lines = renderChatLines({ events, notices, activeSession, expandedLineIds, maxLines: 200 }); - if (!lines.length) { - return ; + const blocks = aggregateChatBlocks({ + events, + notices, + activeSession, + expandedLineIds, + }); + const brailleFrame = useBrailleSpin(); + const spinFrame = useSpinFrame(); + const dotPulse = useDotPulse(); + if (!blocks.length && !streaming) { + return ( + + ); } - const rows = sliceRows(rowsForLines(lines, Math.max(24, width - 2)), maxRows, scrollOffsetRows); + const innerWidth = Math.max(24, width - 4); + const baseRows = rowsForBlocks(blocks, innerWidth, brailleFrame, spinFrame); + const rows = sliceRows( + streaming ? [...baseRows, ...modelWorkingRows(dotPulse)] : baseRows, + maxRows, + scrollOffsetRows, + ); return ( - + {rows.map((row, index) => ( ))} diff --git a/apps/ade-cli/src/tuiClient/components/Drawer.tsx b/apps/ade-cli/src/tuiClient/components/Drawer.tsx index 846f0d6b4..b8f29e000 100644 --- a/apps/ade-cli/src/tuiClient/components/Drawer.tsx +++ b/apps/ade-cli/src/tuiClient/components/Drawer.tsx @@ -1,21 +1,112 @@ import React from "react"; import { Box, Text, useStdout } from "ink"; import type { AgentChatSessionSummary } from "../../../../desktop/src/shared/types/chat"; +import type { DiffLineStats } from "../../../../desktop/src/shared/types/git"; import type { LaneSummary } from "../../../../desktop/src/shared/types/lanes"; -import { formatLaneLabel, formatSessionLabel } from "../format"; +import { formatSessionLabel } from "../format"; +import { computeStackRowMeta, sortLanesForStackGraph } from "../laneTree"; +import { useSpinFrame } from "../spinTick"; +import { theme, type LaneStatusKind } from "../theme"; +import type { AdeCodeProvider } from "../types"; -const PURPLE = "#A78BFA"; -const AMBER = "#F59E0B"; +type DrawerDensity = "full" | "mini"; +type DrawerMode = "lanes" | "chats"; + +const DRAWER_WIDTH_FULL = 32; +const DRAWER_WIDTH_MINI = 22; + +export type DrawerPrSummary = { + number: number; + state: "open" | "closed" | "merged"; + checksPassed: number; + checksTotal: number; +}; export function visibleDrawerLaneCount(panelHeight: number, laneCount: number): number { - const lanesMaxRows = Math.max(2, Math.floor(panelHeight / 2) - 3); - return Math.min(laneCount, 10, lanesMaxRows); + // Full drawer uses compact lane cards; leave room for a chat group + hints. + const lanesMaxRows = Math.max(2, Math.floor((panelHeight - 5) / 4)); + return Math.min(laneCount, 12, lanesMaxRows); } export function visibleDrawerChatCount(chatCount: number): number { return Math.min(chatCount, 12); } +/** Derive a wireframe-bucket status for a lane from its data + active session. */ +function deriveLaneStatus( + lane: LaneSummary, + sessions: AgentChatSessionSummary[], + activeLaneId: string | null, + unavailableLaneIds: ReadonlySet, +): LaneStatusKind { + if (lane.laneType === "primary") return "primary"; + if (unavailableLaneIds.has(lane.id)) return "failed"; + const laneSessions = sessions.filter((s) => s.laneId === lane.id); + const hasActive = laneSessions.some((s) => s.status === "active"); + const awaiting = laneSessions.some((s) => s.awaitingInput); + if (lane.status?.rebaseInProgress) return "failed"; + if (awaiting) return "attention"; + if (hasActive || lane.id === activeLaneId) return "running"; + return "idle"; +} + +function truncate(text: string, max: number): string { + if (max <= 1) return text.slice(0, max); + if (text.length <= max) return text; + return `${text.slice(0, Math.max(1, max - 1))}…`; +} + +function pad(text: string, width: number): string { + if (text.length >= width) return text; + return text + " ".repeat(width - text.length); +} + +function formatAgeMs(ms: number): string { + if (!Number.isFinite(ms) || ms < 0) return ""; + const s = Math.floor(ms / 1000); + if (s < 60) return `${s}s`; + const m = Math.floor(s / 60); + if (m < 60) return `${m}m`; + const h = Math.floor(m / 60); + if (h < 24) return `${h}h`; + const d = Math.floor(h / 24); + return `${d}d`; +} + +function formatLaneAge(lane: LaneSummary): string { + const ts = Date.parse(lane.createdAt); + if (Number.isNaN(ts)) return ""; + return formatAgeMs(Date.now() - ts); +} + +function formatSessionAge(session: AgentChatSessionSummary): string { + const ref = session.endedAt ?? session.idleSinceAt ?? session.startedAt; + if (!ref) return ""; + const ts = Date.parse(ref); + if (Number.isNaN(ts)) return ""; + if (session.status === "active") return "now"; + return formatAgeMs(Date.now() - ts); +} + +function laneDetailSuffix(lane: LaneSummary, diffStats: DiffLineStats | null, worktreeAvailable: boolean): { + diff: { add: number; del: number } | null; + hint: string | null; +} { + if (!worktreeAvailable) { + return { diff: null, hint: "missing worktree" }; + } + if (diffStats) { + return { diff: { add: diffStats.additions, del: diffStats.deletions }, hint: null }; + } + if (lane.status?.rebaseInProgress) { + return { diff: null, hint: "rebase in progress" }; + } + if (lane.status?.dirty) { + return { diff: null, hint: "dirty" }; + } + return { diff: null, hint: `checkpoint ${formatLaneAge(lane)}` }; +} + export function Drawer({ lanes, sessions, @@ -26,6 +117,12 @@ export function Drawer({ selectedChatIndex, panelHeight, focused = false, + density = "full", + mode = "lanes", + prByLaneId = {}, + diffByLaneId = {}, + loading = false, + unavailableLaneIds = new Set(), }: { lanes: LaneSummary[]; sessions: AgentChatSessionSummary[]; @@ -36,48 +133,516 @@ export function Drawer({ selectedChatIndex: number; panelHeight?: number; focused?: boolean; + density?: DrawerDensity; + mode?: DrawerMode; + prByLaneId?: Record; + diffByLaneId?: Record; + loading?: boolean; + unavailableLaneIds?: ReadonlySet; }) { const { stdout } = useStdout(); const resolvedPanelHeight = panelHeight ?? stdout?.rows ?? 40; - const laneSessions = sessions - .filter((session) => session.laneId === browsingLaneId) - .slice(0, visibleDrawerChatCount(sessions.length)); - const laneRows = lanes.slice(0, visibleDrawerLaneCount(resolvedPanelHeight, lanes.length)); + const ordered = React.useMemo(() => sortLanesForStackGraph(lanes), [lanes]); + const rowMeta = React.useMemo(() => computeStackRowMeta(ordered), [ordered]); + const laneRows = ordered.slice(0, visibleDrawerLaneCount(resolvedPanelHeight, ordered.length)); + const visibleRowMeta = rowMeta.slice(0, laneRows.length); + + const browsing = browsingLaneId ?? activeLaneId; + const browsingLane = laneRows.find((l) => l.id === browsing) ?? null; + const laneSessions = browsingLane + ? sessions.filter((s) => s.laneId === browsingLane.id).slice(0, visibleDrawerChatCount(sessions.length)) + : []; + + const width = density === "mini" ? DRAWER_WIDTH_MINI : DRAWER_WIDTH_FULL; + const borderColor = focused ? theme.color.violet : theme.color.border; + + if (density === "mini") { + return ( + + ); + } + return ( - - - LANES - {laneRows.map((lane, index) => ( - - {index === selectedLaneIndex ? "›" : " "} {lane.id === activeLaneId ? "●" : lane.id === browsingLaneId ? "◐" : "○"} {formatLaneLabel(lane).slice(0, 20)} - - ))} - - {selectedLaneIndex === laneRows.length ? "›" : " "} + new lane + + + + LANES · {loading && lanes.length === 0 ? "…" : lanes.length} + + + + + {loading && laneRows.length === 0 ? ( + + Loading lanes… + + ) : null} + {laneRows.map((lane, index) => { + const isSelected = index === selectedLaneIndex; + const meta = visibleRowMeta[index] ?? { depth: 0, isLast: false, prefix: "" }; + const worktreeAvailable = !unavailableLaneIds.has(lane.id); + const status = deriveLaneStatus(lane, sessions, activeLaneId, unavailableLaneIds); + const isBrowsing = lane.id === browsing; + return ( + + + {mode === "chats" && isBrowsing && browsingLane?.id === lane.id ? ( + + ) : null} + + ); + })} + + + + + {!focused ? ( + "\n" + ) : mode === "chats" ? ( + <> + ↑↓{" "} + {browsingLane && unavailableLaneIds.has(browsingLane.id) ? "lane unavailable" : "view chats"} + {"\n"} + Esc back to lanes + + ) : ( + <> + ↑↓ view ·{" "} + {" "} + {laneRows[selectedLaneIndex] && unavailableLaneIds.has(laneRows[selectedLaneIndex].id) ? "details" : "open"} + + )} + + + + = laneRows.length ? theme.color.violet : theme.color.t4} + bold={focused && mode === "lanes" && selectedLaneIndex >= laneRows.length} + > + + new lane - - CHATS - {laneSessions.length === 0 ? ( - No chats in lane. - ) : laneSessions.map((session, index) => ( - - {index === selectedChatIndex ? "›" : " "} {formatSessionLabel(session).slice(0, 22)} + + ); +} + +/** + * Full two-line lane row: + * line 1: rail/prefix · name · [chip] + * line 2: exec · branch · detail · age + */ +function LaneCard({ + lane, + status, + prefix, + width, + selected, + active, + provider, + pr, + diffStats, + worktreeAvailable, +}: { + lane: LaneSummary; + status: LaneStatusKind; + prefix: string; + width: number; + selected: boolean; + active: boolean; + provider: AdeCodeProvider | null; + pr: DrawerPrSummary | null; + 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 chipText = ((): string => { + switch (status) { + case "primary": return "PRIMARY"; + case "running": return "run"; + case "attention": return "wait"; + case "failed": return worktreeAvailable ? "fail" : "miss"; + default: return "idle"; + } + })(); + 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; + default: return theme.color.t4; + } + })(); + + // 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 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 nameMax = Math.max(3, contentWidth - indicatorWidth - chipWidth - (canShowPrPill ? prPillWidth + 1 : 0) - 1); + 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 ?? ""; + 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 truncBranch = truncate(branch, branchMax); + const truncDetail = detailText ? truncate(detailText, detailMax) : ""; + + return ( + + + + {prefix ? ( + {prefix} + ) : ( + + {theme.rail} + {" "} + + )} + + {pad(name, nameMax)} - ))} - - {selectedChatIndex === laneSessions.length ? "›" : " "} + new chat + + {chipText} + {canShowPrPill && pr ? ( + <> + + + + ) : null} + + + + + {line2Indent} + {provider ? ( + {exec.glyph} + ) : ( + · + )} + {truncBranch} + {truncDetail ? ( + <> + · + {detail.diff && truncDetail === detailText ? ( + + +{detail.diff.add} + + −{detail.diff.del} + + ) : ( + {truncDetail} + )} + + ) : null} + {canShowAge && age ? ( + <> + · + {age} + + ) : null} + + + + ); +} + +function formatPrPillText(pr: DrawerPrSummary): string { + return `[#${pr.number} ·${pr.checksPassed}/${pr.checksTotal}]`; +} + +function PrPill({ pr }: { pr: DrawerPrSummary }) { + const checksColor = pr.checksPassed === pr.checksTotal ? theme.color.running : theme.color.attention; + return ( + + [# + {pr.number} + · + {pr.checksPassed} + /{pr.checksTotal} + ] + + ); +} + +/** + * Chat block rendered beneath the browsing lane row, with a violet left border + * matching DFChat from the wireframe. + */ +function ChatBlock({ + sessions, + activeSessionId, + selectedChatIndex, + width, + worktreeAvailable, +}: { + sessions: AgentChatSessionSummary[]; + activeSessionId: string | null; + selectedChatIndex: number; + width: number; + worktreeAvailable: boolean; +}) { + if (!worktreeAvailable) { + return ( + + + + CHATS · unavailable + + + + worktree missing + + + ); + } + if (sessions.length === 0 && selectedChatIndex !== 0) { + return ( + + + No chats in lane. + + ); + } + const max = Math.max(8, width - 4); + return ( + + + + CHATS · {sessions.length} + + {sessions.map((session, index) => { + const running = session.status === "active"; + const selected = index === selectedChatIndex; + const provider = (session.provider as AdeCodeProvider) ?? null; + const exec = theme.provider(provider); + const when = formatSessionAge(session); + const label = truncate(formatSessionLabel(session), max - 6); + const titleColor = running ? theme.color.violet : selected ? theme.color.t1 : theme.color.t2; + return ( + + + {exec.glyph} + + {label} + + + {running ? : null} + {when} + {session.sessionId === activeSessionId ? ( + + ) : null} + + ); + })} + + + + + new chat + + + + ); +} + +function ActiveChatSpin() { + const frame = useSpinFrame(); + return {frame} ; +} + +/** Mini-row drawer variant (single-line rows). Matches D3MiniRow in the wireframe. */ +function MiniDrawer({ + width, + borderColor, + lanes, + sessions, + activeLaneId, + activeSessionId, + browsingLaneId, + selectedLaneIndex, + selectedChatIndex, + rowMeta, + rawSessions, + focused, + mode, + loading, + unavailableLaneIds, +}: { + width: number; + borderColor: string; + lanes: LaneSummary[]; + sessions: AgentChatSessionSummary[]; + activeLaneId: string | null; + activeSessionId: string | null; + browsingLaneId: string | null; + selectedLaneIndex: number; + selectedChatIndex: number; + rowMeta: Array<{ depth: number; isLast: boolean; prefix: string }>; + rawSessions: AgentChatSessionSummary[]; + focused: boolean; + mode: DrawerMode; + loading: boolean; + unavailableLaneIds: ReadonlySet; +}) { + void focused; + void browsingLaneId; + const inner = width - 2; + return ( + + + + LANES · {loading && lanes.length === 0 ? "…" : lanes.length} + + + {loading && lanes.length === 0 ? ( + + Loading lanes… + + ) : null} + {lanes.map((lane, index) => { + const status = deriveLaneStatus(lane, rawSessions, activeLaneId, unavailableLaneIds); + const meta = rowMeta[index] ?? { depth: 0, prefix: "", isLast: false }; + const selected = index === selectedLaneIndex; + const detail = formatLaneAge(lane); + const nameMax = Math.max(4, inner - 3 - detail.length - meta.prefix.length); + return ( + + + {theme.rail} + + {meta.prefix ? {meta.prefix} : } + + {pad(truncate(lane.name, nameMax), nameMax)} + + {detail} + + ); + })} + {mode === "chats" ? ( + <> + + + CHATS · {sessions.length} + + + {sessions.map((session, index) => { + const selected = index === selectedChatIndex; + const running = session.status === "active"; + const provider = (session.provider as AdeCodeProvider) ?? null; + const exec = theme.provider(provider); + const when = formatSessionAge(session); + const nameMax = Math.max(4, inner - 3 - when.length); + return ( + + {exec.glyph} + + + {pad(truncate(formatSessionLabel(session), nameMax), nameMax)} + + {when} + + ); + })} + + {lanes[selectedLaneIndex] && unavailableLaneIds.has(lanes[selectedLaneIndex].id) ? ( + worktree missing + ) : ( + + + new chat + + )} + + + ) : null} + + + {!focused ? "\n" : mode === "chats" ? "↑↓ chats · Esc lanes" : "↑↓ lanes · ↵ open"} + + + + = lanes.length ? theme.color.violet : theme.color.t4} + bold={focused && mode === "lanes" && selectedLaneIndex >= lanes.length} + > + + lane ); } + +/** Best-effort: detect the exec/provider for a lane from its sessions. */ +function sessionProviderFor( + lane: LaneSummary, + sessions: AgentChatSessionSummary[], +): AdeCodeProvider | null { + // Prefer the most recently-active session in the lane. Fall back to the + // most-recently-started. + const laneSessions = sessions.filter((s) => s.laneId === lane.id); + if (!laneSessions.length) return null; + const ordered = [...laneSessions].sort((a, b) => { + const aTs = Date.parse(a.lastActivityAt ?? a.startedAt ?? ""); + const bTs = Date.parse(b.lastActivityAt ?? b.startedAt ?? ""); + if (!Number.isNaN(aTs) && !Number.isNaN(bTs)) return bTs - aTs; + return 0; + }); + const top = ordered[0]; + return (top?.provider as AdeCodeProvider) ?? null; +} diff --git a/apps/ade-cli/src/tuiClient/components/FooterControls.tsx b/apps/ade-cli/src/tuiClient/components/FooterControls.tsx index a7f6d503a..92239c7f9 100644 --- a/apps/ade-cli/src/tuiClient/components/FooterControls.tsx +++ b/apps/ade-cli/src/tuiClient/components/FooterControls.tsx @@ -1,6 +1,11 @@ import React from "react"; import { Box, Text } from "ink"; import { theme } from "../theme"; +import type { AdeCodeProvider } from "../types"; + +const TOKEN_BAR_CELLS = 10; +type FooterPane = "drawer" | "chat" | "details"; +type FooterDrawerMode = "lanes" | "chats"; function Toggle({ label, @@ -37,41 +42,234 @@ function Hint({ keyLabel, action }: { keyLabel: string; action: string }) { ); } +function tokenBarColor(percent: number): string { + if (percent >= 95) return theme.color.danger; + if (percent >= 80) return theme.color.warning; + if (percent >= 50) return theme.color.accent; + return theme.color.running; +} + +function TokenBar({ percent }: { percent: number }) { + const safe = Math.max(0, Math.min(100, percent)); + const filled = Math.max(0, Math.min(TOKEN_BAR_CELLS, Math.round((safe / 100) * TOKEN_BAR_CELLS))); + const empty = TOKEN_BAR_CELLS - filled; + const color = tokenBarColor(safe); + return ( + + {"▓".repeat(filled)} + {"░".repeat(empty)} + + ); +} + export function FooterControls({ drawerOpen, rightOpen, + activePane = "chat", + drawerMode = "lanes", drawerFocused, detailsFocused, footerControlActive, + provider, + modelDisplay, + reasoningEffort, + permissionLabel, + contextPercent, + tokenSummary, + approvalActive, + liveAgentCount, + rightPaneShowsAgents, + agentsAvailable, + agentsOpen, + agentsFocused, + fastMode, + pendingSteerCount = 0, + chatScrollOffset = 0, + chatScrollMaxOffset = 0, }: { drawerOpen: boolean; rightOpen: boolean; + activePane?: FooterPane; + drawerMode?: FooterDrawerMode; drawerFocused: boolean; detailsFocused: boolean; footerControlActive: boolean; + provider?: AdeCodeProvider | null; + modelDisplay?: string | null; + reasoningEffort?: string | null; + permissionLabel?: string | null; + contextPercent?: number | null; + tokenSummary?: string | null; + approvalActive?: boolean; + liveAgentCount?: number; + rightPaneShowsAgents?: boolean; + agentsAvailable?: boolean; + agentsOpen?: boolean; + agentsFocused?: boolean; + fastMode?: boolean; + pendingSteerCount?: number; + chatScrollOffset?: number; + chatScrollMaxOffset?: number; }) { - return ( - - - - - - - - {footerControlActive ? ( - ↵ toggle ← → choose ↑ exit - ) : ( + const brand = provider ? theme.provider(provider) : null; + const hasModeBar = Boolean(brand || modelDisplay || fastMode || reasoningEffort || permissionLabel || contextPercent != null || tokenSummary || pendingSteerCount > 0); + const showAgentsToggle = agentsAvailable === true; + const showAgentsChip = (liveAgentCount ?? 0) > 0 && !rightPaneShowsAgents && !showAgentsToggle; + const actionHint = (() => { + if (activePane === "drawer" && drawerMode === "chats") { + return ( + <> + + + + + ); + } + if (activePane === "drawer") { + return ( + <> + + + + + ); + } + if (activePane === "details") { + return ( + <> + + + + + ); + } + const safeScrollMax = Math.max(0, chatScrollMaxOffset); + const safeScrollOffset = Math.max(0, Math.min(chatScrollOffset, safeScrollMax)); + return ( + <> + + {safeScrollMax > 0 ? {` ${safeScrollOffset}/${safeScrollMax}`} : null} + + + {pendingSteerCount > 0 ? ( <> - - - - - - + - )} - + ) : null} + + ); + })(); + return ( + + {hasModeBar ? ( + + + {brand ? ( + {brand.glyph} {brand.label} + ) : null} + {modelDisplay ? ( + <> + {" · "} + {modelDisplay} + + ) : null} + {fastMode ? ( + <> + {" · "} + fast + + ) : null} + {reasoningEffort ? ( + <> + {" · "} + {reasoningEffort} + + ) : null} + {permissionLabel ? ( + <> + {" · "} + {permissionLabel} + + ) : null} + {pendingSteerCount > 0 ? ( + <> + {" · "} + staged {pendingSteerCount} + + ) : null} + + {contextPercent != null || tokenSummary ? ( + + {contextPercent != null ? ( + <> + + {` ${contextPercent}%`} + + ) : null} + {tokenSummary ? ( + <> + {" "} + {tokenSummary} + + ) : null} + + ) : null} + + ) : null} + + + {showAgentsToggle ? ( + <> + + {" "} + + ) : null} + + {" "} + + {showAgentsChip ? ( + <> + {" "} + + {`[● ${liveAgentCount} agent${liveAgentCount === 1 ? "" : "s"} · ^a]`} + + + ) : null} + + + {approvalActive ? ( + <> + a + {" approve "} + d + {" deny · "} + ← → + {" choose"} + + ) : footerControlActive ? ( + ↵ toggle ← → choose ↑ exit + ) : ( + <> + {actionHint} + + + + + + )} + + ); } diff --git a/apps/ade-cli/src/tuiClient/components/Header.tsx b/apps/ade-cli/src/tuiClient/components/Header.tsx index 6318653bb..9340665bc 100644 --- a/apps/ade-cli/src/tuiClient/components/Header.tsx +++ b/apps/ade-cli/src/tuiClient/components/Header.tsx @@ -17,9 +17,29 @@ export function laneIconGlyph(icon: LaneIcon | null | undefined): string { return LANE_ICON_GLYPH[icon] ?? "▎"; } -export function Header({ projectName, lane }: { projectName: string; lane: LaneSummary | null }) { +export function Header({ + projectName, + lane, + chatTitle, +}: { + projectName: string; + lane: LaneSummary | null; + chatTitle?: string | null; +}) { const laneColor = theme.lane(lane); - const showProject = projectName.trim() && projectName.trim().toLowerCase() !== "ade"; + const normalizedProject = projectName.trim().toLowerCase(); + const branchRef = lane?.branchRef?.trim() ?? ""; + const worktreeName = lane?.worktreePath?.split(/[\\/]/).filter(Boolean).pop()?.toLowerCase() ?? ""; + const projectRepeatsBranch = Boolean( + normalizedProject + && lane + && ( + branchRef.toLowerCase().endsWith(normalizedProject) + || worktreeName === normalizedProject + ), + ); + const showProject = Boolean(normalizedProject && normalizedProject !== "ade" && !projectRepeatsBranch); + const chatLabel = chatTitle?.trim() || null; return ( - + {" ADE "} {showProject ? ( <> - {" "} + {" │ "} {projectName} ) : null} {lane ? ( <> - {" "} - {laneIconGlyph(lane.icon)} {formatLaneLabel(lane)} + {" │ "} + lane + {laneIconGlyph(lane.icon)} + + {formatLaneLabel(lane)} ) : null} {lane?.branchRef ? ( <> {" "} - ⎇ {lane.branchRef} + branch + {lane.branchRef} + + ) : null} + {chatLabel ? ( + <> + {" "} + chat + {chatLabel} ) : null} diff --git a/apps/ade-cli/src/tuiClient/components/MentionPalette.tsx b/apps/ade-cli/src/tuiClient/components/MentionPalette.tsx index 5477c91b0..2bdc5f843 100644 --- a/apps/ade-cli/src/tuiClient/components/MentionPalette.tsx +++ b/apps/ade-cli/src/tuiClient/components/MentionPalette.tsx @@ -1,34 +1,171 @@ import React from "react"; import { Box, Text } from "ink"; +import { theme } from "../theme"; import type { MentionSuggestion } from "../types"; -const COLORS: Record = { - lane: "#F59E0B", - chat: "#A78BFA", - pr: "cyan", - file: "green", - commit: "yellow", +const VISIBLE_ROWS = 5; +export const MENTION_PALETTE_ROWS = VISIBLE_ROWS + 3; +const DEFAULT_PALETTE_WIDTH = 88; +const MAX_PALETTE_WIDTH = 104; +const MIN_PALETTE_WIDTH = 56; +const TYPE_COLUMN_WIDTH = 8; +const NAME_COLUMN_WIDTH = 34; + +const KIND_LABELS: Record = { + lane: "Lane", + chat: "Chat", + pr: "PR", + file: "File", + commit: "Commit", }; +function clampPaletteWidth(width?: number): number { + const available = Number.isFinite(width) + ? Math.floor(width ?? DEFAULT_PALETTE_WIDTH) + : DEFAULT_PALETTE_WIDTH; + return Math.max(MIN_PALETTE_WIDTH, Math.min(MAX_PALETTE_WIDTH, available)); +} + +function basename(path: string): string { + const parts = path.split(/[\\/]/).filter(Boolean); + return parts[parts.length - 1] ?? path; +} + +function extensionFor(path: string | undefined): string { + if (!path) return "none"; + const name = basename(path); + const dot = name.lastIndexOf("."); + return dot > 0 && dot < name.length - 1 ? name.slice(dot + 1) : "none"; +} + +function useLabel(suggestion: MentionSuggestion): string { + switch (suggestion.kind) { + case "file": + return `Adds file context${extensionFor(suggestion.filePath ?? suggestion.label) !== "none" ? ` (${extensionFor(suggestion.filePath ?? suggestion.label)})` : ""}`; + case "commit": + return "Adds a commit reference"; + case "pr": + return "Adds a pull request reference"; + case "lane": + return "Adds a lane/worktree reference"; + case "chat": + return "Adds a chat transcript reference"; + } +} + +function endTruncate(value: string, max: number): string { + if (max <= 1) return value.length ? "…" : ""; + if (value.length <= max) return value; + return `${value.slice(0, Math.max(0, max - 1))}…`; +} + +function textWidth(value: string): number { + return [...value].length; +} + +function padEnd(value: string, width: number): string { + return `${value}${" ".repeat(Math.max(0, width - textWidth(value)))}`; +} + +function fillLine(value: string, width: number): string { + return padEnd(endTruncate(value, width), width); +} + +function topLine(label: string, width: number): string { + const bodyWidth = Math.max(1, width - 2); + const content = ` ${label} `; + return `┌${fillLine(content, bodyWidth).replace(/ +$/u, (spaces) => "─".repeat(spaces.length))}┐`; +} + +function bodyLine(value: string, width: number): string { + return `│${fillLine(` ${value}`, Math.max(1, width - 2))}│`; +} + +function bottomLine(value: string, width: number): string { + return `└${fillLine(` ${value}`, Math.max(1, width - 2)).replace(/ +$/u, (spaces) => "─".repeat(spaces.length))}┘`; +} + +function paletteLine(value: string, color: string) { + return ( + + {value} + + ); +} + export function MentionPalette({ suggestions, selectedIndex, + query = "", + width, }: { suggestions: MentionSuggestion[]; selectedIndex: number; + query?: string; + width?: number; }) { if (!suggestions.length) return null; + const paletteWidth = clampPaletteWidth(width); + const nameWidth = Math.min(NAME_COLUMN_WIDTH, Math.max(20, Math.floor(paletteWidth * 0.4))); + const detailWidth = Math.max(12, paletteWidth - TYPE_COLUMN_WIDTH - nameWidth - 9); + const total = suggestions.length; + const safeIndex = Math.max(0, Math.min(selectedIndex, total - 1)); + const half = Math.floor(VISIBLE_ROWS / 2); + let start = Math.max(0, safeIndex - half); + let end = Math.min(total, start + VISIBLE_ROWS); + start = Math.max(0, end - VISIBLE_ROWS); + const window = suggestions.slice(start, end); + const selected = suggestions[safeIndex]; + const queryLabel = query.trim() ? `@${query.trim()}` : "@"; + const selectedTitle = + selected.kind === "file" + ? basename(selected.filePath ?? selected.label) + : selected.label; + const insertedText = selected.insertText; + const selectedSummary = endTruncate( + `${selectedTitle} · ${useLabel(selected)} · ${insertedText}`, + paletteWidth - 6, + ); + const aboveCount = start; + const belowCount = total - end; + const moreSummary = [ + aboveCount ? `${aboveCount} above` : null, + belowCount ? `${belowCount} below` : null, + ].filter(Boolean).join(" · "); + const header = topLine(`References · ${queryLabel} · ${total} match${total === 1 ? "" : "es"}`, paletteWidth); + const rowLines = window.map((suggestion, index) => { + const absoluteIndex = start + index; + const isSelected = absoluteIndex === safeIndex; + const title = suggestion.kind === "file" + ? basename(suggestion.filePath ?? suggestion.label) + : suggestion.label; + const detail = suggestion.detail ?? suggestion.filePath ?? useLabel(suggestion); + return { + selected: isSelected, + value: bodyLine( + `${isSelected ? theme.rail : " "} ${fillLine(KIND_LABELS[suggestion.kind], TYPE_COLUMN_WIDTH)} ${fillLine(title, nameWidth)} ${endTruncate(detail, detailWidth)}`, + paletteWidth, + ), + }; + }); + while (rowLines.length < VISIBLE_ROWS) { + rowLines.push({ selected: false, value: bodyLine("", paletteWidth) }); + } + const footer = bottomLine( + `${moreSummary ? `${moreSummary} · ` : ""}↑↓ move · Tab insert · Esc close`, + paletteWidth, + ); + return ( - - {suggestions.slice(0, 8).map((suggestion, index) => ( - - {index === selectedIndex ? "›" : " "} - {suggestion.kind.padEnd(6)} - {suggestion.label.slice(0, 28).padEnd(28)} - {suggestion.detail ?? ""} - + + {paletteLine(header, theme.color.violet)} + {rowLines.map((line, index) => ( + + {paletteLine(line.value, line.selected ? theme.color.t1 : theme.color.t2)} + ))} - tab inserts selected reference + {paletteLine(bodyLine(selectedSummary, paletteWidth), theme.color.t3)} + {paletteLine(footer, theme.color.t4)} ); } diff --git a/apps/ade-cli/src/tuiClient/components/ModelStatus.tsx b/apps/ade-cli/src/tuiClient/components/ModelStatus.tsx index 3e091ee34..70becffdb 100644 --- a/apps/ade-cli/src/tuiClient/components/ModelStatus.tsx +++ b/apps/ade-cli/src/tuiClient/components/ModelStatus.tsx @@ -1,89 +1,44 @@ import React from "react"; import { Box, Text } from "ink"; -import type { AdeCodeProvider } from "../types"; import { theme } from "../theme"; -const BAR_CELLS = 10; - -function meterColor(percent: number): string { - if (percent >= 95) return theme.color.danger; - if (percent >= 80) return theme.color.warning; - return theme.color.accent; -} - -function ContextMeter({ percent, summary }: { percent: number; summary: string | null }) { - const filled = Math.max(0, Math.min(BAR_CELLS, Math.round((percent / 100) * BAR_CELLS))); - const empty = BAR_CELLS - filled; - const color = meterColor(percent); - return ( - - {"▓".repeat(filled)} - {"░".repeat(empty)} - {` ${percent}%`} - {summary ? {` ${summary}`} : null} - - ); -} - export function ModelStatus({ - provider, - displayName, - reasoningEffort, - permissionLabel, - fastMode, - draftChatActive, - contextPercent, - tokenSummary, statusLineText, vimMode, + fastMode, + draftChatActive, }: { - provider: AdeCodeProvider; - displayName: string; - reasoningEffort: string | null; - permissionLabel: string; - fastMode?: boolean; - draftChatActive?: boolean; - contextPercent?: number | null; - tokenSummary?: string | null; statusLineText?: string | null; vimMode?: "insert" | "normal" | null; + fastMode?: boolean; + draftChatActive?: boolean; }) { - const brand = theme.provider(provider); const statusRows = statusLineText?.split(/\r?\n/).filter(Boolean).slice(0, 3) ?? []; + const extras: React.ReactNode[] = []; + if (vimMode) { + extras.push( + {vimMode}, + ); + } + if (fastMode) { + extras.push(fast); + } + if (draftChatActive) { + extras.push(next chat); + } + if (!statusRows.length && !extras.length) return null; return ( - + {extras.length ? ( - {brand.glyph} {brand.label} - · - {displayName} - · - {reasoningEffort ?? "no reasoning"} - · - {permissionLabel} - {vimMode ? ( - <> - · - {vimMode} - - ) : null} - {fastMode ? ( - <> - · - fast - - ) : null} - {draftChatActive ? ( - <> - · - next chat - - ) : null} + {extras.map((node, index) => ( + + {index > 0 ? {" · "} : null} + {node} + + ))} - {!statusRows.length && contextPercent != null ? ( - - ) : null} - + ) : null} {statusRows.map((line, index) => ( {line} ))} diff --git a/apps/ade-cli/src/tuiClient/components/RightPane.tsx b/apps/ade-cli/src/tuiClient/components/RightPane.tsx index 4a5ed7d1d..2ea1176f0 100644 --- a/apps/ade-cli/src/tuiClient/components/RightPane.tsx +++ b/apps/ade-cli/src/tuiClient/components/RightPane.tsx @@ -1,349 +1,1082 @@ import React from "react"; import { Box, Text } from "ink"; -import type { ProviderReadinessRow, RightPaneContent, SubagentPaneTab, SubagentSnapshot } from "../types"; +import type { + AdeCodeProvider, + ProviderReadinessRow, + RightPaneContent, + SetupPaneRow, + SubagentSnapshot, +} from "../types"; import { theme } from "../theme"; +import { useSpinFrame } from "../spinTick"; +import { buildSubagentPaneRows, type SubagentPaneRow, type SubagentPaneSection } from "../subagentPane"; -const STATUS_DOT: Record = { - ready: "●", - unknown: "◐", - unavailable: "○", -}; - -export const LANE_DETAIL_ACTIONS: ReadonlyArray<{ label: string; slashCommand: string }> = [ - { label: "stage all", slashCommand: "/stage all" }, - { label: "commit", slashCommand: "/commit" }, - { label: "push", slashCommand: "/push" }, - { label: "pull", slashCommand: "/pull" }, +// --------------------------------------------------------------------------- +// Right-pane width / focus chrome +// --------------------------------------------------------------------------- + +const DEFAULT_PANE_WIDTH = 38; +const LANE_LABEL_WIDTH = 10; +const LANE_FILE_PREVIEW_ROWS = 5; +const MODEL_LABEL_WIDTH = 14; +const PROVIDER_ORDER: AdeCodeProvider[] = ["claude", "codex", "cursor", "droid", "opencode"]; + +// --------------------------------------------------------------------------- +// Actions for the lane-details pane (5 rows · wireframe) +// --------------------------------------------------------------------------- + +export const LANE_DETAIL_ACTIONS: ReadonlyArray<{ + k: string; + label: string; + slashCommand: string; + detail?: string; +}> = [ + { 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: "rebase onto main", slashCommand: "/rebase" }, ]; -function statusColor(status: ProviderReadinessRow["status"]): string { - if (status === "ready") return theme.color.success; - if (status === "unknown") return theme.color.warning; - return theme.color.mutedFg; +// --------------------------------------------------------------------------- +// Tiny atom helpers (inline replacements for Chip / Rail / SectionLG / Kbd / +// ActionRow / Exec / ModelRow / ProvTab from the wireframe primitives) +// --------------------------------------------------------------------------- + +function Chip({ + status, + children, +}: { + status: "running" | "attention" | "error" | "info" | "idle" | "done"; + children: React.ReactNode; +}) { + const colorMap: Record = { + running: theme.color.running, + attention: theme.color.attention, + error: theme.color.error, + info: theme.color.info, + idle: theme.color.t4, + done: theme.color.done, + } as const; + const color = colorMap[status]; + return ( + + [ {children}] + + ); +} + +function SectionHead({ title, hint }: { title: string; hint?: string }) { + return ( + + {title} + {hint ? {hint} : null} + + ); +} + +function ModelSectionHead({ + title, + hint, + width, + marginTop = 1, +}: { + title: string; + hint?: string; + width: number; + marginTop?: number; +}) { + const hintWidth = hint ? hint.length + 1 : 0; + const lineWidth = Math.max(2, width - title.length - hintWidth - 3); + return ( + + {title} + {"─".repeat(lineWidth)} + {hint ? {hint} : null} + + ); +} + +function ActionRow({ + k, + label, + detail, + selected, +}: { + k: string; + label: string; + detail?: string; + selected?: boolean; +}) { + return ( + + + {selected ? theme.rail : " "} + + + {" "}[{k}] {label} + + {detail ? {detail} : null} + + ); +} + +function ExecGlyph({ provider }: { provider: AdeCodeProvider | "shell" | "copilot" | null | undefined }) { + if (!provider) return ·; + if (provider === "shell") return $; + if (provider === "copilot") return ; + const brand = theme.provider(provider as AdeCodeProvider); + return {brand.glyph}; } function tailTruncate(value: string, max: number): string { + if (max <= 1) return value.length ? "…" : ""; if (value.length <= max) return value; return `…${value.slice(value.length - (max - 1))}`; } -function subagentStatusGlyph(status: SubagentSnapshot["status"]): string { - if (status === "running") return "●"; - if (status === "completed") return "✓"; - if (status === "failed") return "!"; - return "○"; +function endTruncate(value: string, max: number): string { + if (max <= 1) return value.length ? "…" : ""; + if (value.length <= max) return value; + return `${value.slice(0, Math.max(0, max - 1))}…`; +} + +function compactPath(value: string, max: number): string { + if (value.length <= max) return value; + const parts = value.split("/").filter(Boolean); + for (let count = Math.min(4, parts.length); count >= 1; count -= 1) { + const candidate = `…/${parts.slice(-count).join("/")}`; + if (candidate.length <= max) return candidate; + } + return tailTruncate(value, max); +} + +function formatTokens(tok: number | null | undefined): string { + if (tok == null) return "—"; + if (tok >= 1000) return `${(tok / 1000).toFixed(1)}k`; + return `${tok}`; +} + +function formatElapsed(ms: number | null | undefined): string { + if (ms == null) return "—"; + const s = Math.max(1, Math.round(ms / 1000)); + if (s < 60) return `${s}s`; + const m = Math.floor(s / 60); + if (m < 60) return `${m}m`; + const h = Math.floor(m / 60); + return `${h}h`; +} + +// --------------------------------------------------------------------------- +// Lane-details renderer (wireframe MainChatFinal · right column) +// --------------------------------------------------------------------------- + +function LaneSectionHead({ title, hint, width }: { title: string; hint?: string; width: number }) { + const hintWidth = hint ? hint.length + 1 : 0; + const lineWidth = Math.max(2, width - title.length - hintWidth - 6); + return ( + + {title} + {"─".repeat(lineWidth)} + {hint ? {hint} : null} + + ); +} + +function LaneMetaRow({ label, children }: { label: string; children: React.ReactNode }) { + return ( + + {label.padEnd(LANE_LABEL_WIDTH)} + {children} + + ); } -function subagentRuntime(snapshot: SubagentSnapshot): string { - const parts = []; - if (snapshot.tokens != null) parts.push(`${snapshot.tokens >= 1000 ? `${(snapshot.tokens / 1000).toFixed(1)}k` : snapshot.tokens} tok`); - if (snapshot.durationMs != null) parts.push(`${Math.max(1, Math.round(snapshot.durationMs / 1000))}s`); - return parts.join(" · ") || snapshot.status; +function LaneFileRow({ + file, + width, +}: { + file: { path: string; status: "M" | "A" | "D" | "?"; staged: boolean }; + width: number; +}) { + const statusColor = file.status === "D" + ? theme.color.error + : file.status === "A" || file.status === "?" + ? theme.color.running + : theme.color.attention; + const pathWidth = Math.max(10, width - 8); + return ( + + {file.staged ? "●" : "○"} + {file.status} + {compactPath(file.path, pathWidth)} + + ); } -function SubagentsPane({ tab, snapshots }: { tab: SubagentPaneTab; snapshots: SubagentSnapshot[] }) { - const rows = snapshots.filter((snapshot) => { - if (tab === "background") return snapshot.kind === "background"; - if (tab === "teammates") return snapshot.kind === "teammate"; - return snapshot.kind === "subagent"; - }); - const tabs: Array<[SubagentPaneTab, string]> = [["subagents", "Subagents"], ["teammates", "Teammates"], ["background", "Background"]]; +function LaneRunSummary({ + run, + width, +}: { + run: NonNullable["run"]>; + width: number; +}) { + const brand = theme.provider(run.provider); + const running = run.status === "running"; + const parts = [ + formatElapsed(run.elapsedMs), + run.tokenSummary, + ].filter((part): part is string => Boolean(part && part !== "—")); + const summary = parts.join(" · "); + const toolSummary = run.toolSummary ? tailTruncate(run.toolSummary, Math.max(8, width - 18)) : null; return ( - Subagents - - {tabs.map(([key, label], index) => ( - - {index ? " " : ""}{key === tab ? `[${label}]` : label} - - ))} + + + + {` ${running ? "running" : "idle"}`} + + {summary ? {` ${summary}`} : null} + + + {brand.label}{toolSummary ? ` · ${toolSummary}` : ""} - {rows.length ? rows.map((snapshot) => ( - - - {subagentStatusGlyph(snapshot.status)} {tailTruncate(snapshot.name, 18)} {snapshot.kind} + + ); +} + +function LaneDetailsPane({ + content, + width, +}: { + content: Extract; + width: number; +}) { + const lane = content.lane; + const worktreeMissing = content.worktreeAvailable === false; + const status = lane.status; + const activeRun = content.run?.status === "running"; + const laneKind: "running" | "attention" | "idle" | "failed" | "primary" = ((): "running" | "attention" | "idle" | "failed" | "primary" => { + if (worktreeMissing) return "failed"; + if (lane.laneType === "primary") return "primary"; + if (status.rebaseInProgress) return "attention"; + if (status.dirty || status.ahead > 0 || status.behind > 0) return "attention"; + return "idle"; + })(); + const railColor = activeRun ? theme.color.running : theme.laneStatusColor(laneKind); + const chipStatus: "running" | "attention" | "idle" = ((): "running" | "attention" | "idle" => { + if (activeRun) return "running"; + if (laneKind === "attention" || laneKind === "failed") return "attention"; + return "idle"; + })(); + const runElapsed = activeRun && content.run?.elapsedMs != null ? formatElapsed(content.run.elapsedMs) : null; + const chipLabel = ((): string => { + if (activeRun) return `running${runElapsed ? ` · ${runElapsed}` : ""}`; + if (worktreeMissing) return "missing"; + if (laneKind === "primary") return "primary"; + if (laneKind === "attention") return status.dirty ? "dirty" : "sync"; + return "idle"; + })(); + + const git = content.git; + const workingClean = git.staged + git.unstaged === 0; + const filesCount = Math.max(git.total, content.files.length); + const contentWidth = Math.max(18, width - 4); + const remoteLabel = git.remote && git.remote !== lane.branchRef ? git.remote : null; + const changedRows = content.files.slice(0, content.showFiles ? 9 : LANE_FILE_PREVIEW_ROWS); + const remainingFiles = Math.max(0, filesCount - changedRows.length); + + return ( + + + {theme.rail} + {endTruncate(lane.name, Math.max(10, contentWidth - 16))} + + {chipLabel} + + ⎇ {tailTruncate(lane.branchRef, contentWidth - 2)} + {remoteLabel ? {tailTruncate(remoteLabel, contentWidth)} : null} + {worktreeMissing ? ( + {tailTruncate(lane.worktreePath, contentWidth)} + ) : null} + + + + + ● {worktreeMissing ? "worktree missing" : workingClean ? "clean" : "dirty"} + + + + 0 ? theme.color.running : theme.color.t4}>↑{git.ahead} + ↓{git.behind} + {remoteLabel ? {tailTruncate(remoteLabel, Math.max(5, contentWidth - LANE_LABEL_WIDTH - 8))} : null} + + + none predicted + + {worktreeMissing ? ( + <> + + Restore this lane worktree before starting chats or running git actions. + + ) : null} + + + + {changedRows.length ? changedRows.map((file) => ( + + )) : ( + No changed files. + )} + {remainingFiles > 0 ? ( + {`… ${remainingFiles} more`} + ) : null} + + {git.staged} staged + {git.unstaged} unstaged + {git.additions || git.deletions ? ( + <> + +{git.additions} + −{git.deletions} + + ) : null} + + + + {!worktreeMissing ? ( + <> + + + {LANE_DETAIL_ACTIONS.map((action, idx) => ( + + ))} + + + ) : null} + + {/* PR */} + {content.pr ? ( + <> + + + + {content.pr.state} + + #{content.pr.number} + + + {content.pr.checksPassed}/{content.pr.checksTotal} ✓ + + {content.pr.url ? tailTruncate(content.pr.url, 20) : ""} + + + ) : null} + + + {content.run ? ( + + ) : ( + no active chat for this lane + )} + + + {worktreeMissing ? "t files" : "↑↓ move · ↵ run · tab next section · esc close"} + + + ); +} + +// --------------------------------------------------------------------------- +// Model-setup renderer (wireframe ModelFinal) +// --------------------------------------------------------------------------- + +function ProviderTabStrip({ + providerRows, + activeProvider, + width, +}: { + providerRows: ProviderReadinessRow[]; + activeProvider: AdeCodeProvider; + width: number; +}) { + const rowsByProvider = new Map(providerRows.map((row) => [row.provider, row])); + const orderedRows = [ + rowsByProvider.get(activeProvider), + ...PROVIDER_ORDER.filter((provider) => provider !== activeProvider).map((provider) => rowsByProvider.get(provider)), + ...providerRows.filter((row) => !PROVIDER_ORDER.includes(row.provider) && row.provider !== activeProvider), + ].filter((row): row is ProviderReadinessRow => Boolean(row)); + + return ( + + + + {orderedRows.map((row) => { + const brand = theme.provider(row.provider); + const isActive = row.provider === activeProvider; + const statusColor = row.status === "ready" + ? theme.color.running + : row.status === "unknown" + ? theme.color.attention + : theme.color.t4; + return ( + + [ + + + {" "}{brand.wordmark} + + ] + + ); + })} + + + + ready · + + active · + + needs login + + + ); +} + +function ModelRow({ + row, + selected, + width, +}: { + row: SetupPaneRow; + selected: boolean; + width: number; +}) { + const rail = selected ? {theme.rail} : ; + const detail = row.detail?.replace(/,\s*/g, " · ") ?? ""; + const valueWidth = Math.max(8, width - MODEL_LABEL_WIDTH - detail.length - 6); + return ( + + + {rail} + + {" "}{row.label.padEnd(MODEL_LABEL_WIDTH)} + + + {endTruncate(row.value, valueWidth)} + + + {detail ? ( + + {endTruncate(detail, Math.max(6, width - MODEL_LABEL_WIDTH - valueWidth - 4))} + + ) : null} + + ); +} + +function ActiveProviderStatus({ row }: { row: ProviderReadinessRow }) { + const modelCount = row.modelCount > 0 ? `${row.modelCount} available` : "none discovered"; + const statusLabel = ((): string => { + if (row.status === "ready") return "ready"; + if (row.status === "unknown") return "needs attention"; + return "needs login"; + })(); + const statusColor = ((): string => { + if (row.status === "ready") return theme.color.running; + if (row.status === "unknown") return theme.color.attention; + return theme.color.error; + })(); + return ( + + + + auth + · + {statusLabel} + + + + models + · + {modelCount} + + + + runtime + · + {tailTruncate(row.detail, 48)} + + + ); +} + +function ModelSetupPane({ + content, + selectedIndex, + width, +}: { + content: Extract; + selectedIndex: number; + width: number; +}) { + const contentWidth = Math.max(30, width - 2); + const cyclableRows = content.rows.filter((row) => row.cyclable === true); + const actionRows = content.rows.filter((row) => row.cyclable !== true); + const activeProviderRow = + content.providerRows.find((row) => row.provider === content.activeProvider) ?? null; + + return ( + + {/* Provider tab strip */} + + + {/* MODEL section */} + + {cyclableRows.map((row) => { + const absoluteIndex = content.rows.indexOf(row); + return ( + + ); + })} + + {/* RUN section (actions like Refresh / Open settings / Login) */} + {actionRows.length ? ( + <> + + {actionRows.map((row) => { + const absoluteIndex = content.rows.indexOf(row); + const selected = absoluteIndex === selectedIndex; + const glyph = ((): string => { + if (row.kind === "refresh-status") return "↻"; + if (row.kind === "open-settings") return "↗"; + return "→"; + })(); + return ( + + ); + })} + + ) : null} + + {/* STATUS · */} + + {activeProviderRow ? ( + + ) : null} + + + + ← → provider · ↑↓ field · ↵ apply · l login + {content.checkedAt ? ` · ${content.checkedAt.slice(11, 19)}` : ""} + + + + ); +} + +// --------------------------------------------------------------------------- +// Agents pane (Subagents · Teammates) — htop process table, runtime-adaptive +// --------------------------------------------------------------------------- + +function subagentExec(snapshot: SubagentSnapshot, provider: AdeCodeProvider): AdeCodeProvider | "copilot" { + if (snapshot.kind === "teammate") return "copilot"; + return provider; +} + +function subagentAgentKind(status: SubagentSnapshot["status"]): "running" | "ok" | "waiting" | "error" { + if (status === "running") return "running"; + if (status === "completed") return "ok"; + if (status === "stopped") return "waiting"; + return "error"; +} + +function SpinningAgentGlyph({ color }: { color: string }) { + const frame = useSpinFrame(); + return {frame}; +} + +function subagentSectionTitle(section: SubagentPaneSection): string { + if (section === "main") return "MAIN"; + if (section === "teammates") return "TEAMMATES"; + if (section === "background") return "BACKGROUND"; + if (section === "recent") return "RECENT · DONE"; + return "SUBAGENTS"; +} + +function subagentSectionCount(rows: SubagentPaneRow[], section: SubagentPaneSection): number { + return rows.filter((row) => row.section === section).length; +} + +function SubagentMainRow({ + selected, + provider, + nameWidth, +}: { + selected: boolean; + provider: AdeCodeProvider; + nameWidth: number; +}) { + const brand = theme.provider(provider); + return ( + + + {selected ? theme.rail : " "} + + + 00 + + + {` ${endTruncate("main", nameWidth).padEnd(nameWidth)}`} + + {" — — —"} + + {` ${brand.label} · main chat`} + + ); +} + +function SubagentRow({ + snapshot, + displayIndex, + selected, + provider, + animateRunningGlyph, + nameWidth, +}: { + snapshot: SubagentSnapshot; + displayIndex: number; + selected: boolean; + provider: AdeCodeProvider; + animateRunningGlyph: boolean; + nameWidth: number; +}) { + const kind = subagentAgentKind(snapshot.status); + const glyph = theme.agentStatusGlyph(kind); + const glyphColor = theme.agentStatusColor(kind); + const tok = formatTokens(snapshot.tokens ?? null); + const elapsed = formatElapsed(snapshot.durationMs ?? null); + const cost = "—"; + const id = String(displayIndex).padStart(2, "0"); + const exec = subagentExec(snapshot, provider); + const faded = snapshot.status === "stopped" || snapshot.status === "failed"; + const nameColor = selected ? theme.color.violet : faded ? theme.color.t4 : theme.color.t1; + const detail = snapshot.lastToolName || snapshot.summary; + + return ( + + + {/* rail/select */} + + {selected ? theme.rail : " "} + + + {/* status glyph */} + {kind === "running" && animateRunningGlyph ? ( + + ) : ( + {glyph} + )} + + {/* id */} + {id} + + {/* exec glyph + name */} + + {` ${endTruncate(snapshot.name, nameWidth).padEnd(nameWidth)}`} + {/* tok / elapsed / cost (right side; not literally right-aligned in Ink) */} + {` ${tok.padStart(5)}`} + {` ${elapsed.padStart(4)}`} + {` ${cost.padStart(3)}`} + + {detail ? ( + {endTruncate(detail, Math.max(12, nameWidth + 16))} + ) : null} + + ); +} + +function SubagentsPane({ + content, + selectedIndex, + width, +}: { + content: Extract; + selectedIndex: number; + width: number; +}) { + const provider = content.provider; + const isDroid = provider === "droid"; + + if (isDroid && content.snapshots.length === 0) { + return ( + + + + Droid runs over ACP, which doesn't model - {subagentRuntime(snapshot)}{snapshot.lastToolName ? ` · ${snapshot.lastToolName}` : ""} - {snapshot.summary ? {tailTruncate(snapshot.summary, 30)} : null} - )) : {tab === "teammates" ? "No teammate sessions." : tab === "background" ? "No background sessions." : "No subagents in this chat."}} - tab cycles tabs + + subagents yet — see agentclientprotocol.com. + + + See /status for session state. + + + ); + } + + const rows = buildSubagentPaneRows(content); + const snapshots = rows.filter((row): row is Extract => row.kind === "snapshot"); + const subagentsCount = subagentSectionCount(rows, "subagents") + subagentSectionCount(rows, "recent"); + const teammatesCount = subagentSectionCount(rows, "teammates"); + const backgroundCount = subagentSectionCount(rows, "background"); + const selected = Math.max(0, Math.min(selectedIndex, Math.max(0, rows.length - 1))); + const nameWidth = Math.max(4, Math.min(22, width - 27)); + let runCount = 0; + let doneCount = 0; + let waitCount = 0; + let totalTok = 0; + let totalMs = 0; + for (const { snapshot: s } of snapshots) { + const k = subagentAgentKind(s.status); + if (k === "running") runCount++; + else if (k === "ok") doneCount++; + else if (k === "waiting") waitCount++; + if (s.tokens) totalTok += s.tokens; + if (s.durationMs) totalMs += s.durationMs; + } + + return ( + + {/* Tab strip */} + + + Subagents · {subagentsCount} + Teammates · {teammatesCount} + + Background · {backgroundCount} + + + {/* Column header */} + + {` ID ${"NAME".padEnd(nameWidth + 2)} TOK ELAPSED $`} + + + {/* Rows */} + {rows.map((row, i) => { + const previous = rows[i - 1]; + const showSection = row.section !== "main" && previous?.section !== row.section; + const displayIndex = i; + return ( + + {showSection ? ( + + {subagentSectionTitle(row.section)} + {"─".repeat(Math.max(3, width - subagentSectionTitle(row.section).length - 6))} + + ) : null} + {row.kind === "main" ? ( + + ) : ( + + )} + + ); + })} + + {/* Footer summary */} + {snapshots.length ? ( + + + {` ${runCount} run · `} + + {` ${doneCount} done · `} + + {` ${waitCount} wait`} + + ) : null} + {snapshots.length ? ( + + {formatTokens(totalTok)} tok · {formatElapsed(totalMs)} + + ) : null} + + + + ↑↓ select · transcript follows · tab returns main + + ); } +// --------------------------------------------------------------------------- +// Other content modes (status, list, details, diff, models, effort, form, +// new-chat-setup, help, empty) — kept compact, refreshed to use theme tokens. +// --------------------------------------------------------------------------- + function HelpPane() { return ( - Help - ctrl-o opens or focuses lanes and chats - ctrl-p opens or focuses setup - shift-tab cycles pane focus - esc closes the active side pane - ctrl-c interrupts a running chat; press again to quit - / opens commands, @ opens references, tab inserts selected - /ade status forces ADE's TUI command when a runtime owns /status + ctrl-o opens or focuses lanes and chats + ctrl-p opens or focuses info + shift-tab cycles pane focus + esc closes the active side pane + ctrl-c interrupts a running chat; press again to quit + / opens commands, @ opens references, tab inserts selected + /ade status forces ADE's TUI command when a runtime owns /status ); } +// --------------------------------------------------------------------------- +// Pane title resolution +// --------------------------------------------------------------------------- + +function paneTitle(content: RightPaneContent): { title: string; hint?: string } { + switch (content.kind) { + case "lane-details": + return { + title: `${endTruncate(content.lane.name.toUpperCase(), 22)} · ${content.worktreeAvailable === false ? "MISSING" : "FOCUSED"}`, + }; + case "model-setup": + return { title: "SETUP · MODEL", hint: new Date().toISOString().slice(11, 19) }; + case "new-chat-setup": + return { title: "NEW CHAT" }; + case "subagents": + return { title: `AGENTS · ${theme.provider(content.provider).label.toUpperCase()}`, hint: "tab · cycle" }; + case "help": + return { title: "HELP" }; + case "status": + return { title: "STATUS" }; + case "diff": + return { title: content.title.toUpperCase() }; + case "list": + return { title: content.title.toUpperCase() }; + case "details": + return { title: content.title.toUpperCase() }; + case "models": + return { title: "MODEL" }; + case "effort": + return { title: "EFFORT" }; + case "form": + return { title: content.title.toUpperCase() }; + default: + return { title: "PANE" }; + } +} + +// --------------------------------------------------------------------------- +// Main right pane component +// --------------------------------------------------------------------------- + export function RightPane({ content, formValues = {}, activeFormField = 0, selectedIndex = 0, focused = false, + width = DEFAULT_PANE_WIDTH, }: { content: RightPaneContent; formValues?: Record; activeFormField?: number; selectedIndex?: number; focused?: boolean; + activeProvider?: AdeCodeProvider | null; + width?: number; }) { - const paneTitle = content.kind === "lane-details" ? content.lane.name.toUpperCase() : "SETUP"; + const { title, hint } = paneTitle(content); + const paneWidth = Math.max(30, width); + return ( - - {paneTitle}{focused ? " · focused" : ""} + + {/* Pane header */} + + + {title} + + {hint ? {hint} : null} + + {content.kind === "empty" ? ( - Run /status, /diff, /model, or /help. + Run /status, /diff, /model, or /help. ) : null} + {content.kind === "help" ? : null} + {content.kind === "status" ? ( - Status {content.rows.map(([key, value]) => ( - {key.padEnd(10)} {value} + + {key.padEnd(10)} {value} + ))} ) : null} + {content.kind === "list" ? ( - {content.title} {content.rows.length ? content.rows.map((row, index) => ( - - {content.action ? `${index === selectedIndex ? "›" : " "} ${row}` : row} + + {content.action ? `${index === selectedIndex ? theme.rail : " "} ${row}` : row} - )) : {content.emptyText ?? "No data."}} - {content.action && content.rows.length ? arrows move · enter opens : null} + )) : {content.emptyText ?? "No data."}} + {content.action && content.rows.length ? ( + arrows move · enter opens + ) : null} ) : null} + {content.kind === "details" ? ( - - {content.title} - {content.body} - + {content.body} ) : null} + {content.kind === "diff" ? ( - {content.title} {content.files.length ? content.files.map((file) => ( - {file.path} +{file.additions ?? 0} -{file.deletions ?? 0} - {file.body ? {file.body.split(/\r?\n/).slice(0, 8).join("\n")} : null} + + {file.path}{" "} + + +{file.additions ?? 0} -{file.deletions ?? 0} + + + {file.body ? ( + + {file.body.split(/\r?\n/).slice(0, 8).join("\n")} + + ) : null} - )) : No changes.} + )) : No changes.} ) : null} + {content.kind === "models" ? ( - Model {content.models.map((model, index) => ( - - {index === selectedIndex ? "›" : " "} {(model.modelId ?? model.id) === content.activeModelId ? "●" : "○"} {model.displayName} + + {index === selectedIndex ? theme.rail : " "}{" "} + {(model.modelId ?? model.id) === content.activeModelId ? "●" : "○"} {model.displayName} ))} - arrows move · enter applies + arrows move · enter applies ) : null} - {content.kind === "lane-details" ? ( - - {content.lane.branchRef} - - {content.git.staged + content.git.unstaged > 0 ? "DIRTY" : "CLEAN"} ↑{content.git.ahead} ↓{content.git.behind} - - {content.git.remote ? {content.git.remote} : null} - - - - Changes - (t to toggle) - - {content.showFiles ? ( - content.files.length ? ( - content.files.slice(0, 8).map((file) => ( - {file.status} {file.path.slice(0, 26)}{file.staged ? " ●" : ""} - )) - ) : ( - No changes. - ) - ) : ( - <> - {content.git.staged} staged · {content.git.unstaged} unstaged - {content.git.total} files total - - )} - - - - Actions - {LANE_DETAIL_ACTIONS.map((action, index) => ( - - {index === content.selectedActionIndex ? "›" : " "} {action.label} - - ))} - - {content.pr ? ( - - Pull request - - {content.selectedActionIndex === LANE_DETAIL_ACTIONS.length ? "›" : " "} #{content.pr.number} {content.pr.state} {content.pr.checksPassed}/{content.pr.checksTotal} ✓ - - - ) : null} - - ) : null} {content.kind === "effort" ? ( - Effort {content.efforts.map((effort, index) => ( - - {index === selectedIndex ? "›" : " "} {effort === content.activeEffort ? "●" : "○"} {effort} + + {index === selectedIndex ? theme.rail : " "} {effort === content.activeEffort ? "●" : "○"} {effort} ))} - arrows move · enter applies + arrows move · enter applies ) : null} - {content.kind === "subagents" ? ( - + + {content.kind === "lane-details" ? ( + ) : null} - {content.kind === "new-chat-setup" ? ( - - New chat - Lane: {content.laneLabel} - - {content.rows.map((row, index) => ( - - - {index === selectedIndex ? "›" : " "} {row.label}: {row.value} - - {index === selectedIndex && row.detail ? {row.detail} : null} - - ))} - - up/down rows · left/right change · enter activates - + + {content.kind === "subagents" ? ( + ) : null} + {content.kind === "model-setup" ? ( + + ) : null} + + {content.kind === "new-chat-setup" ? ( - - MODEL - - {content.rows.filter((row) => row.cyclable === true).map((row) => { - const index = content.rows.indexOf(row); - const selected = index === selectedIndex; - const labelColor = selected ? theme.color.accent : row.disabled ? "gray" : undefined; - const isProviderRow = row.kind === "provider"; - const valueColor = isProviderRow - ? theme.provider(content.activeProvider).color - : row.disabled - ? "gray" - : undefined; - const rightHint = row.disabled ? null : "‹ ›"; - const cursorGlyph = selected ? "›" : " "; - const paddedLabel = row.label.padEnd(12, " "); - return ( - - - - {cursorGlyph} {paddedLabel} - - {isProviderRow ? `${theme.provider(content.activeProvider).glyph} ` : ""}{row.value} - - - {rightHint ? ( - - {rightHint} - - ) : null} - - {selected && row.detail ? {row.detail} : null} - - ); - })} + Lane: {content.laneLabel} - {content.rows.filter((row) => row.cyclable !== true).map((row) => { - const index = content.rows.indexOf(row); + {content.rows.map((row, index) => { const selected = index === selectedIndex; - const glyph = row.kind === "refresh-status" ? "↻" : row.kind === "open-settings" ? "↗" : "→"; - const labelColor = selected ? theme.color.accent : row.disabled ? "gray" : undefined; - const valueColor = row.disabled ? "gray" : theme.color.mutedFg; - const cursorGlyph = selected ? "›" : " "; - const showRunValue = row.kind !== "refresh-status"; return ( - - - {cursorGlyph} {glyph} {row.label} - {showRunValue ? {row.value} : null} - - {row.disabled ? null : ( - - )} - - {selected && row.detail ? {row.detail} : null} - - ); - })} - - - PROVIDERS - {content.providerRows.map((row, providerIdx) => { - const absoluteIndex = content.rows.length + providerIdx; - const providerSelected = absoluteIndex === selectedIndex; - const brand = theme.provider(row.provider); - const isActive = row.provider === content.activeProvider; - const cursorGlyph = providerSelected ? "›" : " "; - return ( - - - - {cursorGlyph} - {brand.glyph} {row.label} - {isActive ? active : null} - - {STATUS_DOT[row.status]} - - {providerSelected ? ( - - {row.modelCount} models - {row.status === "ready" ? tailTruncate(row.detail, 30) : row.detail} - + + {selected ? theme.rail : " "} {row.label}: {row.value} + + {selected && row.detail ? ( + {row.detail} ) : null} ); })} - - - ↑↓ ←→ enter{content.checkedAt ? ` · ${content.checkedAt.slice(11, 19)}` : ""} - - + ↑↓ rows · ←→ change · ↵ prompt ) : null} + {content.kind === "form" ? ( - {content.title} {content.fields.map((field, index) => { const value = formValues[field.name]?.trim(); return ( - - {index === activeFormField ? "›" : " "} {field.label} + + {index === activeFormField ? theme.rail : " "} {field.label} {field.required ? " *" : ""}: {value || field.placeholder || ""} ); })} - arrows move fields · enter submits · esc cancels + arrows move · enter submits · esc cancels ) : null} diff --git a/apps/ade-cli/src/tuiClient/components/SlashPalette.tsx b/apps/ade-cli/src/tuiClient/components/SlashPalette.tsx index d1b3c3d77..8052d6236 100644 --- a/apps/ade-cli/src/tuiClient/components/SlashPalette.tsx +++ b/apps/ade-cli/src/tuiClient/components/SlashPalette.tsx @@ -2,22 +2,95 @@ import React from "react"; import { Box, Text } from "ink"; import type { AgentChatProvider, AgentChatSlashCommand } from "../../../../desktop/src/shared/types/chat"; import { paletteCommands } from "../commands"; +import { theme } from "../theme"; -const VISIBLE_ROWS = 9; +const VISIBLE_ROWS = 5; +export const SLASH_PALETTE_ROWS = VISIBLE_ROWS + 3; +const DEFAULT_PALETTE_WIDTH = 88; +const MAX_PALETTE_WIDTH = 104; +const MIN_PALETTE_WIDTH = 56; + +function clampPaletteWidth(width?: number): number { + const available = Number.isFinite(width) ? Math.floor(width ?? DEFAULT_PALETTE_WIDTH) : DEFAULT_PALETTE_WIDTH; + return Math.max(MIN_PALETTE_WIDTH, Math.min(MAX_PALETTE_WIDTH, available)); +} + +const PROVIDER_LABELS: Record = { + claude: "Claude", + codex: "Codex", + cursor: "Cursor", + droid: "Droid", + opencode: "OpenCode", +}; + +function providerLabel(provider?: AgentChatProvider | null): string { + if (!provider) return "all providers"; + return PROVIDER_LABELS[provider] ?? provider; +} + +type PaletteRow = ReturnType[number]; + +function selectedExample(row: PaletteRow): string { + if (row.argumentHint) return `${row.name} ${row.argumentHint}`; + return row.name; +} + +function endTruncate(value: string, max: number): string { + if (max <= 1) return value.length ? "…" : ""; + if (value.length <= max) return value; + return `${value.slice(0, Math.max(0, max - 1))}…`; +} + +function textWidth(value: string): number { + return [...value].length; +} + +function padEnd(value: string, width: number): string { + return `${value}${" ".repeat(Math.max(0, width - textWidth(value)))}`; +} + +function fillLine(value: string, width: number): string { + return padEnd(endTruncate(value, width), width); +} + +function topLine(label: string, width: number): string { + const bodyWidth = Math.max(1, width - 2); + const content = ` ${label} `; + return `┌${fillLine(content, bodyWidth).replace(/ +$/u, (spaces) => "─".repeat(spaces.length))}┐`; +} + +function bodyLine(value: string, width: number): string { + return `│${fillLine(` ${value}`, Math.max(1, width - 2))}│`; +} + +function bottomLine(value: string, width: number): string { + return `└${fillLine(` ${value}`, Math.max(1, width - 2)).replace(/ +$/u, (spaces) => "─".repeat(spaces.length))}┘`; +} + +function paletteLine(value: string, color: string) { + return ( + + {value} + + ); +} export function SlashPalette({ query, userCommands, selectedIndex, provider, + width, }: { query: string; userCommands: AgentChatSlashCommand[]; selectedIndex: number; provider?: AgentChatProvider | null; + width?: number; }) { const rows = paletteCommands(query, userCommands, { provider }); if (!query.startsWith("/") || !rows.length) return null; + const paletteWidth = clampPaletteWidth(width); const total = rows.length; const safeIndex = Math.max(0, Math.min(selectedIndex, total - 1)); const half = Math.floor(VISIBLE_ROWS / 2); @@ -27,22 +100,49 @@ export function SlashPalette({ const window = rows.slice(start, end); const aboveCount = start; const belowCount = total - end; + const selected = rows[safeIndex] ?? rows[0]; + const queryLabel = query.trim() || "/"; + const nameWidth = Math.max(20, Math.min(36, Math.floor(paletteWidth * 0.42))); + const descriptionWidth = Math.max(12, paletteWidth - nameWidth - 8); + const selectedSummary = endTruncate( + `${selectedExample(selected)} · ${selected.description}`, + paletteWidth - 6, + ); + const moreSummary = [ + aboveCount ? `${aboveCount} above` : null, + belowCount ? `${belowCount} below` : null, + ].filter(Boolean).join(" · "); + const header = topLine(`Commands · ${providerLabel(provider)} · ${queryLabel} · ${total} match${total === 1 ? "" : "es"}`, paletteWidth); + const rowLines = window.map((row, index) => { + const absoluteIndex = start + index; + const isSelected = absoluteIndex === safeIndex; + const command = selectedExample(row); + return { + selected: isSelected, + value: bodyLine( + `${isSelected ? theme.rail : " "} ${fillLine(command, nameWidth)} ${endTruncate(row.description, descriptionWidth)}`, + paletteWidth, + ), + }; + }); + while (rowLines.length < VISIBLE_ROWS) { + rowLines.push({ selected: false, value: bodyLine("", paletteWidth) }); + } + const footer = bottomLine( + `${moreSummary ? `${moreSummary} · ` : ""}↑↓ move · Tab insert · Enter run · Esc close`, + paletteWidth, + ); + return ( - - {aboveCount ? ↑ {aboveCount} more : null} - {window.map((row, index) => { - const absoluteIndex = start + index; - const selected = absoluteIndex === safeIndex; - return ( - - {selected ? "›" : " "} - {row.source} - {row.name.padEnd(16)} - {row.description} - - ); - })} - {belowCount ? ↓ {belowCount} more : null} + + {paletteLine(header, theme.color.violet)} + {rowLines.map((line, index) => ( + + {paletteLine(line.value, line.selected ? theme.color.t1 : theme.color.t2)} + + ))} + {paletteLine(bodyLine(selectedSummary, paletteWidth), theme.color.t3)} + {paletteLine(footer, theme.color.t4)} ); } diff --git a/apps/ade-cli/src/tuiClient/drawerSelection.ts b/apps/ade-cli/src/tuiClient/drawerSelection.ts index 8e198fc10..e8b02f76b 100644 --- a/apps/ade-cli/src/tuiClient/drawerSelection.ts +++ b/apps/ade-cli/src/tuiClient/drawerSelection.ts @@ -24,8 +24,7 @@ export function resolveDrawerChatSelection(args: { const selectedNewChatIsValid = args.selectedDrawerChatAction === "new-chat" && args.selectedDrawerChatId == null - && args.draftChatActive - && args.drawerLaneId === args.activeLaneId; + && args.drawerLaneId != null; if (selectedNewChatIsValid) return null; if (args.draftChatActive && args.drawerLaneId === args.activeLaneId) { diff --git a/apps/ade-cli/src/tuiClient/format.ts b/apps/ade-cli/src/tuiClient/format.ts index a0d3dacdf..16c0519ac 100644 --- a/apps/ade-cli/src/tuiClient/format.ts +++ b/apps/ade-cli/src/tuiClient/format.ts @@ -1,16 +1,9 @@ import path from "node:path"; -import { getModelById } from "../../../desktop/src/shared/modelRegistry"; -import type { AgentChatEventEnvelope, AgentChatProvider, AgentChatSessionSummary } from "../../../desktop/src/shared/types/chat"; +import type { AgentChatEventEnvelope, AgentChatSessionSummary } from "../../../desktop/src/shared/types/chat"; import type { LaneSummary } from "../../../desktop/src/shared/types/lanes"; import { glyphFor } from "./theme"; import type { LocalNotice } from "./types"; -function timeLabel(value: string): string { - const d = new Date(value); - if (Number.isNaN(d.getTime())) return "--:--"; - return d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }); -} - function singleLine(value: unknown, max = 96): string { const text = (() => { if (typeof value === "string") return value; @@ -72,8 +65,49 @@ export type AssistantMarkdownBlock = | { kind: "numbered"; number: string; text: string } | { kind: "quote"; text: string } | { kind: "code"; language?: string; lines: string[] } + | { kind: "table"; headers: string[]; rows: string[][] } | { kind: "hr" }; +export type InlineRun = { + text: string; + bold?: boolean; + italic?: boolean; + code?: boolean; + link?: boolean; + color?: string; + dim?: boolean; +}; + +const INLINE_TOKEN_RE = /(`[^`\n]+`)|(\*\*[^*\n]+\*\*)|(__[^_\n]+__)|(\*[^*\s][^*\n]*\*)|(_[^_\s][^_\n]*_)|(\[[^\]\n]+\]\([^)\n]+\))/; + +export function parseInlineRuns(text: string): InlineRun[] { + const runs: InlineRun[] = []; + let remaining = text; + while (remaining.length) { + const match = INLINE_TOKEN_RE.exec(remaining); + if (!match) { + runs.push({ text: remaining }); + break; + } + const start = match.index; + if (start > 0) runs.push({ text: remaining.slice(0, start) }); + const token = match[0]; + if (match[1]) { + runs.push({ text: token.slice(1, -1), code: true }); + } else if (match[2] || match[3]) { + runs.push({ text: token.slice(2, -2), bold: true }); + } else if (match[4] || match[5]) { + runs.push({ text: token.slice(1, -1), italic: true }); + } else if (match[6]) { + const linkMatch = /^\[([^\]]+)\]\(([^)]+)\)$/.exec(token); + runs.push({ text: linkMatch?.[1] ?? token, link: true }); + } + remaining = remaining.slice(start + token.length); + } + if (!runs.length) runs.push({ text }); + return runs; +} + export function chatEventLineId(envelope: AgentChatEventEnvelope, index = 0): string { return `${envelope.sequence ?? index}:${envelope.event.type}:${envelope.timestamp}`; } @@ -86,26 +120,10 @@ function isFailedExpandableEvent(envelope: AgentChatEventEnvelope): boolean { return false; } -function providerEventLabel(provider: AgentChatProvider | null | undefined): string { - if (provider === "claude") return "Claude"; - if (provider === "codex") return "Codex"; - if (provider === "opencode") return "OpenCode"; - if (provider === "cursor") return "Cursor"; - if (provider === "droid") return "Droid"; - return "ADE"; -} - -function stripTerminalCodes(value: string): string { - return value - .replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, "") - .replace(/\[[0-9;]*m\]?/g, "") - .trim(); -} - -function sessionModelLabel(session: AgentChatSessionSummary | null): string { - const descriptor = session?.modelId ? getModelById(session.modelId) : undefined; - if (descriptor) return descriptor.displayName; - return stripTerminalCodes(session?.model ?? "") || "model"; +function statusGlyph(status: string | undefined): string { + if (status === "running") return "…"; + if (status === "failed") return "x"; + return "✓"; } function multiLine(value: unknown, maxLines = 18): string { @@ -168,6 +186,31 @@ export function parseAssistantMarkdown(text: string): AssistantMarkdownBlock[] { continue; } + if (trimmed.startsWith("|") && trimmed.endsWith("|")) { + const next = sourceLines[index + 1]?.trim() ?? ""; + const isSeparator = /^\|[\s:|-]+\|$/.test(next) && /-/.test(next); + if (isSeparator) { + const parseRow = (raw: string): string[] => raw + .replace(/^\|/, "") + .replace(/\|$/, "") + .split("|") + .map((cell) => cell.trim()); + const headers = parseRow(trimmed); + const bodyRows: string[][] = []; + index += 2; + while (index < sourceLines.length) { + const candidate = sourceLines[index]?.trim() ?? ""; + if (!candidate.startsWith("|") || !candidate.endsWith("|")) break; + bodyRows.push(parseRow(candidate)); + index += 1; + } + index -= 1; + flushParagraph(); + blocks.push({ kind: "table", headers, rows: bodyRows }); + continue; + } + } + if (/^([-*_])(?:\s*\1){2,}\s*$/.test(trimmed)) { flushParagraph(); blocks.push({ kind: "hr" }); @@ -247,13 +290,25 @@ export function renderChatLines(args: { return a.index - b.index; }); + const pushLine = (line: RenderedChatLine): void => { + const last = lines[lines.length - 1]; + if ( + last + && last.tone === line.tone + && last.body === line.body + && (last.header ?? null) === (line.header ?? null) + ) { + return; + } + lines.push(line); + }; + for (const entry of timeline) { if (entry.kind === "notice") { const notice = entry.notice; - lines.push({ + pushLine({ id: notice.id, tone: notice.tone === "error" ? "error" : "notice", - header: `ADE Code · ${timeLabel(notice.timestamp)}`, body: notice.text, }); continue; @@ -267,7 +322,6 @@ export function renderChatLines(args: { lines.push({ id, tone: "user", - header: `you · ${timeLabel(envelope.timestamp)}`, body: event.displayText ?? event.text, }); continue; @@ -276,7 +330,6 @@ export function renderChatLines(args: { lines.push({ id, tone: "assistant", - header: `${providerEventLabel(args.activeSession?.provider)} · ${timeLabel(envelope.timestamp)} · ${sessionModelLabel(args.activeSession)}`, body: event.text, blocks: parseAssistantMarkdown(event.text), }); @@ -346,8 +399,7 @@ export function renderChatLines(args: { continue; } if (event.type === "web_search") { - const statusGlyph = event.status === "running" ? "…" : event.status === "failed" ? "x" : "✓"; - const head = `${statusGlyph} web ${singleLine(event.query, 96)}`; + const head = `${statusGlyph(event.status)} web ${singleLine(event.query, 96)}`; const actionLines = event.actions?.length ? event.actions.map((action) => { const kind = action.type || "action"; @@ -370,7 +422,7 @@ export function renderChatLines(args: { lines.push({ id, tone: event.status === "failed" ? "error" : "tool", - body: `${event.status === "running" ? "…" : event.status === "failed" ? "x" : "✓"} ${isGeneration ? "image generated" : "image"} ${singleLine(title, 120)}`, + body: `${statusGlyph(event.status)} ${isGeneration ? "image generated" : "image"} ${singleLine(title, 120)}`, }); continue; } @@ -414,9 +466,9 @@ export function renderChatLines(args: { continue; } if (event.type === "status") { - const tone = event.turnStatus === "failed" - ? "error" as const - : event.turnStatus === "interrupted" ? "error" as const : "notice" as const; + const tone: "error" | "notice" = event.turnStatus === "failed" || event.turnStatus === "interrupted" + ? "error" + : "notice"; lines.push({ id, tone, body: `[status] ${event.turnStatus}${event.message ? ` · ${singleLine(event.message, 120)}` : ""}` }); continue; } @@ -532,25 +584,32 @@ export function renderChatLines(args: { continue; } if (event.type === "system_notice") { - // Surface severity-bearing notices with an error tone while keeping - // non-blocking telemetry, including allowed Claude rate-limit events, - // in the normal notice channel. const noticeKind = (event as { noticeKind?: string }).noticeKind; const severity = (event as { severity?: string }).severity; + const message = singleLine((event as { message?: unknown }).message, 160); + const normalizedMessage = message.trim().toLowerCase(); + if (!normalizedMessage) continue; + if (noticeKind === "info" && normalizedMessage === "session ready") continue; + if (noticeKind === "hook" && /^hook:\s+.+\s+started$/i.test(message)) continue; + + // Surface the severity-bearing noticeKinds with an error tone so the TUI + // colorizes them distinctively. Guardian warnings, rate limits, thread + // errors, and provider health issues map to `tone: "error"`; warnings and + // config issues keep the default notice tone. const tone: "notice" | "error" = severity === "error" || (!severity && ( noticeKind === "error" + || noticeKind === "rate_limit" || noticeKind === "thread_error" || noticeKind === "provider_health" - || noticeKind === "rate_limit" + || (noticeKind === "auth" && /\b(?:fail|failed|error|invalid|expired|denied)\b/i.test(message)) )) ? "error" : "notice"; - lines.push({ + pushLine({ id, tone, - header: `${providerEventLabel(args.activeSession?.provider)} · ${timeLabel(envelope.timestamp)}`, - body: singleLine((event as { message?: unknown }).message, 160), + body: message, }); continue; } diff --git a/apps/ade-cli/src/tuiClient/imageTargets.ts b/apps/ade-cli/src/tuiClient/imageTargets.ts index b9a5e707f..8568deba5 100644 --- a/apps/ade-cli/src/tuiClient/imageTargets.ts +++ b/apps/ade-cli/src/tuiClient/imageTargets.ts @@ -1,7 +1,16 @@ import path from "node:path"; -import type { AgentChatEventEnvelope } from "../../../desktop/src/shared/types/chat"; +import fs from "node:fs"; +import { spawnSync } from "node:child_process"; +import type { AgentChatEventEnvelope, AgentChatFileRef } from "../../../desktop/src/shared/types/chat"; const IMAGE_FILE_EXTENSION_RE = /\.(png|jpe?g|gif|webp|bmp|svg|ico|tiff?|heic|heif|avif)$/i; +const CLIPBOARD_MAX_BUFFER = 120 * 1024 * 1024; +let clipboardTargetCounter = 0; + +export type ImageDimensions = { + width: number; + height: number; +}; export function isImageFilePath(filePath: string): boolean { return IMAGE_FILE_EXTENSION_RE.test(filePath); @@ -41,6 +50,87 @@ export function latestOpenableImageTarget(events: AgentChatEventEnvelope[]): str return null; } +export function readImageDimensions(filePath: string): ImageDimensions | null { + try { + const buffer = fs.readFileSync(filePath); + return readImageDimensionsFromBuffer(buffer); + } catch { + return null; + } +} + +export function readClipboardImageAttachment(workspaceRoot: string): AgentChatFileRef | null { + if (process.platform === "darwin") { + const pngpasteAttachment = readMacClipboardWithPngpaste(workspaceRoot); + if (pngpasteAttachment) return pngpasteAttachment; + + const applescriptAttachment = readMacClipboardWithAppleScript(workspaceRoot); + if (applescriptAttachment) return applescriptAttachment; + + const pbpasteAttachment = readMacClipboardWithPbpaste(workspaceRoot); + if (pbpasteAttachment) return pbpasteAttachment; + + const filePath = readMacClipboardFilePath(); + if (filePath) return { path: filePath, type: "image" }; + } + + if (process.platform === "win32" && commandAvailable("powershell")) { + const target = clipboardImageTarget(workspaceRoot); + const command = [ + "Add-Type -AssemblyName System.Windows.Forms;", + "Add-Type -AssemblyName System.Drawing;", + "$image = [System.Windows.Forms.Clipboard]::GetImage();", + `if ($image -ne $null) { $image.Save(${powershellQuoted(target)}, [System.Drawing.Imaging.ImageFormat]::Png) }`, + ].join(" "); + const result = spawnSync("powershell", ["-NoProfile", "-Command", command], { stdio: "ignore" }); + if (result.status === 0 && nonEmptyFile(target)) return { path: target, type: "image" }; + } + + if (process.platform === "linux") { + const target = clipboardImageTarget(workspaceRoot); + const commands: string[][] = commandAvailable("wl-paste") + ? [["wl-paste", "-t", "image/png"]] + : commandAvailable("xclip") + ? [["xclip", "-selection", "clipboard", "-t", "image/png", "-o"]] + : []; + for (const [command, ...args] of commands) { + const result = spawnSync(command, args, { encoding: "buffer", maxBuffer: 30 * 1024 * 1024 }); + if (result.status === 0 && result.stdout.length) { + fs.writeFileSync(target, result.stdout); + if (nonEmptyFile(target)) return { path: target, type: "image" }; + } + } + } + + const clipboardPath = readClipboardTextPaths().find((candidate) => fs.existsSync(candidate) && isImageFilePath(candidate)); + if (clipboardPath) return { path: clipboardPath, type: "image" }; + return null; +} + +export function parseAppleScriptClipboardData(stdout: string | Buffer): Buffer | null { + const text = Buffer.isBuffer(stdout) ? stdout.toString("utf8") : stdout; + const match = text.match(/data\s+[A-Za-z0-9]{4}\s*([0-9A-Fa-f\s]+)>|«data\s+[A-Za-z0-9]{4}\s*([0-9A-Fa-f\s]+)»/); + const hex = (match?.[1] ?? match?.[2] ?? "").replace(/\s+/g, ""); + if (!hex || hex.length % 2 !== 0) return null; + return Buffer.from(hex, "hex"); +} + +export function readImageDimensionsFromBuffer(buffer: Buffer): ImageDimensions | null { + if (buffer.length >= 24 && buffer.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]))) { + return { width: buffer.readUInt32BE(16), height: buffer.readUInt32BE(20) }; + } + if (buffer.length >= 10 && buffer.subarray(0, 3).toString("ascii") === "GIF") { + return { width: buffer.readUInt16LE(6), height: buffer.readUInt16LE(8) }; + } + if (buffer.length >= 12 && buffer.subarray(0, 4).toString("ascii") === "RIFF" && buffer.subarray(8, 12).toString("ascii") === "WEBP") { + return readWebpDimensions(buffer); + } + if (buffer.length >= 4 && buffer[0] === 0xff && buffer[1] === 0xd8) { + return readJpegDimensions(buffer); + } + return null; +} + function isHttpUrl(target: string): boolean { try { const parsed = new URL(target); @@ -49,3 +139,196 @@ function isHttpUrl(target: string): boolean { return false; } } + +function commandAvailable(command: string): boolean { + const result = spawnSync(process.platform === "win32" ? "where" : "command", process.platform === "win32" ? [command] : ["-v", command], { + shell: process.platform !== "win32", + stdio: "ignore", + }); + return result.status === 0; +} + +function clipboardImageTarget(workspaceRoot: string, extension = "png"): string { + const dir = path.join(workspaceRoot, ".ade", "cache", "ade-code-clipboard"); + fs.mkdirSync(dir, { recursive: true }); + clipboardTargetCounter += 1; + return path.join(dir, `pasted-screenshot-${Date.now()}-${clipboardTargetCounter}.${extension}`); +} + +function powershellQuoted(value: string): string { + return `'${value.replace(/'/g, "''")}'`; +} + +function nonEmptyFile(filePath: string): boolean { + try { + return fs.statSync(filePath).size > 0; + } catch { + return false; + } +} + +function readClipboardText(): string | null { + const candidates = process.platform === "darwin" + ? [["pbpaste"]] + : process.platform === "win32" + ? [["powershell", "-NoProfile", "-Command", "Get-Clipboard"]] + : [["wl-paste", "--no-newline"], ["xclip", "-selection", "clipboard", "-o"]]; + for (const [command, ...args] of candidates) { + if (!commandAvailable(command)) continue; + const result = spawnSync(command, args, { encoding: "utf8", maxBuffer: 1024 * 1024 }); + if (result.status === 0 && result.stdout.trim()) return result.stdout.trim(); + } + return null; +} + +function readClipboardTextPaths(): string[] { + return (readClipboardText() ?? "") + .split(/\r?\n/) + .map((line) => normalizeClipboardPathText(line)) + .filter((line): line is string => Boolean(line)); +} + +function normalizeClipboardPathText(value: string): string | null { + const trimmed = value.trim().replace(/^['"]|['"]$/g, ""); + if (!trimmed) return null; + if (/^file:/i.test(trimmed)) { + try { + return decodeURIComponent(new URL(trimmed).pathname); + } catch { + return null; + } + } + return trimmed; +} + +function readMacClipboardWithPngpaste(workspaceRoot: string): AgentChatFileRef | null { + if (!commandAvailable("pngpaste")) return null; + const target = clipboardImageTarget(workspaceRoot); + const result = spawnSync("pngpaste", [target], { stdio: "ignore" }); + return result.status === 0 && nonEmptyFile(target) ? { path: target, type: "image" } : null; +} + +function readMacClipboardWithPbpaste(workspaceRoot: string): AgentChatFileRef | null { + if (!commandAvailable("pbpaste")) return null; + const result = spawnSync("pbpaste", ["-Prefer", "image"], { encoding: "buffer", maxBuffer: 30 * 1024 * 1024 }); + if (result.status !== 0 || !result.stdout.length) return null; + return writeClipboardImageBuffer(workspaceRoot, result.stdout); +} + +function readMacClipboardWithAppleScript(workspaceRoot: string): AgentChatFileRef | null { + if (!commandAvailable("osascript")) return null; + for (const clipboardClass of ["PNGf", "TIFF"]) { + const result = spawnSync("osascript", ["-e", `try`, "-e", `the clipboard as «class ${clipboardClass}»`, "-e", "end try"], { + encoding: "utf8", + maxBuffer: CLIPBOARD_MAX_BUFFER, + }); + if (result.status !== 0 || !result.stdout) continue; + const buffer = parseAppleScriptClipboardData(result.stdout); + if (!buffer?.length) continue; + const attachment = writeClipboardImageBuffer(workspaceRoot, buffer, clipboardClass === "TIFF" ? "tiff" : "png"); + if (attachment) return attachment; + } + return null; +} + +function readMacClipboardFilePath(): string | null { + if (!commandAvailable("osascript")) return null; + const script = [ + "use framework \"Foundation\"", + "use framework \"AppKit\"", + "set pasteboard to current application's NSPasteboard's generalPasteboard()", + "set urls to pasteboard's readObjectsForClasses:{current application's NSURL} options:{NSPasteboardURLReadingFileURLsOnlyKey:true}", + "set paths to {}", + "repeat with itemUrl in urls", + "set end of paths to (itemUrl's |path|()) as text", + "end repeat", + "return paths", + ]; + const result = spawnSync("osascript", script.flatMap((line) => ["-e", line]), { encoding: "utf8", maxBuffer: 1024 * 1024 }); + if (result.status !== 0 || !result.stdout.trim()) return null; + return result.stdout + .split(/,\s*|\r?\n/) + .map((line) => normalizeClipboardPathText(line)) + .find((candidate): candidate is string => Boolean(candidate && fs.existsSync(candidate) && isImageFilePath(candidate))) ?? null; +} + +function writeClipboardImageBuffer(workspaceRoot: string, buffer: Buffer, preferredExtension = imageExtensionForBuffer(buffer)): AgentChatFileRef | null { + if (!preferredExtension) return null; + if (preferredExtension === "tiff" || preferredExtension === "tif") { + return writeConvertedTiffClipboardImage(workspaceRoot, buffer); + } + const target = clipboardImageTarget(workspaceRoot, preferredExtension); + fs.writeFileSync(target, buffer); + return nonEmptyFile(target) ? { path: target, type: "image" } : null; +} + +function writeConvertedTiffClipboardImage(workspaceRoot: string, buffer: Buffer): AgentChatFileRef | null { + if (!commandAvailable("sips")) return null; + const source = clipboardImageTarget(workspaceRoot, "tiff"); + const target = clipboardImageTarget(workspaceRoot, "png"); + fs.writeFileSync(source, buffer); + const result = spawnSync("sips", ["-s", "format", "png", source, "--out", target], { stdio: "ignore" }); + try { + fs.rmSync(source, { force: true }); + } catch { + // Best-effort cleanup; the converted PNG is the durable attachment. + } + return result.status === 0 && nonEmptyFile(target) ? { path: target, type: "image" } : null; +} + +function imageExtensionForBuffer(buffer: Buffer): string | null { + if (buffer.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]))) return "png"; + if (buffer.length >= 4 && buffer[0] === 0xff && buffer[1] === 0xd8) return "jpg"; + if (buffer.subarray(0, 3).toString("ascii") === "GIF") return "gif"; + if ( + buffer.subarray(0, 4).toString("ascii") === "II*\0" + || buffer.subarray(0, 4).toString("ascii") === "MM\0*" + ) return "tiff"; + if (buffer.subarray(0, 4).toString("ascii") === "RIFF" && buffer.subarray(8, 12).toString("ascii") === "WEBP") return "webp"; + return null; +} + +function readJpegDimensions(buffer: Buffer): ImageDimensions | null { + let offset = 2; + while (offset + 9 < buffer.length) { + if (buffer[offset] !== 0xff) { + offset += 1; + continue; + } + const marker = buffer[offset + 1]; + const length = buffer.readUInt16BE(offset + 2); + if (length < 2) return null; + if (marker && marker >= 0xc0 && marker <= 0xcf && ![0xc4, 0xc8, 0xcc].includes(marker)) { + return { height: buffer.readUInt16BE(offset + 5), width: buffer.readUInt16BE(offset + 7) }; + } + offset += 2 + length; + } + return null; +} + +function readWebpDimensions(buffer: Buffer): ImageDimensions | null { + const chunk = buffer.subarray(12, 16).toString("ascii"); + if (chunk === "VP8X" && buffer.length >= 30) { + return { + width: 1 + buffer.readUIntLE(24, 3), + height: 1 + buffer.readUIntLE(27, 3), + }; + } + if (chunk === "VP8 " && buffer.length >= 30) { + return { + width: buffer.readUInt16LE(26) & 0x3fff, + height: buffer.readUInt16LE(28) & 0x3fff, + }; + } + if (chunk === "VP8L" && buffer.length >= 25) { + const b1 = buffer[21]!; + const b2 = buffer[22]!; + const b3 = buffer[23]!; + const b4 = buffer[24]!; + return { + width: 1 + (((b2 & 0x3f) << 8) | b1), + height: 1 + (((b4 & 0x0f) << 10) | (b3 << 2) | ((b2 & 0xc0) >> 6)), + }; + } + return null; +} diff --git a/apps/ade-cli/src/tuiClient/keybindings/index.ts b/apps/ade-cli/src/tuiClient/keybindings/index.ts index e9771b27c..408a94304 100644 --- a/apps/ade-cli/src/tuiClient/keybindings/index.ts +++ b/apps/ade-cli/src/tuiClient/keybindings/index.ts @@ -115,6 +115,7 @@ const SUPPORTED_ACTION_VALUES = [ "plugin:install", "plugin:favorite", "pane:toggle", + "pane:agents", "pane:close", "settings:search", "settings:retry", diff --git a/apps/ade-cli/src/tuiClient/laneTree.ts b/apps/ade-cli/src/tuiClient/laneTree.ts new file mode 100644 index 000000000..2e681257c --- /dev/null +++ b/apps/ade-cli/src/tuiClient/laneTree.ts @@ -0,0 +1,112 @@ +import type { LaneSummary } from "../../../desktop/src/shared/types/lanes"; + +/** + * Sort lanes into a stack-graph DFS order (mirrors the desktop + * `sortLanesForStackGraph` in apps/desktop/src/renderer/components/lanes/laneUtils.ts). + * Primary lane is the root; lanes with a parent are placed under their parent; + * orphans hang off primary. Within each parent's children we sort by createdAt asc + * with a name tiebreak. + */ +export function sortLanesForStackGraph(lanes: LaneSummary[]): LaneSummary[] { + const laneById = new Map(lanes.map((lane) => [lane.id, lane] as const)); + const childrenByParent = new Map(); + const roots: LaneSummary[] = []; + const primary = lanes.find((lane) => lane.laneType === "primary") ?? null; + const primaryId = primary?.id ?? null; + + for (const lane of lanes) { + if (lane.laneType === "primary") { + roots.push(lane); + continue; + } + const effectiveParentId = + lane.parentLaneId && laneById.has(lane.parentLaneId) ? lane.parentLaneId : primaryId; + if (!effectiveParentId || effectiveParentId === lane.id) { + roots.push(lane); + continue; + } + const children = childrenByParent.get(effectiveParentId) ?? []; + children.push(lane); + childrenByParent.set(effectiveParentId, children); + } + + const byCreatedAsc = (a: LaneSummary, b: LaneSummary) => { + const aTs = Date.parse(a.createdAt); + const bTs = Date.parse(b.createdAt); + if (!Number.isNaN(aTs) && !Number.isNaN(bTs) && aTs !== bTs) return aTs - bTs; + return a.name.localeCompare(b.name); + }; + roots.sort((a, b) => { + const aPrimary = a.laneType === "primary" ? 1 : 0; + const bPrimary = b.laneType === "primary" ? 1 : 0; + if (aPrimary !== bPrimary) return bPrimary - aPrimary; + return byCreatedAsc(a, b); + }); + for (const [, children] of childrenByParent.entries()) { + children.sort(byCreatedAsc); + } + + const out: LaneSummary[] = []; + const visit = (lane: LaneSummary) => { + out.push(lane); + for (const child of childrenByParent.get(lane.id) ?? []) visit(child); + }; + for (const root of roots) visit(root); + const seen = new Set(out.map((lane) => lane.id)); + return out.concat(lanes.filter((lane) => !seen.has(lane.id)).sort(byCreatedAsc)); +} + +/** + * Compute the ASCII tree prefix shown to the left of a lane row in the stack + * graph. The wireframe uses `├─`, `└─`, `│ ├─`, `│ └─` etc. + * + * `depth` is the lane's stackDepth (0 for roots). `isLast` indicates whether + * the lane is the last sibling of its parent (so we should draw `└─` instead + * of `├─`). Ancestors are rendered with `│ ` for non-last ancestors and ` ` + * for last ancestors — but ade rows don't track per-ancestor lastness, so we + * approximate with `│ ` repeated for ancestors. The visual fidelity at + * depth>2 is good enough for the TUI. + */ +export function stackPrefix(depth: number, isLast: boolean): string { + if (depth <= 0) return ""; + let prefix = ""; + for (let i = 0; i < depth - 1; i += 1) prefix += "│ "; + prefix += isLast ? "└─ " : "├─ "; + return prefix; +} + +/** + * Stack tree row metadata for an ordered list of lanes returned by + * `sortLanesForStackGraph`. For each lane, computes whether it's the last + * sibling of its (effective) parent given the order — used to decide between + * `├─` and `└─` glyphs. + */ +export function computeStackRowMeta( + lanes: LaneSummary[], +): Array<{ depth: number; isLast: boolean; prefix: string }> { + const laneById = new Map(lanes.map((lane) => [lane.id, lane] as const)); + const primaryId = lanes.find((l) => l.laneType === "primary")?.id ?? null; + const effectiveParent = (lane: LaneSummary): string | null => { + if (lane.laneType === "primary") return null; + const pid = lane.parentLaneId && laneById.has(lane.parentLaneId) ? lane.parentLaneId : primaryId; + if (!pid || pid === lane.id) return null; + return pid; + }; + // group lanes by parent in order to detect last-sibling + const byParent = new Map(); + for (const lane of lanes) { + const p = effectiveParent(lane); + const arr = byParent.get(p) ?? []; + arr.push(lane); + byParent.set(p, arr); + } + const lastSiblingId = new Set(); + for (const [, group] of byParent) { + if (group.length > 0) lastSiblingId.add(group[group.length - 1].id); + } + return lanes.map((lane) => { + const depth = lane.laneType === "primary" ? 0 : Math.max(0, lane.stackDepth || 0); + const isLast = lastSiblingId.has(lane.id); + return { depth, isLast, prefix: stackPrefix(depth, isLast) }; + }); +} diff --git a/apps/ade-cli/src/tuiClient/project.ts b/apps/ade-cli/src/tuiClient/project.ts index f83695103..192154de7 100644 --- a/apps/ade-cli/src/tuiClient/project.ts +++ b/apps/ade-cli/src/tuiClient/project.ts @@ -1,6 +1,7 @@ import { execFileSync } from "node:child_process"; import fs from "node:fs"; import path from "node:path"; +import type { AgentChatSessionSummary } from "../../../desktop/src/shared/types/chat"; import type { LaneSummary } from "../../../desktop/src/shared/types/lanes"; import type { ProjectLaunchContext } from "./types"; @@ -112,3 +113,108 @@ export function chooseInitialLane( return lanes.find((lane) => lane.laneType === "primary") ?? lanes[0] ?? null; } + +export function isWorkspaceInsideLane(lane: LaneSummary | null | undefined, workspaceRoot: string): boolean { + if (!lane?.worktreePath) return false; + const workspace = normalizeRoot(workspaceRoot); + const worktree = normalizeRoot(lane.worktreePath); + const attached = lane.attachedRootPath ? normalizeRoot(lane.attachedRootPath) : null; + return ( + workspace === worktree + || workspace.startsWith(`${worktree}${path.sep}`) + || (attached !== null && (workspace === attached || workspace.startsWith(`${attached}${path.sep}`))) + ); +} + +export function chooseTuiLaunchLane( + lanes: LaneSummary[], + context: Pick, + lastLaneId: string | null, +): LaneSummary | null { + const contextLane = chooseInitialLane(lanes, context); + const contextLaneIsExplicit = Boolean(context.laneHint) + || (contextLane?.laneType !== "primary" && isWorkspaceInsideLane(contextLane, context.workspaceRoot)); + if (contextLaneIsExplicit && contextLane) return contextLane; + const persistedLane = lastLaneId ? lanes.find((lane) => lane.id === lastLaneId) ?? null : null; + return persistedLane ?? contextLane; +} + +export function chooseMostRecentSessionLane( + lanes: LaneSummary[], + sessions: AgentChatSessionSummary[], +): LaneSummary | null { + const laneIds = new Set(lanes.map((lane) => lane.id)); + const newest = newestChatSession(sessions.filter((session) => laneIds.has(session.laneId))); + return newest ? lanes.find((lane) => lane.id === newest.laneId) ?? null : null; +} + +function newestChatSession(sessions: AgentChatSessionSummary[]): AgentChatSessionSummary | null { + return [...sessions].sort((left, right) => { + const rightMs = Date.parse(right.lastActivityAt ?? right.startedAt); + const leftMs = Date.parse(left.lastActivityAt ?? left.startedAt); + return (Number.isFinite(rightMs) ? rightMs : 0) - (Number.isFinite(leftMs) ? leftMs : 0); + })[0] ?? null; +} + +export function resolveTuiChatRefreshTarget(args: { + lanes: LaneSummary[]; + sessions: AgentChatSessionSummary[]; + context: Pick; + lastLaneId: string | null; + activeLaneId: string | null; + activeSessionId: string | null; + draftChatActive: boolean; + initialNewChatPreview: boolean; + newChatPreviewLaneId: string | null; + selectedDrawerChatAction: "new-chat" | null; + drawerLaneId: string | null; +}): { + lane: LaneSummary | null; + laneId: string | null; + session: AgentChatSessionSummary | null; + seedSession: AgentChatSessionSummary | null; + launchToNewChatPreview: boolean; + previewMode: boolean; +} { + const launchToNewChatPreview = args.initialNewChatPreview + && args.activeSessionId == null + && !args.draftChatActive; + const socketRecentLane = launchToNewChatPreview + ? chooseMostRecentSessionLane(args.lanes, args.sessions) + : null; + const previewLane = args.newChatPreviewLaneId + ? args.lanes.find((lane) => lane.id === args.newChatPreviewLaneId) ?? null + : null; + const activeLane = args.lanes.find((entry) => entry.id === args.activeLaneId) ?? null; + const contextLaunchLane = chooseTuiLaunchLane(args.lanes, args.context, args.lastLaneId); + const fallbackLane = activeLane + ?? socketRecentLane + ?? previewLane + ?? contextLaunchLane; + const lane = launchToNewChatPreview + ? socketRecentLane ?? activeLane ?? previewLane ?? contextLaunchLane + : fallbackLane; + const laneId = lane?.id ?? null; + const laneSessions = args.sessions.filter((session) => session.laneId === laneId); + const keepNewChatPreview = !args.draftChatActive + && (args.drawerLaneId ?? laneId) === laneId + && args.activeSessionId == null + && ( + args.newChatPreviewLaneId === laneId + || args.selectedDrawerChatAction === "new-chat" + ); + const previewMode = launchToNewChatPreview || keepNewChatPreview; + const seedSession = args.draftChatActive || previewMode ? newestChatSession(laneSessions) : null; + const session = args.draftChatActive || previewMode + ? null + : args.sessions.find((entry) => entry.sessionId === args.activeSessionId) + ?? newestChatSession(laneSessions); + return { + lane, + laneId, + session, + seedSession, + launchToNewChatPreview, + previewMode, + }; +} diff --git a/apps/ade-cli/src/tuiClient/spinTick.tsx b/apps/ade-cli/src/tuiClient/spinTick.tsx new file mode 100644 index 000000000..3f1f44a50 --- /dev/null +++ b/apps/ade-cli/src/tuiClient/spinTick.tsx @@ -0,0 +1,45 @@ +import React, { createContext, useContext, useEffect, useState } from "react"; + +const QUARTER_FRAMES = ["◐", "◓", "◑", "◒"] as const; +const BRAILLE_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"] as const; +const DOT_FRAMES = [". ", ".. ", "...", " "] as const; + +const SpinTickContext = createContext(0); + +export function SpinTickProvider({ children }: { children: React.ReactNode }) { + const [tick, setTick] = useState(0); + + useEffect(() => { + const id = setInterval(() => setTick((t) => (t + 1) % 1000), 100); + return () => clearInterval(id); + }, []); + + return ( + + {children} + + ); +} + +export function useSpinFrame(): string { + const tick = useContext(SpinTickContext); + // Quarter-circle frames advance every ~300ms: tick / 3 over + // QUARTER_FRAMES gives a full ~1200ms cycle. + const index = Math.floor(tick / 3) % QUARTER_FRAMES.length; + return QUARTER_FRAMES[index]!; +} + +export function useBrailleSpin(): string { + const tick = useContext(SpinTickContext); + // Braille cycle ticks every ~100ms — one frame per base tick. + const index = tick % BRAILLE_FRAMES.length; + return BRAILLE_FRAMES[index]!; +} + +export function useDotPulse(): string { + const tick = useContext(SpinTickContext); + // Dot pulse advances every ~300ms, giving the familiar "working..." + // affordance without stealing attention from the transcript. + const index = Math.floor(tick / 3) % DOT_FRAMES.length; + return DOT_FRAMES[index]!; +} diff --git a/apps/ade-cli/src/tuiClient/state.ts b/apps/ade-cli/src/tuiClient/state.ts index c00638b0c..829eab72b 100644 --- a/apps/ade-cli/src/tuiClient/state.ts +++ b/apps/ade-cli/src/tuiClient/state.ts @@ -4,6 +4,7 @@ import path from "node:path"; export type AdeCodeState = { lastChatByLane: Record; + lastLaneId: string | null; }; const STATE_DIR = path.join(os.homedir(), ".ade"); @@ -21,9 +22,12 @@ export function loadAdeCodeState(): AdeCodeState { } } } - return { lastChatByLane }; + return { + lastChatByLane, + lastLaneId: typeof parsed.lastLaneId === "string" ? parsed.lastLaneId : null, + }; } catch { - return { lastChatByLane: {} }; + return { lastChatByLane: {}, lastLaneId: null }; } } diff --git a/apps/ade-cli/src/tuiClient/subagentPane.ts b/apps/ade-cli/src/tuiClient/subagentPane.ts new file mode 100644 index 000000000..0b8aed218 --- /dev/null +++ b/apps/ade-cli/src/tuiClient/subagentPane.ts @@ -0,0 +1,256 @@ +import type { + AgentChatEvent, + AgentChatEventEnvelope, + AgentChatSessionSummary, +} from "../../../desktop/src/shared/types/chat"; +import type { RightPaneContent, SubagentSnapshot } from "./types"; +import { workEventItemId, workEventParentItemId } from "./workEventIds"; + +export type SubagentPaneSection = "main" | "subagents" | "teammates" | "background" | "recent"; + +export type SubagentPaneRow = + | { kind: "main"; key: "main"; section: "main"; label: string } + | { kind: "snapshot"; key: string; section: Exclude; snapshot: SubagentSnapshot }; + +const SUBAGENT_PANE_TABLE_START_LINE = 4; + +export function buildSubagentPaneRows(content: Extract): SubagentPaneRow[] { + const runningSubagents = content.snapshots.filter((snap) => ( + snap.kind === "subagent" + && snap.background !== true + && snap.status === "running" + )); + const teammates = content.snapshots.filter((snap) => snap.kind === "teammate"); + const background = content.snapshots.filter((snap) => snap.kind === "subagent" && snap.background === true); + const recent = content.snapshots.filter((snap) => ( + snap.kind === "subagent" + && snap.background !== true + && snap.status !== "running" + )); + + return [ + { kind: "main", key: "main", section: "main", label: "main" }, + ...runningSubagents.map((snapshot) => ({ kind: "snapshot" as const, key: snapshot.id, section: "subagents" as const, snapshot })), + ...teammates.map((snapshot) => ({ kind: "snapshot" as const, key: snapshot.id, section: "teammates" as const, snapshot })), + ...background.map((snapshot) => ({ kind: "snapshot" as const, key: snapshot.id, section: "background" as const, snapshot })), + ...recent.map((snapshot) => ({ kind: "snapshot" as const, key: snapshot.id, section: "recent" as const, snapshot })), + ]; +} + +export function selectedSubagentSnapshot( + content: Extract, + selectedIndex: number, +): SubagentSnapshot | null { + const row = buildSubagentPaneRows(content)[selectedIndex] ?? null; + return row?.kind === "snapshot" ? row.snapshot : null; +} + +export function clampSubagentSelection( + content: Extract, + selectedIndex: number, +): number { + const rowCount = buildSubagentPaneRows(content).length; + if (rowCount <= 0) return 0; + if (!Number.isFinite(selectedIndex)) return 0; + return Math.max(0, Math.min(Math.floor(selectedIndex), rowCount - 1)); +} + +export function subagentPaneSelectableLineOffsets( + content: Extract, +): number[] { + const rows = buildSubagentPaneRows(content); + const offsets: number[] = []; + let line = SUBAGENT_PANE_TABLE_START_LINE; + + for (let index = 0; index < rows.length; index += 1) { + const row = rows[index]!; + const previous = rows[index - 1]; + const showSection = row.section !== "main" && previous?.section !== row.section; + if (showSection) line += 2; + offsets.push(line); + line += 1; + if (row.kind === "main" || row.snapshot.lastToolName || row.snapshot.summary) { + line += 1; + } + } + + return offsets; +} + +export function subagentIndexForPaneLine( + content: Extract, + line: number, +): number | null { + if (!Number.isFinite(line)) return null; + const offsets = subagentPaneSelectableLineOffsets(content); + if (!offsets.length) return null; + const first = offsets[0]!; + const last = offsets[offsets.length - 1]!; + if (line < first - 1 || line > last + 1) return null; + + let bestIndex = 0; + let bestDistance = Number.POSITIVE_INFINITY; + for (let index = 0; index < offsets.length; index += 1) { + const distance = Math.abs(line - offsets[index]!); + if (distance < bestDistance) { + bestIndex = index; + bestDistance = distance; + } + } + return bestIndex; +} + +function textField(value: unknown): string | null { + return typeof value === "string" && value.trim().length > 0 ? value.trim() : null; +} + +function eventType(event: AgentChatEvent): string { + return String((event as { type?: unknown }).type ?? ""); +} + +function eventSubagentIds(event: AgentChatEvent): string[] { + const record = event as { taskId?: unknown; agentId?: unknown }; + const ids = [ + textField(record.taskId), + textField(record.agentId), + ].filter((value): value is string => value != null); + return [...new Set(ids)]; +} + +function eventParentToolUseId(event: AgentChatEvent): string | null { + return textField((event as { parentToolUseId?: unknown }).parentToolUseId); +} + +function isLifecycleEventForSnapshot(event: AgentChatEvent, snapshot: SubagentSnapshot): boolean { + const type = eventType(event); + if (snapshot.kind === "teammate") { + if (type === "teammate.idle" || type === "task.completed") { + const record = event as { teamName?: unknown; teammateName?: unknown }; + const teamName = textField(record.teamName) ?? ""; + const teammateName = textField(record.teammateName) ?? ""; + return snapshot.id === `teammate:${teamName}:${teammateName}`; + } + return false; + } + if ( + type !== "subagent_started" + && type !== "subagent_progress" + && type !== "subagent_result" + && type !== "subagent.started" + && type !== "subagent.progress" + && type !== "subagent.completed" + ) { + return false; + } + if (eventSubagentIds(event).includes(snapshot.id)) return true; + const parentToolUseId = eventParentToolUseId(event); + return Boolean(snapshot.parentToolUseId && parentToolUseId === snapshot.parentToolUseId); +} + +function lifecycleText(event: AgentChatEvent, snapshot: SubagentSnapshot): string | null { + const type = eventType(event); + const record = event as { + description?: unknown; + summary?: unknown; + finalSummary?: unknown; + text?: unknown; + subject?: unknown; + status?: unknown; + }; + if (type === "subagent_started" || type === "subagent.started") { + return `Subagent started: ${textField(record.description) ?? snapshot.name}`; + } + if (type === "subagent_progress" || type === "subagent.progress") { + return textField(record.summary) ?? textField(record.text) ?? null; + } + if (type === "subagent_result" || type === "subagent.completed") { + const status = textField(record.status) ?? snapshot.status; + const summary = textField(record.finalSummary) ?? textField(record.summary) ?? snapshot.summary; + return `${status}: ${summary || snapshot.name}`; + } + if (type === "teammate.idle") { + return `Teammate idle: ${snapshot.name}`; + } + if (type === "task.completed") { + return `completed: ${textField(record.subject) ?? snapshot.summary ?? snapshot.name}`; + } + return null; +} + +function syntheticTextEvent( + sessionId: string, + timestamp: string, + sequence: number, + turnId: string | null | undefined, + text: string, +): AgentChatEventEnvelope { + return { + sessionId, + timestamp, + sequence, + event: { + type: "text", + text, + ...(turnId ? { turnId } : {}), + }, + }; +} + +export function buildSubagentTranscriptEvents(args: { + events: AgentChatEventEnvelope[]; + activeSession: AgentChatSessionSummary | null; + snapshot: SubagentSnapshot; +}): AgentChatEventEnvelope[] { + const sessionId = args.activeSession?.sessionId ?? args.events[0]?.sessionId ?? "subagent"; + const parentToolUseId = textField(args.snapshot.parentToolUseId); + const childItemIds = new Set(); + + if (parentToolUseId) { + for (const envelope of args.events) { + const event = envelope.event; + const parentItemId = workEventParentItemId(event); + const itemId = workEventItemId(event); + if (parentItemId === parentToolUseId && itemId) { + childItemIds.add(itemId); + } + } + } + + const transcript: AgentChatEventEnvelope[] = [ + syntheticTextEvent( + sessionId, + args.snapshot.startedAt ?? args.events[0]?.timestamp ?? new Date(0).toISOString(), + -2, + args.snapshot.turnId, + `Viewing ${args.snapshot.kind === "teammate" ? "teammate" : args.snapshot.background ? "background agent" : "subagent"}: ${args.snapshot.name}\nLeave the agents pane to return to the main chat.`, + ), + ]; + + for (const envelope of args.events) { + const event = envelope.event; + if (isLifecycleEventForSnapshot(event, args.snapshot)) { + const text = lifecycleText(event, args.snapshot); + if (text) { + transcript.push(syntheticTextEvent(sessionId, envelope.timestamp, envelope.sequence ?? 0, args.snapshot.turnId, text)); + } + continue; + } + const parentItemId = workEventParentItemId(event); + const itemId = workEventItemId(event); + if (parentToolUseId && (parentItemId === parentToolUseId || (itemId != null && childItemIds.has(itemId)))) { + transcript.push(envelope); + } + } + + if (transcript.length === 1) { + transcript.push(syntheticTextEvent( + sessionId, + args.snapshot.endedAt ?? args.events.at(-1)?.timestamp ?? new Date(0).toISOString(), + -1, + args.snapshot.turnId, + args.snapshot.summary || "No detailed transcript rows were recorded for this agent.", + )); + } + + return transcript; +} diff --git a/apps/ade-cli/src/tuiClient/theme.ts b/apps/ade-cli/src/tuiClient/theme.ts index d0fccd137..93d9d6a97 100644 --- a/apps/ade-cli/src/tuiClient/theme.ts +++ b/apps/ade-cli/src/tuiClient/theme.ts @@ -5,30 +5,57 @@ import type { RenderedChatLine } from "./format"; /** * Centralised design tokens for the ade-code TUI. * - * Mirrors the ADE desktop renderer where it matters: accent #A78BFA (purple), - * lane.color for lane chips, and per-provider brand colors and glyphs that map - * the SVG marks used in the desktop ProviderLogos to single-cell BMP glyphs - * safe for Ink's string-width handling. + * Tokens mirror the Claude Design wireframe terminal.css 1:1 so the TUI looks + * like the same family as the desktop app and the design mockups. */ -const ACCENT = "#A78BFA"; -const ACCENT_DIM = "#6D5DBF"; -const FG = "white"; -const MUTED_FG = "gray"; -const SUCCESS = "#22C55E"; -const WARNING = "#F59E0B"; -const DANGER = "#EF4444"; +// Surfaces / borders +const SURFACE_1 = "#16141E"; +const BORDER = "#302C42"; +const BORDER_ACTIVE = "#38334E"; +const BORDER_SOFT = "#211E2E"; + +// Text levels +const T1 = "#F0F0F2"; +const T2 = "#A8A8B4"; +const T3 = "#908FA0"; +const T4 = "#6B6A7A"; +const T5 = "#4A4955"; + +// Brand violet family +const VIOLET = "#A78BFA"; +const VIOLET_DEEP = "#7C3AED"; + +// Status family +const RUNNING = "#22C55E"; +const ATTENTION = "#F59E0B"; +const ATTENTION_2 = "#FBBF24"; +const INFO = "#3B82F6"; +const ERROR = "#EF4444"; +const DONE = "#22C55E"; +const WARNING = ATTENTION; +const DANGER = ERROR; + +// Provider brand colors (used both by FooterControls.tsx via `theme.color.*` +// and by the per-provider glyph chips in PROVIDER_THEME below). +const CLAUDE = "#D97757"; +const CODEX = "#22C55E"; +const CURSOR = "#0EA5E9"; +const OPENCODE = "#6366F1"; +const DROID = "#06B6D4"; +const SHELL = "#F59E0B"; +const COPILOT = "#A855F7"; + const TOOL = "cyan"; -const REASONING = "gray"; -const NOTICE = "gray"; -const APPROVAL = "#F59E0B"; -const ERROR = DANGER; +const REASONING = T4; +const NOTICE = T3; +const APPROVAL = ATTENTION; export type Tone = RenderedChatLine["tone"]; const TONE_COLORS: Record = { - user: ACCENT, - assistant: FG, + user: VIOLET, + assistant: T1, tool: TOOL, error: ERROR, notice: NOTICE, @@ -38,19 +65,20 @@ const TONE_COLORS: Record = { type ProviderTheme = { glyph: string; + wordmark: string; color: string; label: string; }; const PROVIDER_THEME: Record = { - claude: { glyph: "◆", color: "#D97757", label: "Claude" }, - codex: { glyph: "◇", color: "#10A37F", label: "Codex" }, - cursor: { glyph: "▲", color: FG, label: "Cursor" }, - droid: { glyph: "▣", color: "#22D3EE", label: "Droid" }, - opencode: { glyph: "◈", color: ACCENT, label: "OpenCode" }, + claude: { glyph: "◆", wordmark: "Claude", color: CLAUDE, label: "Claude" }, + codex: { glyph: "◇", wordmark: "Codex", color: CODEX, label: "Codex" }, + cursor: { glyph: "▲", wordmark: "Cursor", color: CURSOR, label: "Cursor" }, + droid: { glyph: "▣", wordmark: "Droid", color: DROID, label: "Droid" }, + opencode: { glyph: "◈", wordmark: "OpenCode", color: OPENCODE, label: "OpenCode" }, }; -const FALLBACK_PROVIDER: ProviderTheme = { glyph: "•", color: MUTED_FG, label: "Agent" }; +const FALLBACK_PROVIDER: ProviderTheme = { glyph: "•", wordmark: "Agent", color: T4, label: "Agent" }; export type PlanStepStatus = "pending" | "in_progress" | "completed" | "failed"; @@ -65,29 +93,102 @@ export function glyphFor(status: string | null | undefined): string { return PLAN_STEP_GLYPH[status as PlanStepStatus] ?? "○"; } +// Lane / agent status semantics used across drawer, lane-details, agents pane. +export type LaneStatusKind = "running" | "attention" | "idle" | "failed" | "primary"; + +const LANE_STATUS_COLOR: Record = { + running: RUNNING, + attention: ATTENTION, + idle: T4, + failed: ERROR, + primary: VIOLET, +}; + +export function laneStatusColor(kind: LaneStatusKind): string { + return LANE_STATUS_COLOR[kind] ?? T4; +} + +// Agent status semantics inside the agents pane (Subagents / Teammates). +export type AgentStatusKind = "running" | "ok" | "waiting" | "error"; + +const AGENT_STATUS_COLOR: Record = { + running: RUNNING, + ok: T3, + waiting: ATTENTION, + error: ERROR, +}; + +const AGENT_STATUS_GLYPH: Record = { + running: "●", + ok: "✓", + waiting: "◐", + error: "✗", +}; + +export function agentStatusColor(kind: AgentStatusKind): string { + return AGENT_STATUS_COLOR[kind] ?? T3; +} + +export function agentStatusGlyph(kind: AgentStatusKind): string { + return AGENT_STATUS_GLYPH[kind] ?? "○"; +} + +// Status rail glyph (left vertical bar) used in drawer rows + agents pane selected row. +const RAIL_GLYPH = "▎"; + export const theme = { color: { - accent: ACCENT, - accentDim: ACCENT_DIM, - fg: FG, - mutedFg: MUTED_FG, - notice: NOTICE, - border: MUTED_FG, - borderFocused: ACCENT, - success: SUCCESS, + // Brand + accent: VIOLET, + accentDim: VIOLET_DEEP, + violet: VIOLET, + violetDeep: VIOLET_DEEP, + + // Text + fg: T1, + t1: T1, + t2: T2, + t3: T3, + t4: T4, + t5: T5, + mutedFg: T3, + + // Surfaces / borders + surface1: SURFACE_1, + border: BORDER, + borderActive: BORDER_ACTIVE, + borderSoft: BORDER_SOFT, + borderFocused: VIOLET, + + // Status + running: RUNNING, + attention: ATTENTION, + attention2: ATTENTION_2, + info: INFO, + error: ERROR, + done: DONE, warning: WARNING, danger: DANGER, + + // Executors (used by FooterControls / palettes) + shell: SHELL, + copilot: COPILOT, + + // Misc tool: TOOL, }, tone(tone: Tone): string { - return TONE_COLORS[tone] ?? FG; + return TONE_COLORS[tone] ?? T1; }, provider(provider: AdeCodeProvider | null | undefined): ProviderTheme { if (!provider) return FALLBACK_PROVIDER; return PROVIDER_THEME[provider] ?? FALLBACK_PROVIDER; }, lane(lane: LaneSummary | null | undefined): string { - return lane?.color || ACCENT; + return lane?.color || VIOLET; }, - glyphFor, + laneStatusColor, + agentStatusColor, + agentStatusGlyph, + rail: RAIL_GLYPH, } as const; diff --git a/apps/ade-cli/src/tuiClient/types.ts b/apps/ade-cli/src/tuiClient/types.ts index d047c5050..9d8659ea3 100644 --- a/apps/ade-cli/src/tuiClient/types.ts +++ b/apps/ade-cli/src/tuiClient/types.ts @@ -90,6 +90,7 @@ export type SetupPaneRowKind = | "reasoning" | "permission" | "codex-fast" + | "output-style" | "refresh-status" | "open-settings" | "apply"; @@ -103,14 +104,19 @@ export type SetupPaneRow = { cyclable?: boolean; }; -export type SubagentPaneTab = "subagents" | "teammates" | "background"; +export type SubagentPaneTab = "subagents" | "teammates"; export type SubagentSnapshot = { id: string; name: string; - kind: "subagent" | "teammate" | "background"; + kind: "subagent" | "teammate"; status: "running" | "completed" | "failed" | "stopped"; summary: string; + parentToolUseId?: string | null; + turnId?: string | null; + background?: boolean; + startedAt?: string | null; + endedAt?: string | null; tokens?: number; durationMs?: number; lastToolName?: string; @@ -134,7 +140,7 @@ export type RightPaneContent = | { kind: "diff"; title: string; files: Array<{ path: string; additions?: number; deletions?: number; body?: string }> } | { kind: "models"; models: AgentChatModelInfo[]; activeModelId: string | null } | { kind: "effort"; efforts: string[]; activeEffort: string | null } - | { kind: "subagents"; tab: SubagentPaneTab; snapshots: SubagentSnapshot[] } + | { kind: "subagents"; tab: SubagentPaneTab; snapshots: SubagentSnapshot[]; provider: AdeCodeProvider } | { kind: "new-chat-setup"; laneId: string; @@ -164,11 +170,28 @@ export type RightPaneContent = | { kind: "lane-details"; lane: LaneSummary; - git: { staged: number; unstaged: number; total: number; ahead: number; behind: number; remote: string | null }; + git: { + staged: number; + unstaged: number; + total: number; + ahead: number; + behind: number; + remote: string | null; + additions: number; + deletions: number; + }; files: { path: string; status: "M" | "A" | "D" | "?"; staged: boolean }[]; pr: { number: number; state: "open" | "closed" | "merged"; url: string; checksPassed: number; checksTotal: number } | null; + run?: { + status: "running" | "idle"; + provider: AdeCodeProvider; + elapsedMs: number | null; + tokenSummary: string | null; + toolSummary: string | null; + } | null; showFiles: boolean; selectedActionIndex: number; + worktreeAvailable?: boolean; }; export type LocalNotice = { @@ -184,6 +207,7 @@ export type MentionSuggestion = { insertText: string; detail?: string; filePath?: string; + attachment?: boolean; }; export type PendingApproval = { @@ -209,6 +233,9 @@ export type ShellData = { rightPane: RightPaneContent; contextPercent: number | null; streaming: boolean; + liveAgentCount: number; + highlightedDrawerLaneId: string | null; + drawerMode: "chats" | "lanes"; }; export type CreatedChat = AgentChatSession; diff --git a/apps/ade-cli/src/tuiClient/workEventIds.ts b/apps/ade-cli/src/tuiClient/workEventIds.ts new file mode 100644 index 000000000..948ccf62f --- /dev/null +++ b/apps/ade-cli/src/tuiClient/workEventIds.ts @@ -0,0 +1,19 @@ +import type { AgentChatEvent } from "../../../desktop/src/shared/types/chat"; + +function textField(value: unknown): string | null { + return typeof value === "string" && value.trim().length > 0 ? value.trim() : null; +} + +export function workEventItemId(event: AgentChatEvent): string | null { + if (event.type !== "tool_call" && event.type !== "tool_result" && event.type !== "command" && event.type !== "file_change") { + return null; + } + return textField(event.itemId); +} + +export function workEventParentItemId(event: AgentChatEvent): string | null { + if (event.type !== "tool_call" && event.type !== "tool_result") { + return null; + } + return textField(event.parentItemId); +} diff --git a/apps/desktop/package-lock.json b/apps/desktop/package-lock.json index 0b7aadefb..d3e12d64d 100644 --- a/apps/desktop/package-lock.json +++ b/apps/desktop/package-lock.json @@ -48,6 +48,7 @@ "geist": "^1.7.0", "lottie-react": "^2.4.1", "lucide-react": "^0.563.0", + "mdast-util-find-and-replace": "^3.0.2", "monaco-editor": "^0.55.1", "motion": "^12.34.2", "node-cron": "^3.0.3", diff --git a/apps/desktop/package.json b/apps/desktop/package.json index f62f1fe6e..ca8ed8974 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -88,6 +88,7 @@ "geist": "^1.7.0", "lottie-react": "^2.4.1", "lucide-react": "^0.563.0", + "mdast-util-find-and-replace": "^3.0.2", "monaco-editor": "^0.55.1", "motion": "^12.34.2", "node-cron": "^3.0.3", diff --git a/apps/desktop/src/main/main.ts b/apps/desktop/src/main/main.ts index f36eff94a..3147378c5 100644 --- a/apps/desktop/src/main/main.ts +++ b/apps/desktop/src/main/main.ts @@ -91,6 +91,7 @@ import { import { startJsonRpcServer, type JsonRpcTransport } from "../../../ade-cli/src/jsonrpc"; import { resolveMachineAdeLayout } from "../../../ade-cli/src/services/projects/machineLayout"; import { normalizeProjectRootPath } from "../../../ade-cli/src/services/projects/projectRoots"; +import { EncryptedFileCredentialStore } from "../../../ade-cli/src/services/credentials/credentialStore"; import { createKeybindingsService } from "./services/keybindings/keybindingsService"; import { createAgentToolsService } from "./services/agentTools/agentToolsService"; import { createAdeCliService } from "./services/cli/adeCliService"; @@ -145,7 +146,7 @@ import { createWorkerHeartbeatService } from "./services/cto/workerHeartbeatServ import { createLinearCredentialService } from "./services/cto/linearCredentialService"; import { buildRendererCspPolicy } from "./rendererCsp"; import { createLinearClient } from "./services/cto/linearClient"; -import { createLinearIssueTracker } from "./services/cto/linearIssueTracker"; +import { createLinearIssueTracker, type LinearIssueTracker } from "./services/cto/linearIssueTracker"; import { createLinearTemplateService } from "./services/cto/linearTemplateService"; import { createFlowPolicyService } from "./services/cto/flowPolicyService"; import { createLinearWorkflowFileService } from "./services/cto/linearWorkflowFileService"; @@ -154,6 +155,7 @@ import { createLinearIntakeService } from "./services/cto/linearIntakeService"; import { createLinearOutboundService } from "./services/cto/linearOutboundService"; import { createLinearCloseoutService } from "./services/cto/linearCloseoutService"; import { createLinearDispatcherService } from "./services/cto/linearDispatcherService"; +import { publishLinearLaneCard } from "./services/cto/linearLaneCardService"; import { createLinearIngressService } from "./services/cto/linearIngressService"; import { createLinearSyncService } from "./services/cto/linearSyncService"; import { createOrchestratorService } from "./services/orchestrator/orchestratorService"; @@ -1587,7 +1589,11 @@ app.whenReady().then(async () => { const hadAdeDir = fs.existsSync(path.join(projectRoot, ".ade", "ade.db")); const adePaths = ensureAdeDirs(projectRoot); const { initApiKeyStore } = await import("./services/ai/apiKeyStore"); - initApiKeyStore(projectRoot); + initApiKeyStore(projectRoot, { + credentialStore: new EncryptedFileCredentialStore({ + secretsDir: machineAdeLayout.secretsDir, + }), + }); const logger = createFileLogger(path.join(adePaths.logsDir, "main.jsonl")); const packagedFirstOpenStabilityMode = app.isPackaged @@ -1681,6 +1687,7 @@ app.whenReady().then(async () => { let missionBudgetServiceRef: ReturnType< typeof createMissionBudgetService > | null = null; + let linearIssueTrackerRef: LinearIssueTracker | null = null; const lastHeadByLaneId = new Map(); @@ -1765,6 +1772,24 @@ app.whenReady().then(async () => { } }, onDeleteEvent: (event) => emitProjectEvent(projectRoot, IPC.lanesDeleteEvent, event), + onLinearIssueLinked: ({ lane, issue, linkedAt }) => { + const tracker = linearIssueTrackerRef; + if (!tracker) return; + void publishLinearLaneCard({ + issueTracker: tracker, + lane, + issue, + projectRoot, + linkedAt, + }).catch((error) => { + logger.warn("linear.lane_card_publish_failed", { + laneId: lane.id, + issueId: issue.id, + issueIdentifier: issue.identifier, + error: error instanceof Error ? error.message : String(error), + }); + }); + }, teardownDeps: laneTeardownDeps, logger, }); @@ -2328,6 +2353,10 @@ app.whenReady().then(async () => { onSessionEnded: onTrackedSessionEnded, onSessionRuntimeSignal: (signal) => { aiOrchestratorServiceRef?.onSessionRuntimeSignal(signal); + emitProjectEvent(projectRoot, IPC.sessionsChanged, { + sessionId: signal.sessionId, + reason: "meta-updated", + }); }, loadPty, }); @@ -2645,6 +2674,9 @@ app.whenReady().then(async () => { const linearCredentialService = createLinearCredentialService({ adeDir: adePaths.adeDir, logger, + credentialStore: new EncryptedFileCredentialStore({ + secretsDir: machineAdeLayout.secretsDir, + }), }); const linearClient = createLinearClient({ credentials: linearCredentialService, @@ -2653,6 +2685,7 @@ app.whenReady().then(async () => { const linearIssueTracker = createLinearIssueTracker({ client: linearClient, }); + linearIssueTrackerRef = linearIssueTracker; const linearTemplateService = createLinearTemplateService({ adeDir: adePaths.adeDir, }); @@ -3535,6 +3568,9 @@ app.whenReady().then(async () => { onUpdate: (snapshot) => { emitProjectEvent(projectRoot, IPC.usageEvent, snapshot); }, + onThresholdEvent: (event) => { + emitProjectEvent(projectRoot, IPC.usageThresholdEvent, event); + }, }); scheduleBackgroundProjectTask( "usage.start", @@ -3544,7 +3580,7 @@ app.whenReady().then(async () => { error: error instanceof Error ? error.message : String(error), }); }, - 20_000, + 1_000, "ADE_ENABLE_USAGE_TRACKING", ); diff --git a/apps/desktop/src/main/services/adeActions/registry.test.ts b/apps/desktop/src/main/services/adeActions/registry.test.ts index d3c0585bb..5f4f69058 100644 --- a/apps/desktop/src/main/services/adeActions/registry.test.ts +++ b/apps/desktop/src/main/services/adeActions/registry.test.ts @@ -156,6 +156,11 @@ describe("ADE_ACTION_ALLOWLIST shape", () => { expect(actions).toContain("listSnapshots"); }); + it("exposes pr.listPrsByLane for runtime-backed drawer PR pills", () => { + const actions = ADE_ACTION_ALLOWLIST.pr ?? []; + expect(actions).toContain("listPrsByLane"); + }); + it("exposes ade_project.clearLocalData for runtime-backed cleanup", () => { const actions = ADE_ACTION_ALLOWLIST.ade_project ?? []; expect(actions).toContain("clearLocalData"); @@ -696,6 +701,7 @@ describe("runtime GitHub actions", () => { const runtime = { githubService: { getStatus: vi.fn(), + getRemoteStatus: vi.fn(), setToken: vi.fn(), clearToken: vi.fn(), getRepoOrThrow: vi.fn(), @@ -712,6 +718,7 @@ describe("runtime GitHub actions", () => { expect(listAllowedAdeActionNames("github", githubService)).toEqual(expect.arrayContaining([ "listRepoCollaborators", "listRepoLabels", + "getRemoteStatus", "publishCurrentProject", ])); }); diff --git a/apps/desktop/src/main/services/adeActions/registry.ts b/apps/desktop/src/main/services/adeActions/registry.ts index 17bb8e03c..ff3e35661 100644 --- a/apps/desktop/src/main/services/adeActions/registry.ts +++ b/apps/desktop/src/main/services/adeActions/registry.ts @@ -293,7 +293,7 @@ export const ADE_ACTION_ALLOWLIST: Partial { expect(status.availableModelIds).toContain("opencode/openai/gpt-5.4-mini"); }); + it("keeps forced status refresh non-interactive for Claude", async () => { + const { service } = makeService({ + availability: { claude: true, codex: false, cursor: false, droid: false }, + }); + + await service.getStatus({ force: true }); + + expect(mockState.probeClaudeRuntimeHealth).not.toHaveBeenCalled(); + }); + it("invalidates provider readiness caches after API key verification", async () => { const { service } = makeService({ providerMode: "guest", diff --git a/apps/desktop/src/main/services/ai/aiIntegrationService.ts b/apps/desktop/src/main/services/ai/aiIntegrationService.ts index 6ad8d2fd6..9fb4d4592 100644 --- a/apps/desktop/src/main/services/ai/aiIntegrationService.ts +++ b/apps/desktop/src/main/services/ai/aiIntegrationService.ts @@ -1751,15 +1751,10 @@ export function createAiIntegrationService(args: { // detectAuth -> detectAllAuth already called detectCliAuthStatuses() and // populated the cache, so this reads instantly from cache: const cliStatuses = timeSyncPhase("read_cli_auth_cache", () => getCachedCliAuthStatuses()); - const claudeCli = cliStatuses.find((entry) => entry.cli === "claude"); - if (claudeCli?.installed && options?.force) { - await timePhase("probe_claude_runtime", () => probeClaudeRuntimeHealth({ - projectRoot, - logger, - force: true, - })); - runtimeHealthVersion = getProviderRuntimeHealthVersion(); - } + // Keep AI status refresh non-interactive. Starting a throwaway Claude + // Agent SDK runtime here can trigger Claude's OAuth/API-key bootstrap + // in the browser, even though the user only asked to refresh status. + // Real Claude chat sessions report runtime health when they start. const providerConnections = await timePhase("build_provider_connections", () => buildProviderConnections(cliStatuses)); const configuredLocalProviders = timeSyncPhase( "read_local_provider_config", diff --git a/apps/desktop/src/main/services/ai/apiKeyStore.test.ts b/apps/desktop/src/main/services/ai/apiKeyStore.test.ts index 324e1faee..a0676e704 100644 --- a/apps/desktop/src/main/services/ai/apiKeyStore.test.ts +++ b/apps/desktop/src/main/services/ai/apiKeyStore.test.ts @@ -305,6 +305,51 @@ describe("apiKeyStore", () => { expect(securityAccountsFor("add-generic-password")).toContain("__ade_provider_index__"); }); + it("migrates legacy safeStorage API keys into a provided credential store once", async () => { + delete process.env.OPENAI_API_KEY; + const secretsDir = path.join(tempRoot, ".ade", "secrets"); + fs.mkdirSync(secretsDir, { recursive: true }); + fs.writeFileSync(path.join(secretsDir, "api-keys.v1.bin"), Buffer.from("old-encrypted")); + safeStorageState.available = true; + safeStorageState.decrypted = JSON.stringify({ + cursor: "crsr_old_key", + openai: "openai_old_key", + }); + const credentialStore = new MemoryCredentialStore(); + const store = await loadStoreModule(); + + store.initApiKeyStore(tempRoot, { credentialStore }); + + expect(store.getApiKey("cursor")).toBe("crsr_old_key"); + expect(store.getApiKey("openai")).toBe("openai_old_key"); + expect(credentialStore.values.get("ai.api_key.cursor.v1")).toBe("crsr_old_key"); + expect(credentialStore.values.get("ai.api_key.openai.v1")).toBe("openai_old_key"); + + store.deleteApiKey("openai"); + store.initApiKeyStore(tempRoot, { credentialStore }); + + expect(store.getApiKey("openai")).toBeNull(); + expect(store.listStoredProviders()).toEqual(["cursor"]); + }); + + it("migrates legacy Keychain API keys into a provided credential store", async () => { + keychain.set("__ade_provider_index__", JSON.stringify(["cursor"])); + keychain.set("cursor", "crsr_keychain_key"); + const credentialStore = new MemoryCredentialStore(); + const store = await loadStoreModule(); + + store.initApiKeyStore(tempRoot, { credentialStore }); + + expect(store.getApiKey("cursor")).toBe("crsr_keychain_key"); + expect(credentialStore.values.get("ai.api_key.cursor.v1")).toBe("crsr_keychain_key"); + expect(JSON.parse(credentialStore.values.get("ai.api_key.index.v1") ?? "[]")).toEqual(["cursor"]); + + store.deleteApiKey("cursor"); + store.initApiKeyStore(tempRoot, { credentialStore }); + + expect(store.getApiKey("cursor")).toBeNull(); + }); + it("stores, lists, returns, and deletes API keys through a provided credential store", async () => { delete process.env.OPENAI_API_KEY; const credentialStore = new MemoryCredentialStore(); @@ -334,6 +379,21 @@ describe("apiKeyStore", () => { expect(JSON.parse(credentialStore.values.get("ai.api_key.index.v1") ?? "[]")).toEqual(["cursor"]); }); + it("does not treat malformed credential migration metadata as a decryption failure", async () => { + const credentialStore = new MemoryCredentialStore(); + credentialStore.setSync("ai.api_key.index.v1", JSON.stringify(["cursor"])); + credentialStore.setSync("ai.api_key.cursor.v1", "crsr_test_key"); + credentialStore.setSync("ai.credentials.legacy_projects_migrated.v1", "{broken-json"); + const store = await loadStoreModule(); + + store.initApiKeyStore(tempRoot, { credentialStore }); + + expect(store.getApiKey("cursor")).toBe("crsr_test_key"); + expect(store.getApiKeyStoreStatus()).toMatchObject({ + decryptionFailed: false, + }); + }); + it("reads an unindexed credential-store provider on demand and updates the index", async () => { const credentialStore = new MemoryCredentialStore(); credentialStore.setSync("ai.api_key.openai.v1", "sk-unindexed-key"); diff --git a/apps/desktop/src/main/services/ai/apiKeyStore.ts b/apps/desktop/src/main/services/ai/apiKeyStore.ts index f5c79afa3..90ccfadc8 100644 --- a/apps/desktop/src/main/services/ai/apiKeyStore.ts +++ b/apps/desktop/src/main/services/ai/apiKeyStore.ts @@ -61,6 +61,8 @@ const ENV_KEY_PROVIDERS: Record = { const MACOS_SECURITY_BIN = "/usr/bin/security"; const MACOS_KEYCHAIN_SERVICE = "com.ade.desktop.api-keys.v1"; const MACOS_KEYCHAIN_PROVIDER_INDEX_ACCOUNT = "__ade_provider_index__"; +const CREDENTIAL_LEGACY_KEYCHAIN_MIGRATED_KEY = "ai.credentials.legacy_keychain_migrated.v1"; +const CREDENTIAL_LEGACY_PROJECTS_MIGRATED_KEY = "ai.credentials.legacy_projects_migrated.v1"; const MACOS_KEYCHAIN_MISSING_PATTERNS = [ /could not be found/i, /item could not be found/i, @@ -71,6 +73,7 @@ const CREDENTIAL_PROVIDER_INDEX_KEY = "ai.api_key.index.v1"; let storePath: string | null = null; let legacyStorePath: string | null = null; +let projectRootPath: string | null = null; let credentialStore: ApiKeyCredentialStore | null = null; let cache: StoredKeys | null = null; let decryptionFailed = false; @@ -285,7 +288,6 @@ function readCredentialProviderIndex(): { exists: boolean; providers: string[] } try { return { exists: true, providers: normalizeProviderList(JSON.parse(raw)) }; } catch { - decryptionFailed = true; return { exists: true, providers: [] }; } } @@ -294,6 +296,34 @@ function writeCredentialProviderIndex(providers: Iterable): void { writeCredentialSecret(CREDENTIAL_PROVIDER_INDEX_KEY, JSON.stringify(normalizeProviderList(Array.from(providers)))); } +function readCredentialLegacyMigratedProjectRoots(): Set { + const raw = readCredentialSecret(CREDENTIAL_LEGACY_PROJECTS_MIGRATED_KEY); + if (!raw) return new Set(); + try { + const parsed = JSON.parse(raw) as unknown; + if (!Array.isArray(parsed)) return new Set(); + const roots = parsed + .filter((entry): entry is string => typeof entry === "string" && entry.trim().length > 0) + .map((entry) => path.resolve(entry)); + return new Set(roots); + } catch { + return new Set(); + } +} + +function writeCredentialLegacyMigratedProjectRoots(projectRoots: Iterable): void { + const normalized = Array.from(new Set(Array.from(projectRoots).map((entry) => path.resolve(entry)))).sort(); + writeCredentialSecret(CREDENTIAL_LEGACY_PROJECTS_MIGRATED_KEY, JSON.stringify(normalized)); +} + +function isCredentialLegacyKeychainMigrated(): boolean { + return Boolean(readCredentialSecret(CREDENTIAL_LEGACY_KEYCHAIN_MIGRATED_KEY)); +} + +function markCredentialLegacyKeychainMigrated(): void { + writeCredentialSecret(CREDENTIAL_LEGACY_KEYCHAIN_MIGRATED_KEY, new Date().toISOString()); +} + function readCredentialStore(providerCandidates: Iterable): StoredKeys { const out: StoredKeys = {}; for (const provider of providerCandidates) { @@ -305,6 +335,76 @@ function readCredentialStore(providerCandidates: Iterable): StoredKeys { return out; } +function mergeLegacyValuesIntoCredentialStore( + currentStore: StoredKeys, + legacyStore: StoredKeys, +): StoredKeys { + const nextStore = { ...currentStore }; + const changedProviders = new Set(); + for (const [provider, rawValue] of Object.entries(legacyStore)) { + const normalizedProvider = normalizeProvider(provider); + const value = rawValue.trim(); + if (!normalizedProvider.length || !value.length || nextStore[normalizedProvider]) continue; + writeCredentialSecret(credentialProviderKey(normalizedProvider), value); + nextStore[normalizedProvider] = value; + changedProviders.add(normalizedProvider); + } + if (changedProviders.size) { + const index = readCredentialProviderIndex(); + writeCredentialProviderIndex(new Set([...index.providers, ...Object.keys(nextStore)])); + } + return nextStore; +} + +function migrateLegacyProjectStoreIntoCredentialStore(currentStore: StoredKeys): StoredKeys { + if (!projectRootPath) return currentStore; + const migratedProjectRoots = readCredentialLegacyMigratedProjectRoots(); + const normalizedProjectRoot = path.resolve(projectRootPath); + if (migratedProjectRoots.has(normalizedProjectRoot)) return currentStore; + + const hadEncryptedStore = Boolean(storePath && fs.existsSync(storePath)); + const legacyStore = loadEncryptedStore(); + const migrationComplete = !hadEncryptedStore || !decryptionFailed; + const nextStore = mergeLegacyValuesIntoCredentialStore(currentStore, legacyStore); + + if (migrationComplete) { + migratedProjectRoots.add(normalizedProjectRoot); + writeCredentialLegacyMigratedProjectRoots(migratedProjectRoots); + } + return nextStore; +} + +function migrateLegacyKeychainIntoCredentialStore(currentStore: StoredKeys): StoredKeys { + if (isCredentialLegacyKeychainMigrated()) return currentStore; + if (!isMacosKeychainAvailable()) return currentStore; + + const index = readMacosKeychainProviderIndex(); + const providerCandidates = new Set([ + ...index.providers, + ...Object.keys(ENV_KEY_PROVIDERS), + ]); + const keychainStore = readMacosKeychainStore(providerCandidates); + const nextStore = mergeLegacyValuesIntoCredentialStore(currentStore, keychainStore); + markCredentialLegacyKeychainMigrated(); + return nextStore; +} + +function migrateLegacyStoresIntoCredentialStore(currentStore: StoredKeys): StoredKeys { + let nextStore = currentStore; + try { + nextStore = migrateLegacyKeychainIntoCredentialStore(nextStore); + } catch { + // Keep the machine credential store usable even if a legacy Keychain read + // or write is blocked. The legacy copy remains available for a later retry. + } + try { + nextStore = migrateLegacyProjectStoreIntoCredentialStore(nextStore); + } catch { + // Best effort. A failed migration must not block app or runtime startup. + } + return nextStore; +} + function readMacosKeychainProviderIndex(): { exists: boolean; providers: string[] } { const raw = readMacosKeychainSecret(MACOS_KEYCHAIN_PROVIDER_INDEX_ACCOUNT); if (!raw) return { exists: false, providers: [] }; @@ -402,7 +502,8 @@ function ensureStore(): StoredKeys { if (credentialStore) { const index = readCredentialProviderIndex(); - cache = index.exists ? readCredentialStore(index.providers) : {}; + const credentialValues = index.exists ? readCredentialStore(index.providers) : {}; + cache = migrateLegacyStoresIntoCredentialStore(credentialValues); return cache; } @@ -452,6 +553,7 @@ function persistEncryptedStore(nextStore: StoredKeys = cache ?? {}): void { export function initApiKeyStore(projectRoot: string, options: InitApiKeyStoreOptions = {}): void { const layout = resolveAdeLayout(projectRoot); + projectRootPath = path.resolve(projectRoot); storePath = layout.apiKeysPath; legacyStorePath = layout.legacyApiKeysPath; credentialStore = options.credentialStore ?? null; @@ -531,7 +633,9 @@ export function getApiKey(provider: string): string | null { } missingCredentialProviders.add(normalizedProvider); } - if (isMacosKeychainAvailable() && !missingMacosKeychainProviders.has(normalizedProvider)) { + const allowLegacyKeychainFallback = + !credentialStore || !isCredentialLegacyKeychainMigrated(); + if (allowLegacyKeychainFallback && isMacosKeychainAvailable() && !missingMacosKeychainProviders.has(normalizedProvider)) { const keychainValue = readMacosKeychainSecret(normalizedProvider); if (keychainValue) { store[normalizedProvider] = keychainValue; diff --git a/apps/desktop/src/main/services/ai/tools/systemPrompt.test.ts b/apps/desktop/src/main/services/ai/tools/systemPrompt.test.ts index ec9ffd419..4956165cc 100644 --- a/apps/desktop/src/main/services/ai/tools/systemPrompt.test.ts +++ b/apps/desktop/src/main/services/ai/tools/systemPrompt.test.ts @@ -47,7 +47,7 @@ describe("buildCodingAgentSystemPrompt", () => { const result = buildCodingAgentSystemPrompt({ cwd: "/x", permissionMode: "plan", - runtime: "codex-cli", + runtime: "codex-app-server", }); expect(result).toContain("Native Codex Plan Mode controls planning and approval"); @@ -59,7 +59,7 @@ describe("buildCodingAgentSystemPrompt", () => { const result = buildCodingAgentSystemPrompt({ cwd: "/x", permissionMode: "plan", - runtime: "codex-cli", + runtime: "codex-app-server", interactive: false, }); @@ -109,6 +109,14 @@ describe("buildCodingAgentSystemPrompt", () => { expect(result).toContain("No autonomous wake from ADE"); }); + it("describes the Codex app-server runtime", () => { + const result = buildCodingAgentSystemPrompt({ cwd: "/x", runtime: "codex-app-server" }); + expect(result).toContain("## Runtime Environment"); + expect(result).toContain("Codex app-server protocol"); + expect(result).toContain("JSON-RPC"); + expect(result).toContain("No autonomous wake from ADE"); + }); + it("describes the Claude Agent SDK query runtime with wake-up caveat", () => { const result = buildCodingAgentSystemPrompt({ cwd: "/x", runtime: "claude-agent-sdk-query" }); expect(result).toContain("## Runtime Environment"); diff --git a/apps/desktop/src/main/services/ai/tools/systemPrompt.ts b/apps/desktop/src/main/services/ai/tools/systemPrompt.ts index bd8adf30b..3fe2a0b36 100644 --- a/apps/desktop/src/main/services/ai/tools/systemPrompt.ts +++ b/apps/desktop/src/main/services/ai/tools/systemPrompt.ts @@ -11,6 +11,7 @@ type HarnessPermissionMode = "plan" | "edit" | "full-auto"; */ export type AdeRuntimeKind = | "claude-agent-sdk-query" + | "codex-app-server" | "codex-cli" | "cursor-sdk" | "droid-acp" @@ -29,6 +30,11 @@ function describeRuntime(runtime: AdeRuntimeKind): string[] { "**Runtime:** ADE Work chat wrapping the Codex CLI as a subprocess. Your turns are driven through the Codex agent loop, but the orchestration host is ADE — slash commands, attachments, and lane scoping come from ADE.", "**Wake-up semantics:** No autonomous wake from ADE. If you need to wait, prefer `sleep ... && ` so the shell holds the wait without burning model tokens, then resume reasoning when the command produces output.", ]; + case "codex-app-server": + return [ + "**Runtime:** ADE Work chat hosted on the Codex app-server protocol. Your turns are driven through Codex app-server JSON-RPC, while the orchestration host is ADE — slash commands, attachments, and lane scoping come from ADE.", + "**Wake-up semantics:** No autonomous wake from ADE. If you need to wait, prefer `sleep ... && ` so the shell holds the wait without burning model tokens, then resume reasoning when the command produces output.", + ]; case "cursor-sdk": return [ "**Runtime:** ADE Work chat hosted on the Cursor SDK (`@cursor/sdk`).", @@ -146,7 +152,7 @@ export function buildCodingAgentSystemPrompt(args: { ? `Available tools: ${toolNames.join(", ")}.` : "Use the available tools deliberately and only when they move the task forward.", ...(guardedLocalReadOnly - ? runtime === "codex-cli" + ? runtime === "codex-cli" || runtime === "codex-app-server" ? [ interactive ? "Native Codex Plan Mode controls planning and approval. Preserve that built-in flow: stay read-only, use request_user_input for important clarifications when needed, and publish the final plan through Codex's proposed-plan mechanism." diff --git a/apps/desktop/src/main/services/chat/agentChatService.test.ts b/apps/desktop/src/main/services/chat/agentChatService.test.ts index 8c8f52f2b..26718ae48 100644 --- a/apps/desktop/src/main/services/chat/agentChatService.test.ts +++ b/apps/desktop/src/main/services/chat/agentChatService.test.ts @@ -1220,6 +1220,25 @@ async function waitForSessionTitle(sessionService: ReturnType = {}): LaneLinearIssue { return { id: "issue-1", @@ -1558,7 +1577,52 @@ describe("createAgentChatService", () => { expect(opts?.systemPrompt?.append).toContain("clean up old, stale, or finished processes"); }); - it("keeps Claude SDK project/user setting sources and skills enabled", async () => { + it("keeps ADE tooling guidance out of Claude SDK user turns", async () => { + const send = vi.fn().mockResolvedValue(undefined); + vi.mocked(claudeSdkCreateSessionCompat).mockReturnValue({ + send, + stream: vi.fn(() => (async function* () { + yield { + type: "result", + subtype: "success", + is_error: false, + session_id: "sdk-session-user-guidance", + usage: { input_tokens: 1, output_tokens: 1 }, + }; + })()), + close: vi.fn(), + sessionId: "sdk-session-user-guidance", + query: { + setPermissionMode: vi.fn(async () => undefined), + supportedCommands: vi.fn(async () => []), + }, + } as any); + + const { service } = createService(); + const session = await service.createSession({ + laneId: "lane-1", + provider: "claude", + model: "sonnet", + }); + + await service.runSessionTurn({ + sessionId: session.id, + text: "Inspect the repo and report the chat wiring.", + timeoutMs: 15_000, + }); + + const userTurnPayload = send.mock.calls + .map((call) => String(call[0] ?? "")) + .find((payload) => payload.includes("Inspect the repo and report the chat wiring.")); + + expect(userTurnPayload).toContain("[ADE launch directive]"); + expect(userTurnPayload).not.toContain("only normal reason to skip ADE CLI"); + expect(userTurnPayload).not.toContain("ade actions list --text"); + const opts = vi.mocked(claudeSdkCreateSessionCompat).mock.calls[0]?.[0] as { systemPrompt?: { append?: string } } | undefined; + expect(opts?.systemPrompt?.append).toContain("only normal reason to skip ADE CLI"); + }); + + it("keeps Claude SDK setting sources and skills enabled without output-style plugins", async () => { vi.mocked(claudeSdkCreateSessionCompat).mockReturnValue({ send: vi.fn(), stream: vi.fn(async function* () { @@ -1579,9 +1643,27 @@ describe("createAgentChatService", () => { expect(claudeSdkCreateSessionCompat).toHaveBeenCalled(); }); - const opts = vi.mocked(claudeSdkCreateSessionCompat).mock.calls[0]?.[0] as { settingSources?: string[]; skills?: string } | undefined; + const opts = vi.mocked(claudeSdkCreateSessionCompat).mock.calls[0]?.[0] as { + promptSuggestions?: boolean; + settingSources?: string[]; + settings?: { + enabledPlugins?: Record; + outputStyle?: string; + }; + skills?: string; + } | undefined; expect(opts?.settingSources).toEqual(expect.arrayContaining(["user", "project"])); expect(opts?.skills).toBe("all"); + expect(opts?.promptSuggestions).toBe(false); + expect(opts?.settings).toEqual(expect.objectContaining({ + outputStyle: "Default", + enabledPlugins: expect.objectContaining({ + "learning-output-style@claude-code-plugins": false, + "learning-output-style@claude-plugins-official": false, + "explanatory-output-style@claude-code-plugins": false, + "explanatory-output-style@claude-plugins-official": false, + }), + })); }); it("passes discovered local Claude plugins to SDK sessions", async () => { @@ -1590,6 +1672,9 @@ describe("createAgentChatService", () => { fs.writeFileSync(path.join(pluginRoot, ".claude-plugin", "plugin.json"), JSON.stringify({ name: "review-pack", })); + fs.writeFileSync(path.join(tmpRoot, ".claude", "settings.json"), JSON.stringify({ + enabledPlugins: { "review-pack@local": true }, + })); vi.mocked(claudeSdkCreateSessionCompat).mockReturnValue({ send: vi.fn(), stream: vi.fn(async function* () { @@ -2521,13 +2606,21 @@ describe("createAgentChatService", () => { }); const startPayload = mockState.codexRequestPayloads.find((payload) => payload.method === "thread/start"); - expect(startPayload?.params).toMatchObject({ cwd: expect.stringContaining("lane-2") }); + expect(startPayload?.params).toMatchObject({ + cwd: expect.stringContaining("lane-2"), + developerInstructions: "system prompt", + }); const turnStartRequest = mockState.codexRequestPayloads.find((payload) => payload.method === "turn/start"); - const turnParams = turnStartRequest?.params as { input?: Array<{ text?: unknown }> } | undefined; + const turnParams = turnStartRequest?.params as { + input?: Array<{ text?: unknown }>; + collaborationMode?: { settings?: { developer_instructions?: unknown } }; + } | undefined; const textInput = turnParams?.input?.map((entry) => String(entry.text ?? "")).join("\n") ?? ""; - expect(textInput).toContain("only normal reason to skip ADE CLI"); - expect(textInput).toContain("ade actions list --text"); + expect(turnParams?.collaborationMode?.settings?.developer_instructions).toBe("system prompt"); + expect(textInput).not.toContain("only normal reason to skip ADE CLI"); + expect(textInput).not.toContain("ade actions list --text"); + expect(textInput).toContain("Inspect the repo and fix the lane launch bug."); }); it("passes the selected Codex reasoning effort into app-server config", async () => { @@ -3861,6 +3954,55 @@ describe("createAgentChatService", () => { expect(commands).toEqual([]); }); + it("returns Claude commands for a draft lane before a chat session exists", async () => { + const commandsDir = path.join(tmpRoot, ".claude", "commands"); + fs.mkdirSync(commandsDir, { recursive: true }); + fs.writeFileSync(path.join(commandsDir, "shipLane.md"), [ + "---", + "description: Ship the active lane", + "---", + "", + "Ship lane.", + "", + ].join("\n")); + const { service } = createService(); + + const commands = service.getSlashCommands({ laneId: "lane-1", provider: "claude" }); + const names = commands.map((command) => command.name); + + expect(names).toContain("/agents"); + expect(names).toContain("/output-style"); + expect(commands).toEqual(expect.arrayContaining([ + expect.objectContaining({ + name: "/shipLane", + description: "Ship the active lane", + source: "sdk", + }), + ])); + expect(names).not.toContain("/login"); + }); + + it("returns Codex commands for a draft lane before a chat session exists", async () => { + const promptsDir = path.join(tmpRoot, ".codex", "prompts"); + fs.mkdirSync(promptsDir, { recursive: true }); + fs.writeFileSync(path.join(promptsDir, "audit.md"), "Audit recent work."); + const { service } = createService(); + + const commands = service.getSlashCommands({ laneId: "lane-1", provider: "codex" }); + const names = commands.map((command) => command.name); + + expect(names).toContain("/permissions"); + expect(names).toContain("/review"); + expect(commands).toEqual(expect.arrayContaining([ + expect.objectContaining({ + name: "/audit", + description: "Audit recent work.", + source: "sdk", + }), + ])); + expect(names).not.toContain("/apps"); + }); + it("returns local commands for a opencode session", async () => { const { service } = createService(); const session = await service.createSession({ @@ -4240,12 +4382,15 @@ describe("createAgentChatService", () => { describe("Claude plugins", () => { it("lists discovered local Claude plugins", async () => { const pluginRoot = path.join(tmpRoot, ".claude", "plugins", "team-tools", "review-plugin"); - fs.mkdirSync(path.join(pluginRoot, ".claude-plugin"), { recursive: true }); - fs.writeFileSync(path.join(pluginRoot, ".claude-plugin", "plugin.json"), JSON.stringify({ - name: "review-plugin", - description: "Review helpers", - })); - const { service } = createService(); + fs.mkdirSync(path.join(pluginRoot, ".claude-plugin"), { recursive: true }); + fs.writeFileSync(path.join(pluginRoot, ".claude-plugin", "plugin.json"), JSON.stringify({ + name: "review-plugin", + description: "Review helpers", + })); + fs.writeFileSync(path.join(tmpRoot, ".claude", "settings.json"), JSON.stringify({ + enabledPlugins: { "review-plugin@local": true }, + })); + const { service } = createService(); const session = await service.createSession({ laneId: "lane-1", provider: "claude", @@ -5459,6 +5604,151 @@ describe("createAgentChatService", () => { expect(textInput).toContain("Use the attached issue context."); }); + it("strips repeated automatic macOS VM context from Codex follow-up prompts", async () => { + const events: AgentChatEventEnvelope[] = []; + const { service } = createService({ + onEvent: (event: AgentChatEventEnvelope) => { + events.push(event); + }, + }); + const session = await service.createSession({ + laneId: "lane-1", + provider: "codex", + model: "gpt-5.4", + }); + + await service.sendMessage({ + sessionId: session.id, + text: automaticMacosVmContextText("First task."), + displayText: "First task.", + }); + await vi.waitFor(() => { + expect(mockState.codexRequestPayloads.filter((payload) => payload.method === "turn/start")).toHaveLength(1); + }); + const firstTurnStart = mockState.codexRequestPayloads.find((payload) => payload.method === "turn/start"); + const firstTurnParams = firstTurnStart?.params as { input?: Array<{ text?: unknown }> } | undefined; + const firstInput = firstTurnParams?.input?.map((entry) => String(entry.text ?? "")).join("\n") ?? ""; + expect(firstInput).toContain("ADE macOS VM capability for this lane"); + expect(firstInput).toContain("macos_vm_status"); + expect(firstInput).toContain("First task."); + mockState.emitCodexPayload({ + jsonrpc: "2.0", + method: "turn/started", + params: { turn: { id: "turn-1", status: "inProgress" } }, + }); + mockState.emitCodexPayload({ + jsonrpc: "2.0", + method: "turn/completed", + params: { turn: { id: "turn-1", status: "completed" } }, + }); + + mockState.codexRequestPayloads = []; + await service.sendMessage({ + sessionId: session.id, + text: automaticMacosVmContextText("Second task."), + displayText: "Second task.", + }); + await vi.waitFor(() => { + expect(mockState.codexRequestPayloads.filter((payload) => payload.method === "turn/start")).toHaveLength(1); + }); + const secondTurnStart = mockState.codexRequestPayloads.find((payload) => payload.method === "turn/start"); + const secondTurnParams = secondTurnStart?.params as { input?: Array<{ text?: unknown }> } | undefined; + const secondInput = secondTurnParams?.input?.map((entry) => String(entry.text ?? "")).join("\n") ?? ""; + expect(secondInput).not.toContain("ADE macOS VM capability for this lane"); + expect(secondInput).not.toContain("macos_vm_status"); + expect(secondInput).toContain("Second task."); + + const userMessages = events.filter((event): event is AgentChatEventEnvelope & { + event: Extract; + } => event.event.type === "user_message"); + expect(userMessages[0]?.event.text).toContain("ADE macOS VM capability for this lane"); + expect(userMessages[1]?.event.text).toBe("Second task."); + }); + + it("strips repeated automatic macOS VM context from Claude follow-up prompts", async () => { + const send = vi.fn(async (_message: unknown) => undefined); + vi.mocked(claudeSdkCreateSessionCompat).mockReturnValue({ + ...makeDefaultClaudeSession(), + send, + }); + const events: AgentChatEventEnvelope[] = []; + const { service } = createService({ + onEvent: (event: AgentChatEventEnvelope) => { + events.push(event); + }, + }); + const session = await service.createSession({ + laneId: "lane-1", + provider: "claude", + model: "sonnet", + }); + + await service.runSessionTurn({ + sessionId: session.id, + text: automaticMacosVmContextText("First Claude task."), + displayText: "First Claude task.", + }); + await service.runSessionTurn({ + sessionId: session.id, + text: automaticMacosVmContextText("Second Claude task."), + displayText: automaticMacosVmContextText("Second Claude task."), + }); + + const sentPrompts = send.mock.calls.map(([message]) => String(message)); + const firstPrompt = sentPrompts.find((message) => message.includes("First Claude task.")) ?? ""; + const secondPrompt = sentPrompts.find((message) => message.includes("Second Claude task.")) ?? ""; + expect(firstPrompt).toContain("ADE macOS VM capability for this lane"); + expect(firstPrompt).toContain("macos_vm_status"); + expect(secondPrompt).not.toContain("ADE macOS VM capability for this lane"); + expect(secondPrompt).not.toContain("macos_vm_status"); + expect(secondPrompt).toContain("Second Claude task."); + + const userMessages = events.filter((event): event is AgentChatEventEnvelope & { + event: Extract; + } => event.event.type === "user_message"); + expect(userMessages[1]?.event.text).toBe("Second Claude task."); + }); + + it("strips repeated automatic macOS VM context from Cursor SDK follow-up prompts", async () => { + process.env.CURSOR_API_KEY = "cursor-test-key"; + const events: AgentChatEventEnvelope[] = []; + const { service } = createService({ + onEvent: (event: AgentChatEventEnvelope) => { + events.push(event); + }, + }); + const session = await service.createSession({ + laneId: "lane-1", + provider: "cursor", + model: "composer-2", + modelId: "cursor/composer-2", + }); + + await service.runSessionTurn({ + sessionId: session.id, + text: automaticMacosVmContextText("First Cursor task."), + displayText: "First Cursor task.", + }); + await service.runSessionTurn({ + sessionId: session.id, + text: automaticMacosVmContextText("Second Cursor task."), + displayText: automaticMacosVmContextText("Second Cursor task."), + }); + + const firstPrompt = String(mockState.cursorSdkSendCalls[0]?.promptText ?? ""); + const secondPrompt = String(mockState.cursorSdkSendCalls[1]?.promptText ?? ""); + expect(firstPrompt).toContain("ADE macOS VM capability for this lane"); + expect(firstPrompt).toContain("macos_vm_status"); + expect(secondPrompt).not.toContain("ADE macOS VM capability for this lane"); + expect(secondPrompt).not.toContain("macos_vm_status"); + expect(secondPrompt).toContain("Second Cursor task."); + + const userMessages = events.filter((event): event is AgentChatEventEnvelope & { + event: Extract; + } => event.event.type === "user_message"); + expect(userMessages[1]?.event.text).toBe("Second Cursor task."); + }); + it("prefers the canonical turn-scoped Codex text stream when item-scoped deltas also arrive", async () => { const textEvents: Array<{ text: string; itemId?: string; turnId?: string }> = []; const { service } = createService({ @@ -7359,17 +7649,18 @@ describe("createAgentChatService", () => { expect(collaborationMode?.mode).toBe("plan"); expect(collaborationMode?.settings?.model).toBe("gpt-5.4"); expect(collaborationMode?.settings?.reasoning_effort).toBe("medium"); - expect(collaborationMode?.settings?.developer_instructions).toBeNull(); - expect(textInputs.at(-2)?.text).toContain("System context (ADE runtime guidance"); - expect(textInputs.at(-2)?.text).toContain("system prompt"); + expect(collaborationMode?.settings?.developer_instructions).toBe("system prompt"); + expect(textInputs).toHaveLength(1); expect(textInputs.at(-1)?.text).toContain("User request:"); expect(textInputs.at(-1)?.text).toContain("Ask one planning question before coding."); + expect(textInputs.at(-1)?.text).not.toContain("System context (ADE runtime guidance"); expect(vi.mocked(buildCodingAgentSystemPrompt)).toHaveBeenCalledWith( expect.objectContaining({ cwd: expect.stringContaining(path.basename(tmpRoot)), mode: "planning", permissionMode: "plan", interactive: true, + runtime: "codex-app-server", }), ); }); @@ -7811,7 +8102,7 @@ describe("createAgentChatService", () => { expect(params?.sandboxPolicy?.type).toBe("workspaceWrite"); expect(params?.effort).toBe("medium"); expect(collaborationMode?.mode).toBe("default"); - expect(collaborationMode?.settings?.developer_instructions).toBeNull(); + expect(collaborationMode?.settings?.developer_instructions).toBe("system prompt"); }); it("handles Codex /plan prompts inline and sends the next app-server turn in plan mode", async () => { diff --git a/apps/desktop/src/main/services/chat/agentChatService.ts b/apps/desktop/src/main/services/chat/agentChatService.ts index 130f79686..118c777b8 100644 --- a/apps/desktop/src/main/services/chat/agentChatService.ts +++ b/apps/desktop/src/main/services/chat/agentChatService.ts @@ -617,6 +617,12 @@ const CLAUDE_BUILT_IN_SLASH_COMMANDS: AgentChatSlashCommand[] = [ const CODEX_BUILT_IN_SLASH_COMMAND_NAMES = new Set(CODEX_BUILT_IN_SLASH_COMMANDS.map((command) => slashCommandKey(command.name))); const CLAUDE_BUILT_IN_SLASH_COMMAND_NAMES = new Set(CLAUDE_BUILT_IN_SLASH_COMMANDS.map((command) => slashCommandKey(command.name))); const CLAUDE_LOGIN_NOT_SDK_COMMAND = "ADE Claude chat is hosted through the Claude Agent SDK, and /login is not an SDK-dispatchable command. Run `claude auth login` in a terminal or configure ANTHROPIC_API_KEY, then refresh AI settings."; +const CLAUDE_SESSION_DISABLED_PLUGINS: Record = { + "learning-output-style@claude-code-plugins": false, + "learning-output-style@claude-plugins-official": false, + "explanatory-output-style@claude-code-plugins": false, + "explanatory-output-style@claude-plugins-official": false, +}; function slashCommandKey(value: string): string { return value.trim().toLowerCase(); @@ -646,6 +652,8 @@ type OpenCodeRuntime = { textByPartId: Map; reasoningByPartId: Map; toolStateByPartId: Map; + /** IDs of OpenCode child sessions already announced as subagents this run. */ + subagentSessionIds: Set; }; type CursorPermissionWaiter = @@ -696,6 +704,8 @@ type CursorRuntime = { cloudRuns: Map; /** RunId attached to the currently active cloud turn, when runtime === "cloud". */ activeCloudRunId: string | null; + /** Last observed Cursor task status by run_id for subagent lifecycle mapping. */ + cursorTaskStatusByRunId: Map; }; type DroidRuntime = { @@ -1294,6 +1304,11 @@ const DEFAULT_TRANSCRIPT_READ_LIMIT = 20; const MAX_TRANSCRIPT_READ_LIMIT = 100; const DEFAULT_TRANSCRIPT_READ_CHARS = 8_000; const MAX_TRANSCRIPT_READ_CHARS = 40_000; +const AUTOMATIC_MACOS_VM_CONTEXT_HEADER = "ADE macOS VM capability for this lane (automatic context)."; +const AUTOMATIC_MACOS_VM_CONTEXT_ENDINGS = [ + "- This lane uses a sanitized mirror for the VM share; ADE syncs code while excluding secrets, runtime databases, caches, transcripts, generated local history, and .git.", + "- Keep VM-side edits inside the mounted guest lane path so the host lane and guest stay aligned.", +] as const; const AUTO_TITLE_MAX_CHARS = 48; const REASONING_ACTIVITY_DETAIL = "Thinking through the answer"; const WORKING_ACTIVITY_DETAIL = "Preparing response"; @@ -3220,23 +3235,17 @@ function buildCodexDeveloperInstructions(args: { mode: promptMode, permissionMode: toHarnessPermissionMode(args.session.permissionMode), interactive: true, - runtime: "codex-cli", + runtime: "codex-app-server", }); } -function buildCodexAdeContextInput(args: { - laneWorktreePath: string; - session: Pick; - collaborationMode: "default" | "plan"; -}): Record { - return { - type: "text", - text: [ - "System context (ADE runtime guidance, do not echo verbatim):", - buildCodexDeveloperInstructions(args), - ].join("\n\n"), - text_elements: [], - }; +function resolveCodexInstructionCollaborationMode( + session: Pick, +): "default" | "plan" { + return (session.interactionMode === "plan" || session.permissionMode === "plan") + && session.surface !== "mission" + ? "plan" + : "default"; } function buildCodexCollaborationMode( @@ -3245,13 +3254,11 @@ function buildCodexCollaborationMode( "provider" | "permissionMode" | "interactionMode" | "model" | "reasoningEffort" | "codexConfigSource" | "surface" >, supportedModes: Set | null, + laneWorktreePath: string, ): CodexCollaborationModePayload | null { if (session.provider !== "codex") return null; if (resolveSessionCodexConfigSource(session) === "config-toml") return null; - const requestedMode = (session.interactionMode === "plan" || session.permissionMode === "plan") - && session.surface !== "mission" - ? "plan" - : "default"; + const requestedMode = resolveCodexInstructionCollaborationMode(session); const mode = (() => { if (!supportedModes || supportedModes.size === 0) return requestedMode; if (supportedModes.has(requestedMode)) return requestedMode; @@ -3264,7 +3271,11 @@ function buildCodexCollaborationMode( settings: { model: session.model, reasoning_effort: session.reasoningEffort ?? DEFAULT_REASONING_EFFORT, - developer_instructions: null, + developer_instructions: buildCodexDeveloperInstructions({ + laneWorktreePath, + session, + collaborationMode: mode, + }), }, }; } @@ -3277,10 +3288,7 @@ function resolveRequestedCodexCollaborationMode( ): "default" | "plan" | null { if (session.provider !== "codex") return null; if (resolveSessionCodexConfigSource(session) === "config-toml") return null; - return (session.interactionMode === "plan" || session.permissionMode === "plan") - && session.surface !== "mission" - ? "plan" - : "default"; + return resolveCodexInstructionCollaborationMode(session); } function parseCodexCollaborationModes(value: unknown): Set | null { @@ -5183,6 +5191,27 @@ export function createAgentChatService(args: { } }; + const isAutomaticMacosVmContextUserMessage = (entry: AgentChatEventEnvelope): boolean => + entry.event.type === "user_message" + && entry.event.text.trimStart().startsWith(AUTOMATIC_MACOS_VM_CONTEXT_HEADER); + + const hasAutomaticMacosVmContextInTranscript = (managed: ManagedChatSession): boolean => + (eventHistoryBySession.get(managed.session.id) ?? []).some(isAutomaticMacosVmContextUserMessage) + || readTranscriptEnvelopes(managed).some(isAutomaticMacosVmContextUserMessage); + + const stripAutomaticMacosVmContextPrefix = (text: string): string | null => { + const leadingTrimmed = text.trimStart(); + if (!leadingTrimmed.startsWith(AUTOMATIC_MACOS_VM_CONTEXT_HEADER)) return null; + const leadingWhitespaceLength = text.length - leadingTrimmed.length; + const contextStart = leadingWhitespaceLength; + for (const ending of AUTOMATIC_MACOS_VM_CONTEXT_ENDINGS) { + const endingIndex = text.indexOf(ending, contextStart); + if (endingIndex === -1) continue; + return text.slice(endingIndex + ending.length).trimStart(); + } + return null; + }; + // Read the full on-disk transcript for a session without requiring an active // ManagedChatSession. Used by getChatEventHistory to hydrate the in-memory // ring buffer on first read, even for sessions that haven't been resumed yet @@ -6070,6 +6099,7 @@ export function createAgentChatService(args: { textByPartId: new Map(), reasoningByPartId: new Map(), toolStateByPartId: new Map(), + subagentSessionIds: new Set(), }; handle.setEvictionHandler((reason) => { if (managed.runtime?.kind === "opencode" && managed.runtime.handle === handle) { @@ -8339,6 +8369,7 @@ export function createAgentChatService(args: { const collaborationMode = buildCodexCollaborationMode( managed.session, runtime.collaborationModes, + managed.laneWorktreePath, ); if ( requestedCollaborationMode === "plan" @@ -8354,13 +8385,6 @@ export function createAgentChatService(args: { } else if (collaborationMode?.mode === "plan") { runtime.planModeFallbackNotified = false; } - if (collaborationMode) { - input.push(buildCodexAdeContextInput({ - laneWorktreePath: managed.laneWorktreePath, - session: managed.session, - collaborationMode: collaborationMode.mode, - })); - } input.push({ type: "text", text: effectivePromptText, @@ -9843,6 +9867,73 @@ export function createAgentChatService(args: { } }; + // Surface OpenCode child sessions (spawned via the `task` subagent + // tool) as subagent lifecycle events. Child sessions carry a + // `parentID` pointing at this runtime's primary session. + if ( + event.type === "session.created" + || event.type === "session.updated" + || event.type === "session.deleted" + ) { + const childInfo = event.properties.info; + if ( + childInfo.parentID + && childInfo.parentID === runtime.handle.sessionId + && childInfo.id !== runtime.handle.sessionId + ) { + const childKey = childInfo.id; + const childDescription = (childInfo.title && childInfo.title.length) + ? childInfo.title + : "subagent"; + const formatSummary = (): string => { + const summary = childInfo.summary; + return summary + ? `+${summary.additions} −${summary.deletions} · ${summary.files} files` + : childDescription; + }; + const ensureSubagentStarted = (): void => { + if (runtime.subagentSessionIds.has(childKey)) return; + runtime.subagentSessionIds.add(childKey); + emitChatEvent(managed, { + type: "subagent_started", + taskId: childKey, + parentToolUseId: null, + description: childDescription, + agentType: "opencode-subagent", + turnId, + }); + }; + + if (event.type === "session.created") { + ensureSubagentStarted(); + } else if (event.type === "session.updated") { + // Synthesize started first if we missed the created event so the + // panel has a row to update. + ensureSubagentStarted(); + emitChatEvent(managed, { + type: "subagent_progress", + taskId: childKey, + parentToolUseId: null, + description: childDescription, + summary: formatSummary(), + turnId, + }); + } else { + // session.deleted + emitChatEvent(managed, { + type: "subagent_result", + taskId: childKey, + parentToolUseId: null, + status: "completed", + summary: formatSummary(), + turnId, + }); + runtime.subagentSessionIds.delete(childKey); + } + continue; + } + } + if (resolveSessionId() !== runtime.handle.sessionId) { continue; } @@ -12197,6 +12288,11 @@ export function createAgentChatService(args: { model: managed.session.model, cwd: managed.laneWorktreePath, effort: reasoningEffort, + developerInstructions: buildCodexDeveloperInstructions({ + laneWorktreePath: managed.laneWorktreePath, + session: managed.session, + collaborationMode: resolveCodexInstructionCollaborationMode(managed.session), + }), ...codexServiceTierArgs(managed.session), ...codexPolicyArgs(codexPolicy), experimentalRawEvents: false, @@ -12498,14 +12594,17 @@ export function createAgentChatService(args: { cwd: managed.laneWorktreePath, env: claudeEnv, pathToClaudeCodeExecutable: claudeExecutable.path, - settings: { outputStyle }, + settings: { + outputStyle, + enabledPlugins: CLAUDE_SESSION_DISABLED_PLUGINS, + }, ...(pluginPaths.length ? { plugins: pluginPaths.map((pluginPath) => ({ type: "local" as const, path: pluginPath })) } : {}), ...(Object.keys(mcpServers).length ? { mcpServers } : {}), permissionMode: claudePermissionMode as any, ...(claudePermissionMode === "bypassPermissions" ? { allowDangerouslySkipPermissions: true } as any : {}), includePartialMessages: true, agentProgressSummaries: true, - promptSuggestions: true, + promptSuggestions: false, forwardSubagentText: true, enableFileCheckpointing: true, skills: "all", @@ -13844,17 +13943,27 @@ export function createAgentChatService(args: { cloudOverrides, allowActiveSession = false, }: AgentChatSendArgs & { allowActiveSession?: boolean }): PreparedSendMessage | null => { + const managed = ensureManagedSession(sessionId); const publicContextAttachments = normalizeChatContextAttachments(contextAttachments); - const trimmedText = text.trim(); + const strippedSubmittedText = stripAutomaticMacosVmContextPrefix(text); + const stripRepeatedAutomaticContext = strippedSubmittedText != null + && hasAutomaticMacosVmContextInTranscript(managed); + const submittedRawText = stripRepeatedAutomaticContext + ? strippedSubmittedText + : text; + const trimmedText = submittedRawText.trim(); const trimmed = trimmedText.length || !publicContextAttachments.length ? trimmedText : "Use the attached issue context."; if (!trimmed.length) return null; const slashCommand = extractLeadingSlashCommand(trimmed); const providerSlashCommand = isProviderSlashCommandInput(trimmed); - const visibleText = displayText?.trim().length ? displayText.trim() : trimmed; + const rawDisplayText = displayText?.trim().length ? displayText : undefined; + const cleanedDisplayText = stripRepeatedAutomaticContext && rawDisplayText + ? stripAutomaticMacosVmContextPrefix(rawDisplayText) ?? rawDisplayText + : rawDisplayText; + const visibleText = cleanedDisplayText?.trim().length ? cleanedDisplayText.trim() : trimmed; - const managed = ensureManagedSession(sessionId); if (hasLivePendingInput(managed)) { throw new Error(PENDING_INPUT_SEND_BLOCKED_MESSAGE); } @@ -13949,13 +14058,13 @@ export function createAgentChatService(args: { const laneDirectiveKey = executionContext.laneDirectiveKey; const shouldInjectLaneDirective = laneDirectiveKey != null && managed.lastLaneDirectiveKey !== laneDirectiveKey; // Guidance injection is capability-based, not session-state-based: - // Claude sessions already receive ADE_CLI_AGENT_GUIDANCE in their - // persistent system prompt (see buildClaudeQueryOptions), so we skip the - // first-user-message copy there. Every other provider (Codex, OpenCode, - // Cursor…) has no persistent system prompt, so the guidance must be - // prepended even on resumed sessions where `shouldInjectLaneDirective` is - // false (review 3134504183 / 3134403060). - const providerHasPersistentGuidance = managed.session.provider === "claude"; + // Claude sessions receive ADE_CLI_AGENT_GUIDANCE in their persistent system + // prompt, and native Codex app-server sessions receive ADE guidance through + // developerInstructions/collaborationMode settings. Providers without a + // trusted instruction channel still need the guidance in the user prompt, + // including on resumed sessions where `shouldInjectLaneDirective` is false. + const providerHasPersistentGuidance = managed.session.provider === "claude" + || managed.session.provider === "codex"; const shouldInjectGuidance = !providerHasPersistentGuidance; const claudeRuntimeSlashCommandNames = managed.runtime?.kind === "claude" ? new Set(managed.runtime.slashCommands.map((command) => slashCommandKey(command.name))) @@ -15314,6 +15423,7 @@ export function createAgentChatService(args: { const events = mapCursorSdkMessageToChatEvents(event, { turnId, cwd: managed.laneWorktreePath, + taskStatusMap: runtime.cursorTaskStatusByRunId, runtime: isCloud ? "cloud" : "local", ...(meta?.runId ? { runId: meta.runId } : {}), }); @@ -15467,6 +15577,7 @@ export function createAgentChatService(args: { configOptions: [], cloudRuns: new Map(), activeCloudRunId: null, + cursorTaskStatusByRunId: new Map(), }; managed.runtime = rt; wireCursorSdkBridgeHandlers(managed, rt); @@ -17032,6 +17143,11 @@ export function createAgentChatService(args: { model: managed.session.model, cwd: managed.laneWorktreePath, effort: resumeReasoningEffort, + developerInstructions: buildCodexDeveloperInstructions({ + laneWorktreePath: managed.laneWorktreePath, + session: managed.session, + collaborationMode: resolveCodexInstructionCollaborationMode(managed.session), + }), ...codexServiceTierArgs(managed.session), ...codexPolicyArgs(codexPolicy), excludeTurns: true, @@ -19535,10 +19651,28 @@ export function createAgentChatService(args: { return deriveSessionCapabilities(managed); }; - const getSlashCommands = ({ sessionId }: AgentChatSlashCommandsArgs): AgentChatSlashCommand[] => { - const managed = managedSessions.get(sessionId); - if (!managed) return []; - const provider = managed.session.provider; + const getSlashCommands = (args: AgentChatSlashCommandsArgs): AgentChatSlashCommand[] => { + const requestedSessionId = args.sessionId?.trim() ?? ""; + const managed = requestedSessionId.length ? managedSessions.get(requestedSessionId) ?? null : null; + if (requestedSessionId.length && !managed && !args.provider) return []; + const provider = managed?.session.provider ?? args.provider ?? null; + if (!provider) return []; + + function resolveLaneWorktreePath(): string { + if (managed) return managed.laneWorktreePath; + const laneId = args.laneId?.trim() ?? ""; + if (!laneId.length) return projectRoot; + try { + return resolveLaneLaunchContext({ + laneService, + laneId, + purpose: "list slash commands", + }).laneWorktreePath; + } catch { + return projectRoot; + } + } + const laneWorktreePath = resolveLaneWorktreePath(); const localCommands: AgentChatSlashCommand[] = provider === "claude" || provider === "codex" ? [] @@ -19556,7 +19690,7 @@ export function createAgentChatService(args: { // Claude SDK commands plus filesystem-backed Claude Code commands/skills. if (provider === "claude") { - const runtimeCommands: AgentChatSlashCommand[] = (managed.runtime?.kind === "claude" ? managed.runtime.slashCommands : []) + const runtimeCommands: AgentChatSlashCommand[] = (managed?.runtime?.kind === "claude" ? managed.runtime.slashCommands : []) .filter(isDispatchableClaudeSdkSlashCommand) .map((cmd: { name: string; description: string; argumentHint?: string }) => ({ name: cmd.name, @@ -19564,7 +19698,7 @@ export function createAgentChatService(args: { argumentHint: cmd.argumentHint, source: "sdk" as const, })); - const projectCommands: AgentChatSlashCommand[] = discoverClaudeSlashCommands(managed.laneWorktreePath) + const projectCommands: AgentChatSlashCommand[] = discoverClaudeSlashCommands(laneWorktreePath) .filter(isDispatchableClaudeSdkSlashCommand) .map((cmd: { name: string; description: string; argumentHint?: string }) => ({ name: cmd.name, @@ -19577,20 +19711,20 @@ export function createAgentChatService(args: { // Codex SDK commands if (provider === "codex") { - const rt = managed.runtime?.kind === "codex" ? managed.runtime : null; + const rt = managed?.runtime?.kind === "codex" ? managed.runtime : null; const dynamicCommands: AgentChatSlashCommand[] = (rt?.slashCommands ?? []).map((cmd: { name: string; description: string; argumentHint?: string }) => ({ name: cmd.name, description: cmd.description, argumentHint: cmd.argumentHint, source: "sdk" as const, })); - const promptCommands: AgentChatSlashCommand[] = discoverCodexSlashCommands(managed.laneWorktreePath).map((cmd: { name: string; description: string; argumentHint?: string }) => ({ + const promptCommands: AgentChatSlashCommand[] = discoverCodexSlashCommands(laneWorktreePath).map((cmd: { name: string; description: string; argumentHint?: string }) => ({ name: cmd.name, description: cmd.description, argumentHint: cmd.argumentHint, source: "sdk" as const, })); - const claudeProjectCommands: AgentChatSlashCommand[] = discoverClaudeSlashCommands(managed.laneWorktreePath) + const claudeProjectCommands: AgentChatSlashCommand[] = discoverClaudeSlashCommands(laneWorktreePath) .filter(isDispatchableClaudeSdkSlashCommand) .map((cmd: { name: string; description: string; argumentHint?: string }) => ({ name: cmd.name, diff --git a/apps/desktop/src/main/services/chat/claudeOutputStyles.test.ts b/apps/desktop/src/main/services/chat/claudeOutputStyles.test.ts index 81b583f7a..260fa0ac9 100644 --- a/apps/desktop/src/main/services/chat/claudeOutputStyles.test.ts +++ b/apps/desktop/src/main/services/chat/claudeOutputStyles.test.ts @@ -63,6 +63,9 @@ describe("discoverClaudeOutputStyles", () => { fs.mkdirSync(path.join(pluginRoot, ".claude-plugin"), { recursive: true }); fs.mkdirSync(styleDir, { recursive: true }); fs.writeFileSync(path.join(pluginRoot, ".claude-plugin", "plugin.json"), JSON.stringify({ name: "style-plugin" })); + fs.writeFileSync(path.join(tmpRoot, ".claude", "settings.json"), JSON.stringify({ + enabledPlugins: { "style-plugin@local": true }, + })); fs.writeFileSync(path.join(styleDir, "brief.md"), [ "---", "name: Brief", @@ -99,6 +102,9 @@ describe("discoverClaudePlugins", () => { description: "Review helpers", version: "1.2.3", })); + fs.writeFileSync(path.join(tmpRoot, ".claude", "settings.json"), JSON.stringify({ + enabledPlugins: { "review-plugin@local": true }, + })); expect(discoverClaudePlugins(tmpRoot)).toEqual([ { @@ -177,7 +183,6 @@ describe("discoverClaudePlugins", () => { fs.writeFileSync(path.join(disabledRoot, ".claude-plugin", "plugin.json"), JSON.stringify({ name: "serena", })); - fs.mkdirSync(path.join(homeRoot, ".claude", "plugins"), { recursive: true }); fs.writeFileSync(path.join(homeRoot, ".claude", "settings.json"), JSON.stringify({ enabledPlugins: { "code-simplifier@claude-plugins-official": true, @@ -204,4 +209,35 @@ describe("discoverClaudePlugins", () => { }, ]); }); + + it("does not load disabled plugins just because they exist in the user plugin tree", () => { + const enabledRoot = path.join(homeRoot, ".claude", "plugins", "cache", "claude-plugins-official", "review-pack", "1.0.0"); + const disabledRoot = path.join(homeRoot, ".claude", "plugins", "marketplaces", "claude-plugins-official", "external_plugins", "serena"); + fs.mkdirSync(path.join(enabledRoot, ".claude-plugin"), { recursive: true }); + fs.mkdirSync(path.join(disabledRoot, ".claude-plugin"), { recursive: true }); + fs.writeFileSync(path.join(enabledRoot, ".claude-plugin", "plugin.json"), JSON.stringify({ name: "review-pack" })); + fs.writeFileSync(path.join(disabledRoot, ".claude-plugin", "plugin.json"), JSON.stringify({ name: "serena" })); + fs.writeFileSync(path.join(disabledRoot, ".mcp.json"), JSON.stringify({ + serena: { command: "uvx", args: ["serena", "start-mcp-server"] }, + })); + fs.writeFileSync(path.join(homeRoot, ".claude", "settings.json"), JSON.stringify({ + enabledPlugins: { + "review-pack@claude-plugins-official": true, + "serena@claude-plugins-official": false, + }, + })); + fs.writeFileSync(path.join(homeRoot, ".claude", "plugins", "installed_plugins.json"), JSON.stringify({ + version: 2, + plugins: { + "review-pack@claude-plugins-official": [ + { scope: "user", installPath: enabledRoot, version: "1.0.0" }, + ], + "serena@claude-plugins-official": [ + { scope: "user", installPath: disabledRoot, version: "1.0.0" }, + ], + }, + })); + + expect(discoverClaudePlugins(tmpRoot).map((plugin) => plugin.name)).toEqual(["review-pack"]); + }); }); diff --git a/apps/desktop/src/main/services/chat/claudeOutputStyles.ts b/apps/desktop/src/main/services/chat/claudeOutputStyles.ts index df56e9c11..a251caf7d 100644 --- a/apps/desktop/src/main/services/chat/claudeOutputStyles.ts +++ b/apps/desktop/src/main/services/chat/claudeOutputStyles.ts @@ -7,7 +7,7 @@ import { writeTextAtomic } from "../shared/utils"; const MAX_ANCESTOR_DEPTH = 25; const MAX_PLUGIN_DEPTH = 6; -const CLAUDE_MANAGED_PLUGIN_DIRS = new Set(["cache", "marketplaces"]); +const CLAUDE_MANAGED_PLUGIN_DIRS = new Set(["cache", "data", "marketplaces"]); export const CLAUDE_BUILT_IN_OUTPUT_STYLES: AgentChatClaudeOutputStyle[] = [ { @@ -44,8 +44,8 @@ type ClaudePluginManifest = { }; type ClaudeSettingsLocal = Record & { - enabledPlugins?: unknown; outputStyle?: unknown; + enabledPlugins?: unknown; }; type ClaudeInstalledPluginEntry = Record & { @@ -73,19 +73,8 @@ function maybeString(value: unknown): string | undefined { return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined; } -function maybeRecord(value: unknown): Record | undefined { - return value && typeof value === "object" && !Array.isArray(value) - ? value as Record - : undefined; -} - -function readJsonObject(filePath: string): Record { - try { - const parsed = JSON.parse(fs.readFileSync(filePath, "utf8")); - return maybeRecord(parsed) ?? {}; - } catch { - return {}; - } +function isRecord(value: unknown): value is Record { + return Boolean(value) && typeof value === "object" && !Array.isArray(value); } function styleKey(value: string): string { @@ -105,15 +94,95 @@ function realpathOrResolve(targetPath: string): string { } } -function isSamePathOrDescendant(candidatePath: string, ancestorPath: string): boolean { - const relative = path.relative(ancestorPath, candidatePath); - return relative === "" || (!!relative && !relative.startsWith("..") && !path.isAbsolute(relative)); +function readJsonObject(filePath: string): Record | null { + try { + const parsed = JSON.parse(fs.readFileSync(filePath, "utf8")); + return isRecord(parsed) ? parsed : null; + } catch { + return null; + } +} + +function isEnabledPluginSetting(value: unknown): boolean { + if (value === false || value === null || value === undefined) return false; + if (isRecord(value) && value.enabled === false) return false; + return true; +} + +function pluginKeyName(pluginKey: string): string { + return pluginKey.split("@", 1)[0]?.trim().toLowerCase() ?? ""; } -function pathsOverlap(firstPath: string, secondPath: string): boolean { - const first = realpathOrResolve(firstPath); - const second = realpathOrResolve(secondPath); - return isSamePathOrDescendant(first, second) || isSamePathOrDescendant(second, first); +function enabledPluginStateFromFile(filePath: string): Map { + const parsed = readJsonObject(filePath); + const enabledPlugins = isRecord(parsed?.enabledPlugins) ? parsed.enabledPlugins : null; + const states = new Map(); + if (!enabledPlugins) return states; + for (const [key, value] of Object.entries(enabledPlugins)) { + states.set(key, isEnabledPluginSetting(value)); + } + return states; +} + +function enabledPluginKeysForRoots(roots: string[]): Set { + const states = new Map(); + for (const root of [...roots].reverse()) { + for (const fileName of ["settings.json", "settings.local.json"]) { + for (const [key, enabled] of enabledPluginStateFromFile(path.join(root, fileName))) { + states.set(key, enabled); + } + } + } + return new Set([...states.entries()] + .filter(([, enabled]) => enabled) + .map(([key]) => key)); +} + +function enabledPluginNames(keys: Set): Set { + return new Set([...keys].map(pluginKeyName).filter(Boolean)); +} + +function readPluginManifest(pluginRoot: string): ClaudePluginManifest { + const parsed = readJsonObject(path.join(pluginRoot, ".claude-plugin", "plugin.json")); + return parsed ?? {}; +} + +function pathsOverlap(left: string, right: string): boolean { + const leftPath = realpathOrResolve(left); + const rightPath = realpathOrResolve(right); + return ( + leftPath === rightPath + || leftPath.startsWith(`${rightPath}${path.sep}`) + || rightPath.startsWith(`${leftPath}${path.sep}`) + ); +} + +function installedClaudePluginPaths(cwd: string, enabledKeys: Set, enabledNames: Set): string[] { + const registry = readJsonObject(path.join(os.homedir(), ".claude", "plugins", "installed_plugins.json")); + const plugins = isRecord(registry?.plugins) ? registry.plugins : null; + if (!plugins) return []; + + const paths: string[] = []; + for (const [pluginKey, records] of Object.entries(plugins)) { + if (!enabledKeys.has(pluginKey) && !enabledNames.has(pluginKeyName(pluginKey))) continue; + if (!Array.isArray(records)) continue; + for (const record of records) { + if (!isRecord(record)) continue; + const entry = record as ClaudeInstalledPluginEntry; + const installPath = maybeString(entry.installPath) ?? maybeString(entry.path); + if (!installPath) continue; + const scope = maybeString(entry.scope) ?? "user"; + const projectPath = maybeString(entry.projectPath); + const applies = scope === "user" + || !projectPath + || pathsOverlap(cwd, projectPath); + if (!applies) continue; + if (fs.existsSync(path.join(installPath, ".claude-plugin", "plugin.json"))) { + paths.push(realpathOrResolve(installPath)); + } + } + } + return paths; } function ancestorClaudeRoots(cwd: string): string[] { @@ -223,98 +292,13 @@ function discoverPluginRoots(pluginsDir: string): string[] { return roots; } -function isEnabledPluginSetting(value: unknown): boolean { - if (value === false || value === null || value === undefined) return false; - if (maybeRecord(value)?.enabled === false) return false; - return true; -} - -function claudeSettingsFilesByPrecedence(cwd: string): string[] { - const homeClaudeRoot = path.resolve(os.homedir(), ".claude"); - const projectRoots = ancestorClaudeRoots(cwd) - .map((root) => path.resolve(root)) - .filter((root) => root !== homeClaudeRoot) - .reverse(); - - return [ - path.join(homeClaudeRoot, "settings.json"), - ...projectRoots.flatMap((root) => [ - path.join(root, "settings.json"), - path.join(root, "settings.local.json"), - ]), - ]; -} - -function discoverEnabledClaudePluginIds(cwd: string): Set { - const enabledByPluginId = new Map(); - for (const settingsPath of claudeSettingsFilesByPrecedence(cwd)) { - const enabledPlugins = maybeRecord(readJsonObject(settingsPath).enabledPlugins); - if (!enabledPlugins) continue; - for (const [pluginId, enabled] of Object.entries(enabledPlugins)) { - enabledByPluginId.set(pluginId, isEnabledPluginSetting(enabled)); - } - } - return new Set( - [...enabledByPluginId.entries()] - .filter(([, enabled]) => enabled) - .map(([pluginId]) => pluginId), - ); -} - -function readClaudeInstalledPluginEntries(homeClaudeRoot: string): Map { - const registry = readJsonObject(path.join(homeClaudeRoot, "plugins", "installed_plugins.json")); - const rawPlugins = maybeRecord(registry.plugins); - const entriesByPluginId = new Map(); - if (!rawPlugins) return entriesByPluginId; - - for (const [pluginId, rawEntries] of Object.entries(rawPlugins)) { - const rawEntryList = Array.isArray(rawEntries) ? rawEntries : [rawEntries]; - const entries = rawEntryList - .map((entry) => maybeRecord(entry)) - .filter((entry): entry is ClaudeInstalledPluginEntry => !!entry); - if (entries.length) entriesByPluginId.set(pluginId, entries); - } - - return entriesByPluginId; -} - -function installedPluginEntryAppliesToCwd(entry: ClaudeInstalledPluginEntry, cwd: string): boolean { - const scope = maybeString(entry.scope); - if (!scope || scope === "user" || scope === "global") return true; - - const projectPath = maybeString(entry.projectPath); - if (!projectPath) return false; - return pathsOverlap(cwd, projectPath); -} - -function discoverEnabledInstalledPluginPaths(cwd: string): string[] { - const enabledPluginIds = discoverEnabledClaudePluginIds(cwd); - if (!enabledPluginIds.size) return []; - - const homeClaudeRoot = path.resolve(os.homedir(), ".claude"); - const installedPluginEntries = readClaudeInstalledPluginEntries(homeClaudeRoot); - const pluginPaths: string[] = []; - const seen = new Set(); - - for (const pluginId of enabledPluginIds) { - for (const entry of installedPluginEntries.get(pluginId) ?? []) { - if (!installedPluginEntryAppliesToCwd(entry, cwd)) continue; - const installPath = maybeString(entry.installPath) ?? maybeString(entry.path); - if (!installPath || !fs.existsSync(path.join(installPath, ".claude-plugin", "plugin.json"))) continue; - const resolved = realpathOrResolve(installPath); - if (seen.has(resolved)) continue; - seen.add(resolved); - pluginPaths.push(resolved); - } - } - - return pluginPaths; -} - export function discoverClaudePluginPaths(cwd: string): string[] { const roots = claudeRootsByPrecedence(cwd); + const enabledKeys = enabledPluginKeysForRoots(roots); + const enabledNames = enabledPluginNames(enabledKeys); const pluginPaths: string[] = []; const seen = new Set(); + const addPluginPath = (pluginRoot: string): void => { const resolved = realpathOrResolve(pluginRoot); if (seen.has(resolved)) return; @@ -322,24 +306,22 @@ export function discoverClaudePluginPaths(cwd: string): string[] { pluginPaths.push(resolved); }; + for (const pluginRoot of installedClaudePluginPaths(cwd, enabledKeys, enabledNames)) { + addPluginPath(pluginRoot); + } + for (const root of roots) { for (const pluginRoot of discoverPluginRoots(path.join(root, "plugins"))) { addPluginPath(pluginRoot); } } - for (const pluginRoot of discoverEnabledInstalledPluginPaths(cwd)) addPluginPath(pluginRoot); + return pluginPaths; } export function discoverClaudePlugins(cwd: string): AgentChatClaudePlugin[] { return discoverClaudePluginPaths(cwd).map((pluginPath) => { - const manifestPath = path.join(pluginPath, ".claude-plugin", "plugin.json"); - let manifest: ClaudePluginManifest = {}; - try { - manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8")) as ClaudePluginManifest; - } catch { - manifest = {}; - } + const manifest = readPluginManifest(pluginPath); return { name: maybeString(manifest.name) ?? path.basename(pluginPath), path: pluginPath, diff --git a/apps/desktop/src/main/services/chat/cursorSdkEventMapper.test.ts b/apps/desktop/src/main/services/chat/cursorSdkEventMapper.test.ts index cdcb7a0d7..6143b627f 100644 --- a/apps/desktop/src/main/services/chat/cursorSdkEventMapper.test.ts +++ b/apps/desktop/src/main/services/chat/cursorSdkEventMapper.test.ts @@ -1,10 +1,20 @@ import { describe, expect, it } from "vitest"; import { + type CursorSdkEventMapperMeta, mapCursorSdkMessageToChatEvents, mapCursorSdkRunResultToDoneEvent, mapTurnEndedTokensToEvent, } from "./cursorSdkEventMapper"; +function mapperMeta(overrides: Partial = {}): CursorSdkEventMapperMeta { + return { + turnId: "turn-1", + cwd: "/repo", + taskStatusMap: new Map(), + ...overrides, + }; +} + describe("Cursor SDK event mapper", () => { it("maps assistant text content to chat text events", () => { const events = mapCursorSdkMessageToChatEvents({ @@ -15,7 +25,7 @@ describe("Cursor SDK event mapper", () => { { type: "text", text: "world" }, ], }, - }, { turnId: "turn-1", cwd: "/repo" }); + }, mapperMeta()); expect(events).toEqual([ { type: "text", text: "hello", turnId: "turn-1" }, @@ -31,7 +41,7 @@ describe("Cursor SDK event mapper", () => { status: "completed", args: { command: "npm test", cwd: "/repo" }, result: { exitCode: 0, output: "ok" }, - }, { turnId: "turn-1", cwd: "/fallback" }); + }, mapperMeta({ cwd: "/fallback" })); expect(events).toEqual([{ type: "command", @@ -52,7 +62,7 @@ describe("Cursor SDK event mapper", () => { name: "mystery", status: "running", args: { value: 1 }, - }, { turnId: "turn-1", cwd: "/repo" })).toEqual([{ + }, mapperMeta())).toEqual([{ type: "tool_call", tool: "mystery", args: { value: 1 }, @@ -79,7 +89,7 @@ describe("Cursor SDK event mapper", () => { const events = mapCursorSdkMessageToChatEvents({ type: "assistant", message: { content: [{ type: "text", text: "hi" }] }, - }, { turnId: "turn-1", cwd: "/repo", runtime: "cloud" }); + }, mapperMeta({ runtime: "cloud" })); expect(events).toEqual([ { type: "text", text: "hi", turnId: "turn-1", runtime: "cloud" }, ]); @@ -89,7 +99,7 @@ describe("Cursor SDK event mapper", () => { const events = mapCursorSdkMessageToChatEvents({ type: "assistant", message: { content: [{ type: "text", text: "hi" }] }, - }, { turnId: "turn-1", cwd: "/repo" }); + }, mapperMeta()); expect(events[0]).not.toHaveProperty("runtime"); }); @@ -99,7 +109,7 @@ describe("Cursor SDK event mapper", () => { status: "RUNNING", message: "VM provisioned", run_id: "run-7", - }, { turnId: "turn-1", cwd: "/repo", runtime: "cloud", runId: "run-7" }); + }, mapperMeta({ runtime: "cloud", runId: "run-7" })); expect(events).toEqual([{ type: "cloud_status", turnId: "turn-1", @@ -115,7 +125,7 @@ describe("Cursor SDK event mapper", () => { status: "FINISHED", run_id: "run-9", git: { branch: "feat/foo", prUrl: "https://github.com/x/y/pull/12" }, - }, { turnId: "turn-1", cwd: "/repo", runtime: "cloud", runId: "run-9" }); + }, mapperMeta({ runtime: "cloud", runId: "run-9" })); expect(events[0]).toMatchObject({ type: "cloud_status", status: "finished", @@ -128,7 +138,7 @@ describe("Cursor SDK event mapper", () => { const events = mapCursorSdkMessageToChatEvents({ type: "status", status: "wat", - }, { turnId: "turn-1", cwd: "/repo", runtime: "cloud" }); + }, mapperMeta({ runtime: "cloud" })); expect(events).toHaveLength(1); expect(events[0]).toMatchObject({ type: "activity", @@ -143,7 +153,7 @@ describe("Cursor SDK event mapper", () => { type: "status", status: "RUNNING", message: "going", - }, { turnId: "turn-1", cwd: "/repo" }); + }, mapperMeta()); expect(events).toEqual([{ type: "activity", activity: "working", @@ -152,6 +162,115 @@ describe("Cursor SDK event mapper", () => { }]); }); + it("uses the provided task status map for task lifecycle transitions", () => { + const taskStatusMap = new Map(); + const started = mapCursorSdkMessageToChatEvents({ + type: "task", + run_id: "task-1", + agent_id: "agent-1", + status: "running", + text: "Investigate issue", + }, mapperMeta({ taskStatusMap })); + + expect(started).toEqual([ + { + type: "subagent_started", + taskId: "task-1", + agentId: "agent-1", + parentToolUseId: null, + description: "Investigate issue", + turnId: "turn-1", + }, + { + type: "activity", + activity: "spawning_agent", + detail: "Investigate issue", + turnId: "turn-1", + }, + ]); + expect(taskStatusMap.get("task-1")).toBe("running"); + + const completed = mapCursorSdkMessageToChatEvents({ + type: "task", + run_id: "task-1", + agent_id: "agent-1", + status: "completed", + text: "Investigation done", + }, mapperMeta({ taskStatusMap })); + + expect(completed).toEqual([ + { + type: "subagent_result", + taskId: "task-1", + agentId: "agent-1", + parentToolUseId: null, + status: "completed", + summary: "Investigation done", + turnId: "turn-1", + }, + { + type: "activity", + activity: "spawning_agent", + detail: "Investigation done", + turnId: "turn-1", + }, + ]); + expect(taskStatusMap.has("task-1")).toBe(false); + }); + + it("emits terminal task results when the first observed task event is already terminal", () => { + const taskStatusMap = new Map(); + const events = mapCursorSdkMessageToChatEvents({ + type: "task", + run_id: "task-1", + agent_id: "agent-1", + status: "failed", + text: "Investigation failed", + }, mapperMeta({ taskStatusMap })); + + expect(events).toEqual([ + { + type: "subagent_result", + taskId: "task-1", + agentId: "agent-1", + parentToolUseId: null, + status: "failed", + summary: "Investigation failed", + turnId: "turn-1", + }, + { + type: "activity", + activity: "spawning_agent", + detail: "Investigation failed", + turnId: "turn-1", + }, + ]); + expect(taskStatusMap.has("task-1")).toBe(false); + }); + + it("does not share task status across mapper meta maps", () => { + const firstMap = new Map(); + const secondMap = new Map(); + + const first = mapCursorSdkMessageToChatEvents({ + type: "task", + run_id: "task-1", + status: "running", + text: "Start task", + }, mapperMeta({ taskStatusMap: firstMap })); + const second = mapCursorSdkMessageToChatEvents({ + type: "task", + run_id: "task-1", + status: "running", + text: "Start task", + }, mapperMeta({ taskStatusMap: secondMap })); + + expect(first[0]).toMatchObject({ type: "subagent_started", taskId: "task-1" }); + expect(second[0]).toMatchObject({ type: "subagent_started", taskId: "task-1" }); + expect(firstMap.get("task-1")).toBe("running"); + expect(secondMap.get("task-1")).toBe("running"); + }); + it("maps TurnEnded usage updates to a tokens event", () => { const ev = mapTurnEndedTokensToEvent( { usage: { inputTokens: 10, outputTokens: 20, cacheReadTokens: 3, cacheCreationTokens: 5 } }, diff --git a/apps/desktop/src/main/services/chat/cursorSdkEventMapper.ts b/apps/desktop/src/main/services/chat/cursorSdkEventMapper.ts index 27a989fa9..12ca16b04 100644 --- a/apps/desktop/src/main/services/chat/cursorSdkEventMapper.ts +++ b/apps/desktop/src/main/services/chat/cursorSdkEventMapper.ts @@ -66,6 +66,7 @@ function extractTextContent(message: unknown): string[] { export type CursorSdkEventMapperMeta = { turnId: string; cwd: string; + taskStatusMap: Map; runtime?: AgentChatRuntime; runId?: string; }; @@ -75,6 +76,22 @@ function tagRuntime(event: T, runtime?: AgentChatRuntime): T { return { ...event, runtime } as T; } +function isCursorTaskTerminalStatus(status: string): boolean { + const lower = status.toLowerCase(); + return lower === "completed" + || lower === "failed" + || lower === "stopped" + || lower === "cancelled" + || lower === "error"; +} + +function cursorTaskResultStatus(status: string): "completed" | "failed" | "stopped" { + const lower = status.toLowerCase(); + if (lower === "completed") return "completed"; + if (lower === "stopped" || lower === "cancelled") return "stopped"; + return "failed"; +} + function normalizeCloudStatus(raw: string | null): AgentChatCloudRunStatus | null { if (!raw) return null; const lower = raw.toLowerCase(); @@ -157,13 +174,57 @@ export function mapCursorSdkMessageToChatEvents( } case "task": { const text = readString(record.text); - if (!text) return []; - return [tagRuntime({ - type: "activity" as const, - activity: "spawning_agent" as const, - detail: text, - turnId, - }, runtime)]; + const runId = readString(record.run_id); + const agentId = readString(record.agent_id); + const status = readString(record.status); + const out: AgentChatEvent[] = []; + + const makeResultEvent = (terminalStatus: string): AgentChatEvent => { + const resultStatus = cursorTaskResultStatus(terminalStatus); + return tagRuntime({ + type: "subagent_result" as const, + taskId: runId!, + ...(agentId ? { agentId } : {}), + parentToolUseId: null, + status: resultStatus, + summary: text ?? `subagent ${resultStatus}`, + turnId, + }, runtime); + }; + + if (runId) { + const prevStatus = meta.taskStatusMap.get(runId) ?? null; + if (prevStatus === null) { + if (status && isCursorTaskTerminalStatus(status)) { + out.push(makeResultEvent(status)); + } else { + meta.taskStatusMap.set(runId, status ?? "started"); + out.push(tagRuntime({ + type: "subagent_started" as const, + taskId: runId, + ...(agentId ? { agentId } : {}), + parentToolUseId: null, + description: text ?? "subagent", + turnId, + }, runtime)); + } + } else if (status && status !== prevStatus) { + meta.taskStatusMap.set(runId, status); + if (isCursorTaskTerminalStatus(status)) { + out.push(makeResultEvent(status)); + meta.taskStatusMap.delete(runId); + } + } + } + if (text) { + out.push(tagRuntime({ + type: "activity" as const, + activity: "spawning_agent" as const, + detail: text, + turnId, + }, runtime)); + } + return out; } case "status": { const statusText = readString(record.status); diff --git a/apps/desktop/src/main/services/cto/issueTracker.ts b/apps/desktop/src/main/services/cto/issueTracker.ts index 8782dfccd..63702c71e 100644 --- a/apps/desktop/src/main/services/cto/issueTracker.ts +++ b/apps/desktop/src/main/services/cto/issueTracker.ts @@ -37,6 +37,30 @@ export type IssueTrackerWorkpadResult = { commentId: string; }; +export type IssueTrackerAttachmentAttribute = { + name: string; + value: string; +}; + +export type IssueTrackerAttachmentMessage = { + subject?: string; + body?: string; + timestamp?: string; +}; + +export type IssueTrackerIssueAttachmentInput = { + issueId: string; + title: string; + url: string; + subtitle?: string | null; + iconUrl?: string | null; + metadata?: Record & { + title?: string; + attributes?: IssueTrackerAttachmentAttribute[]; + messages?: IssueTrackerAttachmentMessage[]; + }; +}; + export type IssueTrackerWorkflowState = { id: string; name: string; @@ -62,6 +86,7 @@ export type IssueTracker = { updateComment(commentId: string, body: string): Promise; addLabel(issueId: string, labelName: string): Promise; uploadAttachment(args: { issueId: string; filePath: string; title?: string }): Promise<{ url: string; id?: string }>; + createIssueAttachment(args: IssueTrackerIssueAttachmentInput): Promise<{ url: string; id?: string }>; getConnectionStatus(): Promise<{ connected: boolean; viewerId: string | null; diff --git a/apps/desktop/src/main/services/cto/linearAuth.test.ts b/apps/desktop/src/main/services/cto/linearAuth.test.ts index 68876af3a..5af9289df 100644 --- a/apps/desktop/src/main/services/cto/linearAuth.test.ts +++ b/apps/desktop/src/main/services/cto/linearAuth.test.ts @@ -29,6 +29,34 @@ function createLogger() { } as any; } +class MemoryCredentialStore { + readonly values = new Map(); + + async get(key: string): Promise { + return this.getSync(key); + } + + async set(key: string, value: string): Promise { + this.setSync(key, value); + } + + async delete(key: string): Promise { + this.deleteSync(key); + } + + getSync(key: string): string | null { + return this.values.get(key) ?? null; + } + + setSync(key: string, value: string): void { + this.values.set(key, value); + } + + deleteSync(key: string): void { + this.values.delete(key); + } +} + // ===================================================================== // linearCredentialService // ===================================================================== @@ -118,6 +146,82 @@ describe("linearCredentialService", () => { expect(fs.readFileSync(sentinelPath, "utf8")).toContain("imported"); }); + it("migrates project-local Linear credentials into the machine credential store once", () => { + const previousAdeLinearApi = process.env.ADE_LINEAR_API; + const previousLinearApiKey = process.env.LINEAR_API_KEY; + const previousAdeLinearToken = process.env.ADE_LINEAR_TOKEN; + const previousLinearToken = process.env.LINEAR_TOKEN; + try { + delete process.env.ADE_LINEAR_API; + delete process.env.LINEAR_API_KEY; + delete process.env.ADE_LINEAR_TOKEN; + delete process.env.LINEAR_TOKEN; + + const root = fs.mkdtempSync(path.join(os.tmpdir(), "ade-linear-machine-migrate-")); + const adeDir = path.join(root, ".ade"); + const secretsDir = path.join(adeDir, "secrets"); + fs.mkdirSync(secretsDir, { recursive: true }); + fs.writeFileSync( + path.join(secretsDir, "linear-token.v1.bin"), + Buffer.from("enc:" + JSON.stringify({ + token: "lin_project_token", + authMode: "oauth", + refreshToken: "refresh-project", + expiresAt: "2026-05-13T12:00:00.000Z", + })), + ); + fs.writeFileSync( + path.join(secretsDir, "linear-oauth-client.v1.bin"), + Buffer.from("enc:" + JSON.stringify({ + clientId: "client-project", + clientSecret: "secret-project", + })), + ); + const credentialStore = new MemoryCredentialStore(); + const service = createLinearCredentialService({ + adeDir, + logger: createLogger(), + credentialStore, + }); + + expect(service.getToken()).toBe("lin_project_token"); + expect(service.getStatus()).toMatchObject({ + authMode: "oauth", + refreshTokenStored: true, + tokenExpiresAt: "2026-05-13T12:00:00.000Z", + }); + expect(service.getOAuthClientCredentials()).toEqual({ + clientId: "client-project", + clientSecret: "secret-project", + }); + expect(credentialStore.values.get("linear.token.v1")).toBe("lin_project_token"); + expect(credentialStore.values.get("linear.refreshToken.v1")).toBe("refresh-project"); + expect(JSON.parse(credentialStore.values.get("linear.oauthClient.v1") ?? "{}")).toEqual({ + clientId: "client-project", + clientSecret: "secret-project", + }); + + service.clearToken(); + const reloaded = createLinearCredentialService({ + adeDir, + logger: createLogger(), + credentialStore, + }); + + expect(reloaded.getToken()).toBeNull(); + expect(credentialStore.values.has("linear.token.v1")).toBe(false); + } finally { + if (previousAdeLinearApi === undefined) delete process.env.ADE_LINEAR_API; + else process.env.ADE_LINEAR_API = previousAdeLinearApi; + if (previousLinearApiKey === undefined) delete process.env.LINEAR_API_KEY; + else process.env.LINEAR_API_KEY = previousLinearApiKey; + if (previousAdeLinearToken === undefined) delete process.env.ADE_LINEAR_TOKEN; + else process.env.ADE_LINEAR_TOKEN = previousAdeLinearToken; + if (previousLinearToken === undefined) delete process.env.LINEAR_TOKEN; + else process.env.LINEAR_TOKEN = previousLinearToken; + } + }); + it("reads Linear OAuth client credentials from .ade/secrets", () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "ade-linear-oauth-")); const adeDir = path.join(root, ".ade"); @@ -676,6 +780,95 @@ describe("linearClient", () => { }); }); + it("creates rich issue attachments", async () => { + const fetchImpl = vi.fn(async (_url: string, init?: RequestInit) => { + const body = JSON.parse(String(init?.body ?? "{}")) as { query?: string; variables?: Record }; + expect(init?.headers).toMatchObject({ authorization: "Bearer test-token" }); + expect(body.query).toContain("mutation CreateIssueAttachment"); + expect(body.variables).toMatchObject({ + issueId: "issue-1", + title: "ADE lane: ABC-42", + url: "https://linear.app/acme/issue/ABC-42#ade-lane-lane-1", + subtitle: "abc-42 - linked {linkedAt__since}", + iconUrl: null, + metadata: { + title: "ADE lane linked", + linkedAt: "2026-05-12T20:05:00.000Z", + attributes: [{ name: "Lane", value: "ABC-42" }], + }, + }); + return new Response( + JSON.stringify({ + data: { + attachmentCreate: { + success: true, + attachment: { + id: "attachment-1", + url: "https://linear.app/acme/issue/ABC-42#ade-lane-lane-1", + }, + }, + }, + }), + { status: 200, headers: { "content-type": "application/json" } } + ); + }); + + const client = createLinearClient({ + credentials: { + getTokenOrThrow: () => "Bearer test-token", + getStatus: () => ({ authMode: "oauth" }), + } as any, + fetchImpl: fetchImpl as any, + logger: null, + }); + + await expect(client.createIssueAttachment({ + issueId: "issue-1", + title: "ADE lane: ABC-42", + url: "https://linear.app/acme/issue/ABC-42#ade-lane-lane-1", + subtitle: "abc-42 - linked {linkedAt__since}", + metadata: { + title: "ADE lane linked", + linkedAt: "2026-05-12T20:05:00.000Z", + attributes: [{ name: "Lane", value: "ABC-42" }], + }, + })).resolves.toEqual({ + id: "attachment-1", + url: "https://linear.app/acme/issue/ABC-42#ade-lane-lane-1", + }); + }); + + it("rejects failed rich issue attachment mutations", async () => { + const fetchImpl = vi.fn(async () => { + return new Response( + JSON.stringify({ + data: { + attachmentCreate: { + success: false, + attachment: null, + }, + }, + }), + { status: 200, headers: { "content-type": "application/json" } }, + ); + }); + + const client = createLinearClient({ + credentials: { + getTokenOrThrow: () => "Bearer test-token", + getStatus: () => ({ authMode: "oauth" }), + } as any, + fetchImpl: fetchImpl as any, + logger: null, + }); + + await expect(client.createIssueAttachment({ + issueId: "issue-1", + title: "ADE lane: ABC-42", + url: "https://linear.app/acme/issue/ABC-42#ade-lane-lane-1", + })).rejects.toThrow("attachmentCreate"); + }); + it("lists projects with their owning team names", async () => { const fetchImpl = vi.fn(async (_url: string, init?: RequestInit) => { const body = JSON.parse(String(init?.body ?? "{}")) as { query?: string }; diff --git a/apps/desktop/src/main/services/cto/linearClient.ts b/apps/desktop/src/main/services/cto/linearClient.ts index fa5901dbb..c3888d11e 100644 --- a/apps/desktop/src/main/services/cto/linearClient.ts +++ b/apps/desktop/src/main/services/cto/linearClient.ts @@ -14,7 +14,7 @@ import type { NormalizedLinearIssue, } from "../../../shared/types"; import type { LinearCredentialService } from "./linearCredentialService"; -import type { IssueTrackerIssueSearchQuery, IssueTrackerIssueSearchResult } from "./issueTracker"; +import type { IssueTrackerIssueAttachmentInput, IssueTrackerIssueSearchQuery, IssueTrackerIssueSearchResult } from "./issueTracker"; import { isRecord, toOptionalString as asString, asArray, sleep, getErrorMessage } from "../shared/utils"; const LINEAR_GRAPHQL_URL = "https://api.linear.app/graphql"; @@ -46,12 +46,6 @@ function priorityIsValid(value: number | null | undefined): value is number { return typeof value === "number" && Number.isInteger(value) && value >= 0 && value <= 4; } -function toIsoString(value: unknown): string | null { - if (value instanceof Date) return value.toISOString(); - if (typeof value === "string" && value.trim().length > 0) return value; - return null; -} - function toNormalizedIssue(node: Record): NormalizedLinearIssue | null { const id = asString(node.id); const identifier = asString(node.identifier); @@ -607,89 +601,6 @@ export function createLinearClient(args: LinearClientArgs) { return data.issue && isRecord(data.issue) ? toNormalizedIssue(data.issue) : null; }; - const normalizeSdkIssue = async (issue: Record): Promise => { - const [project, team, state, assignee, creator, labelsConnection, childrenConnection] = await Promise.all([ - typeof issue.project === "object" ? issue.project : Promise.resolve(null), - typeof issue.team === "object" ? issue.team : Promise.resolve(null), - typeof issue.state === "object" ? issue.state : Promise.resolve(null), - typeof issue.assignee === "object" ? issue.assignee : Promise.resolve(null), - typeof issue.creator === "object" ? issue.creator : Promise.resolve(null), - typeof issue.labels === "function" - ? (issue.labels as (args: { first: number }) => Promise<{ nodes?: unknown[] }>)({ first: 8 }).catch(() => null) - : typeof issue.labels === "object" - ? issue.labels - : Promise.resolve(null), - typeof issue.children === "function" - ? (issue.children as (args: { first: number }) => Promise<{ nodes?: unknown[] }>)({ first: 20 }).catch(() => null) - : typeof issue.children === "object" - ? issue.children - : Promise.resolve(null), - ]); - const childNodes = await Promise.all( - asArray(isRecord(childrenConnection) ? childrenConnection.nodes : []) - .filter(isRecord) - .map(async (child) => { - const childState = typeof child.state === "object" - ? await Promise.resolve(child.state).catch(() => null) - : null; - return { - id: child.id, - state: isRecord(childState) ? { type: childState.type } : null, - }; - }), - ); - - const raw = { - id: issue.id, - identifier: issue.identifier, - title: issue.title, - description: issue.description, - url: issue.url, - priority: issue.priority, - createdAt: toIsoString(issue.createdAt), - updatedAt: toIsoString(issue.updatedAt), - dueDate: issue.dueDate, - estimate: issue.estimate, - archivedAt: toIsoString(issue.archivedAt), - completedAt: toIsoString(issue.completedAt), - canceledAt: toIsoString(issue.canceledAt), - startedAt: toIsoString(issue.startedAt), - project: isRecord(project) ? { - id: project.id, - name: project.name, - slug: project.slugId ?? project.slug, - } : null, - team: isRecord(team) ? { - id: team.id, - key: team.key, - name: team.name, - } : null, - state: isRecord(state) ? { - id: state.id, - name: state.name, - type: state.type, - } : null, - assignee: isRecord(assignee) ? { - id: assignee.id, - name: assignee.name, - displayName: assignee.displayName, - } : null, - creator: isRecord(creator) ? { - id: creator.id, - name: creator.name, - displayName: creator.displayName, - } : null, - labels: { - nodes: asArray(isRecord(labelsConnection) ? labelsConnection.nodes : []) - .filter(isRecord) - .map((label) => ({ id: label.id, name: label.name })), - }, - children: { nodes: childNodes }, - }; - - return toNormalizedIssue(raw); - }; - const getQuickView = async (connection: CtoLinearQuickView["connection"]): Promise => { const sdk = createSdkClient(); // Recent issues fetched via raw GraphQL (single request with ISSUE_FIELDS_FRAGMENT) @@ -1158,6 +1069,61 @@ export function createLinearClient(args: LinearClientArgs) { }; }; + const createIssueAttachment = async (params: IssueTrackerIssueAttachmentInput): Promise<{ url: string; id?: string }> => { + const subtitle = params.subtitle?.trim() ? params.subtitle.trim() : null; + const iconUrl = params.iconUrl?.trim() ? params.iconUrl.trim() : null; + const attachment = await request<{ + attachmentCreate?: { + success?: boolean; + attachment?: { id?: string; url?: string }; + }; + }>({ + query: ` + mutation CreateIssueAttachment( + $issueId: String!, + $title: String!, + $url: String!, + $subtitle: String, + $iconUrl: String, + $metadata: JSONObject + ) { + attachmentCreate(input: { + issueId: $issueId, + title: $title, + url: $url, + subtitle: $subtitle, + iconUrl: $iconUrl, + metadata: $metadata + }) { + success + attachment { id url } + } + } + `, + variables: { + issueId: params.issueId, + title: params.title, + url: params.url, + subtitle, + iconUrl, + metadata: params.metadata ?? null, + }, + maxRetries: 1, + }); + + const created = attachment.attachmentCreate; + const createdUrl = asString(created?.attachment?.url); + const createdId = asString(created?.attachment?.id); + if (created?.success === false || !createdUrl) { + throw new Error("linear: attachmentCreate did not return a created attachment"); + } + + return { + url: createdUrl, + id: createdId ?? undefined, + }; + }; + const listWebhooks = async (): Promise => { const data = await request<{ webhooks?: { @@ -1290,6 +1256,7 @@ export function createLinearClient(args: LinearClientArgs) { updateComment, addLabel, uploadAttachment, + createIssueAttachment, }; } diff --git a/apps/desktop/src/main/services/cto/linearCredentialService.ts b/apps/desktop/src/main/services/cto/linearCredentialService.ts index 3a1be9736..6fa296cd3 100644 --- a/apps/desktop/src/main/services/cto/linearCredentialService.ts +++ b/apps/desktop/src/main/services/cto/linearCredentialService.ts @@ -4,6 +4,7 @@ import YAML from "yaml"; import { safeStorage } from "electron"; import type { Logger } from "../logging/logger"; import { isRecord, getErrorMessage, isEnoentError } from "../shared/utils"; +import type { SyncCredentialStore } from "../../../../../ade-cli/src/services/credentials/credentialStore"; // Bundled OAuth client ID — ships with ADE so users get "Sign in with Linear" // out of the box without configuring their own OAuth app. @@ -14,6 +15,12 @@ const BUNDLED_LINEAR_OAUTH_CLIENT_ID: string | null = const TOKEN_FILE = "linear-token.v1.bin"; const OAUTH_CLIENT_FILE = "linear-oauth-client.v1.bin"; const IMPORT_SENTINEL = "linear-token.imported.v1"; +const MACHINE_TOKEN_KEY = "linear.token.v1"; +const MACHINE_AUTH_MODE_KEY = "linear.authMode.v1"; +const MACHINE_TOKEN_EXPIRES_AT_KEY = "linear.tokenExpiresAt.v1"; +const MACHINE_REFRESH_TOKEN_KEY = "linear.refreshToken.v1"; +const MACHINE_OAUTH_CLIENT_KEY = "linear.oauthClient.v1"; +const MACHINE_LEGACY_PROJECTS_MIGRATED_KEY = "linear.legacy_projects_migrated.v1"; const OAUTH_CONFIG_FILES = [ "linear-oauth.v1.json", "linear-oauth.json", @@ -26,6 +33,7 @@ const ENV_LINEAR_TOKEN_KEYS = ["ADE_LINEAR_API", "LINEAR_API_KEY", "ADE_LINEAR_T type LinearCredentialServiceArgs = { adeDir: string; logger?: Logger | null; + credentialStore?: SyncCredentialStore | null; }; type StoredLinearToken = { @@ -63,6 +71,7 @@ export function createLinearCredentialService(args: LinearCredentialServiceArgs) const tokenPath = path.join(secretsDir, TOKEN_FILE); const oauthClientPath = path.join(secretsDir, OAUTH_CLIENT_FILE); const importSentinelPath = path.join(secretsDir, IMPORT_SENTINEL); + const credentialStore = args.credentialStore ?? null; const readEnvToken = (): string | null => { for (const key of ENV_LINEAR_TOKEN_KEYS) { @@ -144,7 +153,166 @@ export function createLinearCredentialService(args: LinearCredentialServiceArgs) } }; + const readMachineCredential = (key: string): string | null => { + if (!credentialStore) return null; + try { + return credentialStore.getSync(key)?.trim() || null; + } catch (error: unknown) { + args.logger?.warn("linear_sync.machine_credential_read_failed", { + key, + error: getErrorMessage(error), + }); + return null; + } + }; + + const writeMachineCredential = (key: string, value: string | null | undefined): void => { + if (!credentialStore) return; + try { + if (value?.trim()) { + credentialStore.setSync(key, value.trim()); + } else { + credentialStore.deleteSync(key); + } + } catch (error: unknown) { + args.logger?.warn("linear_sync.machine_credential_write_failed", { + key, + error: getErrorMessage(error), + }); + throw error; + } + }; + + const readMachineToken = (): StoredLinearToken | null => { + const token = readMachineCredential(MACHINE_TOKEN_KEY); + if (!token) return null; + const authMode = readMachineCredential(MACHINE_AUTH_MODE_KEY); + return { + token, + authMode: authMode === "oauth" ? "oauth" : "manual", + refreshToken: readMachineCredential(MACHINE_REFRESH_TOKEN_KEY), + expiresAt: readMachineCredential(MACHINE_TOKEN_EXPIRES_AT_KEY), + }; + }; + + const readMachineOAuthClientCredentials = (): LinearOAuthClientCredentials | null => { + const raw = readMachineCredential(MACHINE_OAUTH_CLIENT_KEY); + if (!raw) return null; + try { + return normalizeOAuthClientCredentials(JSON.parse(raw)); + } catch { + return null; + } + }; + + const readMigratedProjectRoots = (): Set => { + const raw = readMachineCredential(MACHINE_LEGACY_PROJECTS_MIGRATED_KEY); + if (!raw) return new Set(); + try { + const parsed = JSON.parse(raw) as unknown; + if (!Array.isArray(parsed)) return new Set(); + return new Set( + parsed + .filter((entry): entry is string => typeof entry === "string" && entry.trim().length > 0) + .map((entry) => path.resolve(entry)), + ); + } catch { + return new Set(); + } + }; + + const markProjectMigrated = (): void => { + if (!credentialStore) return; + const roots = readMigratedProjectRoots(); + roots.add(path.resolve(path.dirname(args.adeDir))); + writeMachineCredential(MACHINE_LEGACY_PROJECTS_MIGRATED_KEY, JSON.stringify(Array.from(roots).sort())); + }; + + const readOAuthConfigFileCredentials = (): LinearOAuthClientCredentials | null => { + for (const filename of OAUTH_CONFIG_FILES) { + const configPath = path.join(secretsDir, filename); + try { + const raw = fs.readFileSync(configPath, "utf8"); + const parsed = filename.endsWith(".json") ? JSON.parse(raw) : YAML.parse(raw); + const credentials = normalizeOAuthClientCredentials(parsed); + if (credentials) return credentials; + } catch (error: unknown) { + if (isEnoentError(error)) { + continue; + } + args.logger?.warn("linear_sync.oauth_config_read_failed", { + filename, + error: getErrorMessage(error), + }); + } + } + return null; + }; + + const persistMachineToken = (record: StoredLinearToken | null): void => { + writeMachineCredential(MACHINE_TOKEN_KEY, record?.token ?? null); + writeMachineCredential(MACHINE_AUTH_MODE_KEY, record?.authMode ?? null); + writeMachineCredential(MACHINE_REFRESH_TOKEN_KEY, record?.refreshToken ?? null); + writeMachineCredential(MACHINE_TOKEN_EXPIRES_AT_KEY, record?.expiresAt ?? null); + }; + + const persistMachineOAuthClientCredentials = (record: LinearOAuthClientCredentials | null): void => { + if (!record?.clientId?.trim()) { + writeMachineCredential(MACHINE_OAUTH_CLIENT_KEY, null); + return; + } + writeMachineCredential(MACHINE_OAUTH_CLIENT_KEY, JSON.stringify({ + clientId: record.clientId.trim(), + clientSecret: record.clientSecret?.trim() || null, + })); + }; + + const migrateLegacyProjectCredentialsIfNeeded = (): void => { + if (!credentialStore) return; + const projectRoot = path.resolve(path.dirname(args.adeDir)); + const migratedRoots = readMigratedProjectRoots(); + if (migratedRoots.has(projectRoot)) return; + const hadEncryptedLegacyStore = fs.existsSync(tokenPath) || fs.existsSync(oauthClientPath); + + if (!readMachineToken()) { + const legacyToken = readEncryptedToken(); + if (legacyToken) { + persistMachineToken(legacyToken); + } else { + const legacyPath = path.join(args.adeDir, "local.secret.yaml"); + try { + const raw = fs.readFileSync(legacyPath, "utf8"); + const token = extractLegacyToken(raw); + if (token) { + persistMachineToken({ token, authMode: "manual" }); + } + } catch (error: unknown) { + if (!isEnoentError(error)) { + args.logger?.warn("linear_sync.token_import_failed", { + legacyPath, + error: getErrorMessage(error), + }); + } + } + } + } + + if (!readMachineOAuthClientCredentials()) { + const legacyOAuth = readStoredOAuthClientCredentials() ?? readOAuthConfigFileCredentials(); + if (legacyOAuth) persistMachineOAuthClientCredentials(legacyOAuth); + } + + if (!hadEncryptedLegacyStore || safeStorage.isEncryptionAvailable()) { + markProjectMigrated(); + } + }; + const persistToken = (record: StoredLinearToken | null): void => { + if (credentialStore) { + persistMachineToken(record); + return; + } + const token = record?.token?.trim() ?? ""; if (!token.length) { try { @@ -175,6 +343,11 @@ export function createLinearCredentialService(args: LinearCredentialServiceArgs) }; const persistOAuthClientCredentials = (record: LinearOAuthClientCredentials | null): void => { + if (credentialStore) { + persistMachineOAuthClientCredentials(record); + return; + } + const clientId = record?.clientId?.trim() ?? ""; if (!clientId.length) { try { @@ -247,6 +420,17 @@ export function createLinearCredentialService(args: LinearCredentialServiceArgs) const getStoredToken = (): StoredLinearToken | null => { if (cachedToken !== undefined) return cachedToken; + if (credentialStore) { + migrateLegacyProjectCredentialsIfNeeded(); + cachedToken = readMachineToken(); + if (!cachedToken) { + const envToken = readEnvToken(); + if (envToken) { + cachedToken = { token: envToken, authMode: "manual" }; + } + } + return cachedToken; + } importLegacyTokenIfNeeded(); cachedToken = readEncryptedToken(); if (!cachedToken) { @@ -265,6 +449,25 @@ export function createLinearCredentialService(args: LinearCredentialServiceArgs) const readOAuthClientCredentials = (): LinearOAuthClientCredentials | null => { if (cachedOAuthCreds !== undefined) return cachedOAuthCreds; + if (credentialStore) { + migrateLegacyProjectCredentialsIfNeeded(); + const stored = readMachineOAuthClientCredentials(); + if (stored) { + cachedOAuthCreds = stored; + return cachedOAuthCreds; + } + const configFileCredentials = readOAuthConfigFileCredentials(); + if (configFileCredentials) { + cachedOAuthCreds = configFileCredentials; + return cachedOAuthCreds; + } + if (BUNDLED_LINEAR_OAUTH_CLIENT_ID) { + cachedOAuthCreds = { clientId: BUNDLED_LINEAR_OAUTH_CLIENT_ID, clientSecret: null }; + return cachedOAuthCreds; + } + cachedOAuthCreds = null; + return null; + } // Priority 1: User-configured credentials (encrypted store) const stored = readStoredOAuthClientCredentials(); if (stored) { @@ -272,25 +475,10 @@ export function createLinearCredentialService(args: LinearCredentialServiceArgs) return cachedOAuthCreds; } // Priority 2: Config files in secrets dir - for (const filename of OAUTH_CONFIG_FILES) { - const configPath = path.join(secretsDir, filename); - try { - const raw = fs.readFileSync(configPath, "utf8"); - const parsed = filename.endsWith(".json") ? JSON.parse(raw) : YAML.parse(raw); - const credentials = normalizeOAuthClientCredentials(parsed); - if (credentials) { - cachedOAuthCreds = credentials; - return cachedOAuthCreds; - } - } catch (error: unknown) { - if (isEnoentError(error)) { - continue; - } - args.logger?.warn("linear_sync.oauth_config_read_failed", { - filename, - error: getErrorMessage(error), - }); - } + const configFileCredentials = readOAuthConfigFileCredentials(); + if (configFileCredentials) { + cachedOAuthCreds = configFileCredentials; + return cachedOAuthCreds; } // Priority 3: Bundled client ID (ships with ADE, no secret — uses PKCE) if (BUNDLED_LINEAR_OAUTH_CLIENT_ID) { diff --git a/apps/desktop/src/main/services/cto/linearDispatcherService.ts b/apps/desktop/src/main/services/cto/linearDispatcherService.ts index 697ce71b5..9748f8b68 100644 --- a/apps/desktop/src/main/services/cto/linearDispatcherService.ts +++ b/apps/desktop/src/main/services/cto/linearDispatcherService.ts @@ -18,6 +18,7 @@ import type { LinearWorkflowRunStep, LinearWorkflowStep, LinearWorkflowTargetStatus, + LaneLinearIssue, NormalizedLinearIssue, } from "../../../shared/types"; import type { AdeDb } from "../state/kvDb"; @@ -72,6 +73,36 @@ type RunRow = { updated_at: string; }; +function toLaneLinearIssue(issue: NormalizedLinearIssue): LaneLinearIssue { + return { + id: issue.id, + identifier: issue.identifier, + title: issue.title, + description: issue.description, + url: issue.url, + projectId: issue.projectId, + projectSlug: issue.projectSlug, + projectName: issue.projectName ?? null, + teamId: issue.teamId, + teamKey: issue.teamKey, + teamName: issue.teamName ?? null, + stateId: issue.stateId, + stateName: issue.stateName, + stateType: issue.stateType, + priority: issue.priority, + priorityLabel: issue.priorityLabel, + labels: issue.labels, + assigneeId: issue.assigneeId, + assigneeName: issue.assigneeName, + creatorId: issue.creatorId ?? issue.ownerId ?? null, + creatorName: issue.creatorName ?? null, + dueDate: issue.dueDate ?? null, + estimate: issue.estimate ?? null, + createdAt: issue.createdAt, + updatedAt: issue.updatedAt, + }; +} + type StepRow = { id: string; run_id: string; @@ -981,6 +1012,7 @@ export function createLinearDispatcherService(args: { name: (target.freshLaneName?.trim() || `${issue.identifier} ${issue.title}`).slice(0, 72), description: `Linear workflow ${workflow.name} for ${issue.identifier}`, parentLaneId: preferredPrimary.id, + linearIssue: toLaneLinearIssue(issue), }); appendEvent(run.id, "run.lane_created", "completed", `Created dedicated lane '${lane.name}'.`, { laneId: lane.id, diff --git a/apps/desktop/src/main/services/cto/linearIssueTracker.ts b/apps/desktop/src/main/services/cto/linearIssueTracker.ts index ff705c2a3..c0b5d2910 100644 --- a/apps/desktop/src/main/services/cto/linearIssueTracker.ts +++ b/apps/desktop/src/main/services/cto/linearIssueTracker.ts @@ -68,6 +68,10 @@ export function createLinearIssueTracker(args: { client: LinearClient }): IssueT return args.client.uploadAttachment(params); }, + createIssueAttachment(params) { + return args.client.createIssueAttachment(params); + }, + async getConnectionStatus() { try { const identity = await args.client.getConnectionIdentity(); diff --git a/apps/desktop/src/main/services/cto/linearLaneCardService.test.ts b/apps/desktop/src/main/services/cto/linearLaneCardService.test.ts new file mode 100644 index 000000000..7968fb4c6 --- /dev/null +++ b/apps/desktop/src/main/services/cto/linearLaneCardService.test.ts @@ -0,0 +1,122 @@ +import { describe, expect, it, vi } from "vitest"; +import type { LaneLinearIssue, LaneSummary } from "../../../shared/types"; +import { buildLinearLaneCardAttachment, publishLinearLaneCard } from "./linearLaneCardService"; + +function makeLane(overrides: Partial = {}): LaneSummary { + return { + id: "lane-1", + name: "ABC-42 Fix flaky sync run", + description: null, + laneType: "worktree", + baseRef: "main", + branchRef: "abc-42-fix-flaky-sync-run", + worktreePath: "/tmp/worktrees/abc-42-fix-flaky-sync-run", + 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-12T20:00:00.000Z", + archivedAt: null, + linearIssue: null, + ...overrides, + }; +} + +function makeIssue(overrides: Partial = {}): LaneLinearIssue { + return { + id: "issue-1", + identifier: "ABC-42", + title: "Fix flaky sync run", + description: "Occasional sync failure under load.", + url: "https://linear.app/acme/issue/ABC-42/fix-flaky-sync-run", + projectId: "project-1", + projectSlug: "acme-platform", + projectName: "Acme Platform", + teamId: "team-1", + teamKey: "ABC", + teamName: "Platform", + stateId: "state-1", + stateName: "In Progress", + stateType: "started", + priority: 2, + priorityLabel: "high", + labels: ["bug", "sync"], + assigneeId: "user-1", + assigneeName: "Taylor", + creatorId: "creator-1", + creatorName: "Alex", + dueDate: null, + estimate: null, + branchName: "abc-42-fix-flaky-sync-run", + createdAt: "2026-05-11T20:00:00.000Z", + updatedAt: "2026-05-12T19:00:00.000Z", + ...overrides, + }; +} + +describe("linearLaneCardService", () => { + it("builds a stable Linear issue attachment for an ADE lane", () => { + const attachment = buildLinearLaneCardAttachment({ + lane: makeLane(), + issue: makeIssue(), + projectRoot: "/Users/admin/Projects/ADE", + linkedAt: "2026-05-12T20:05:00.000Z", + }); + + expect(attachment).toMatchObject({ + issueId: "issue-1", + title: "ADE lane: ABC-42 Fix flaky sync run", + subtitle: "abc-42-fix-flaky-sync-run - linked {linkedAt__since}", + url: "https://linear.app/acme/issue/ABC-42/fix-flaky-sync-run#ade-lane-lane-1", + }); + expect(attachment.metadata).toMatchObject({ + title: "ADE lane linked to ABC-42", + laneId: "lane-1", + laneName: "ABC-42 Fix flaky sync run", + branch: "abc-42-fix-flaky-sync-run", + baseRef: "main", + projectName: "ADE", + linkedAt: "2026-05-12T20:05:00.000Z", + }); + expect(attachment.metadata?.attributes).toContainEqual({ name: "Linear team", value: "Platform" }); + expect(attachment.metadata?.messages?.[0]?.body).toContain("ADE uses this lane link"); + }); + + it("keeps truncated title and subtitle within Linear attachment limits", () => { + const attachment = buildLinearLaneCardAttachment({ + lane: makeLane({ name: "A".repeat(120) }), + issue: makeIssue({ branchName: "b".repeat(120) }), + projectRoot: "/Users/admin/Projects/ADE", + }); + + expect(attachment.title.length).toBeLessThanOrEqual("ADE lane: ".length + 64); + expect((attachment.subtitle ?? "").split(" - linked ")[0]?.length).toBeLessThanOrEqual(56); + }); + + it("publishes the card through the issue tracker", async () => { + const createIssueAttachment = vi.fn(async () => ({ id: "attachment-1", url: "https://linear.app/acme/issue/ABC-42#ade-lane-lane-1" })); + const result = await publishLinearLaneCard({ + issueTracker: { createIssueAttachment } as any, + lane: makeLane(), + issue: makeIssue(), + projectRoot: "/Users/admin/Projects/ADE", + }); + + expect(result.id).toBe("attachment-1"); + expect(createIssueAttachment).toHaveBeenCalledWith(expect.objectContaining({ + issueId: "issue-1", + title: "ADE lane: ABC-42 Fix flaky sync run", + })); + }); +}); diff --git a/apps/desktop/src/main/services/cto/linearLaneCardService.ts b/apps/desktop/src/main/services/cto/linearLaneCardService.ts new file mode 100644 index 000000000..368185271 --- /dev/null +++ b/apps/desktop/src/main/services/cto/linearLaneCardService.ts @@ -0,0 +1,95 @@ +import path from "node:path"; +import type { LaneLinearIssue, LaneSummary } from "../../../shared/types"; +import type { IssueTracker, IssueTrackerIssueAttachmentInput } from "./issueTracker"; + +function truncate(value: string, maxLength: number): string { + const trimmed = value.trim(); + if (trimmed.length <= maxLength) return trimmed; + return `${trimmed.slice(0, Math.max(0, maxLength - 3)).trimEnd()}...`; +} + +function dateLabel(value: string): string { + const parsed = new Date(value); + if (Number.isNaN(parsed.getTime())) return value; + return parsed.toISOString(); +} + +function buildCardUrl(issue: LaneLinearIssue, laneId: string): string { + const fallback = `https://linear.app/issue/${encodeURIComponent(issue.identifier)}`; + let url: URL; + try { + url = new URL(issue.url?.trim() || fallback); + } catch { + url = new URL(fallback); + } + url.hash = `ade-lane-${laneId}`; + return url.toString(); +} + +export function buildLinearLaneCardAttachment(args: { + lane: LaneSummary; + issue: LaneLinearIssue; + projectRoot: string; + linkedAt?: string | null; +}): IssueTrackerIssueAttachmentInput { + const linkedAt = args.linkedAt?.trim() || args.lane.createdAt || new Date().toISOString(); + const branch = args.issue.branchName?.trim() || args.lane.branchRef; + const projectName = path.basename(args.projectRoot) || "project"; + const teamName = args.issue.teamName?.trim() || args.issue.teamKey; + const projectLabel = args.issue.projectName?.trim() || args.issue.projectSlug; + const labels = args.issue.labels.length ? args.issue.labels.join(", ") : "None"; + + return { + issueId: args.issue.id, + title: `ADE lane: ${truncate(args.lane.name, 64)}`, + subtitle: `${truncate(branch, 56)} - linked {linkedAt__since}`, + url: buildCardUrl(args.issue, args.lane.id), + metadata: { + title: `ADE lane linked to ${args.issue.identifier}`, + laneId: args.lane.id, + laneName: args.lane.name, + branch, + baseRef: args.lane.baseRef, + projectName, + linkedAt, + issueIdentifier: args.issue.identifier, + attributes: [ + { name: "Lane", value: args.lane.name }, + { name: "Branch", value: branch }, + { name: "Base", value: args.lane.baseRef }, + { name: "ADE project", value: projectName }, + { name: "Linear team", value: teamName }, + { name: "Linear project", value: projectLabel }, + { name: "Issue state at link", value: args.issue.stateName }, + { name: "Assignee at link", value: args.issue.assigneeName?.trim() || "Unassigned" }, + { name: "Labels at link", value: labels }, + { name: "Linked at", value: dateLabel(linkedAt) }, + { name: "Lane ID", value: args.lane.id }, + ], + messages: [ + { + subject: "What ADE linked", + body: `This Linear issue is linked to the ADE lane "${args.lane.name}" on branch "${branch}". ADE uses this lane link for branch naming, commit references, PR text, and chat context.`, + timestamp: linkedAt, + }, + ], + }, + }; +} + +export async function publishLinearLaneCard(args: { + issueTracker: IssueTracker; + lane: LaneSummary; + issue: LaneLinearIssue; + projectRoot: string; + linkedAt?: string | null; +}): Promise<{ url: string; id?: string }> { + return args.issueTracker.createIssueAttachment( + buildLinearLaneCardAttachment({ + lane: args.lane, + issue: args.issue, + projectRoot: args.projectRoot, + linkedAt: args.linkedAt, + }), + ); +} diff --git a/apps/desktop/src/main/services/cto/linearSync.test.ts b/apps/desktop/src/main/services/cto/linearSync.test.ts index d53356a86..dfcd7bd00 100644 --- a/apps/desktop/src/main/services/cto/linearSync.test.ts +++ b/apps/desktop/src/main/services/cto/linearSync.test.ts @@ -1830,6 +1830,14 @@ describe("linearDispatcherService (file group)", () => { await dispatcher.advanceRun(run.id, policy); expect(createLane).toHaveBeenCalledTimes(1); + expect(createLane).toHaveBeenCalledWith(expect.objectContaining({ + linearIssue: expect.objectContaining({ + id: issueFixture.id, + identifier: issueFixture.identifier, + title: issueFixture.title, + url: issueFixture.url, + }), + })); expect(ensureIdentitySession).toHaveBeenCalledWith(expect.objectContaining({ identityKey: "cto", laneId: "lane-2", diff --git a/apps/desktop/src/main/services/diffs/diffService.test.ts b/apps/desktop/src/main/services/diffs/diffService.test.ts index ae06b670b..d945283d4 100644 --- a/apps/desktop/src/main/services/diffs/diffService.test.ts +++ b/apps/desktop/src/main/services/diffs/diffService.test.ts @@ -2,7 +2,7 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { execFileSync } from "node:child_process"; -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { createDiffService } from "./diffService"; function git(cwd: string, args: string[]): string { @@ -18,6 +18,100 @@ function createLaneServiceStub(rootPath: string) { } describe("diffService", () => { + it("returns lane line stats against the lane base ref", async () => { + const rootPath = fs.mkdtempSync(path.join(os.tmpdir(), "ade-diff-service-line-stats-")); + const service = createDiffService({ + laneService: { + getLaneBaseAndBranch: () => ({ + baseRef: "main", + worktreePath: rootPath, + }), + list: vi.fn(), + } as any, + }); + + try { + git(rootPath, ["init"]); + git(rootPath, ["config", "user.email", "ade@example.com"]); + git(rootPath, ["config", "user.name", "ADE"]); + git(rootPath, ["branch", "-M", "main"]); + fs.writeFileSync(path.join(rootPath, "alpha.txt"), "one\ntwo\n", "utf8"); + fs.writeFileSync(path.join(rootPath, "beta.txt"), "same\n", "utf8"); + git(rootPath, ["add", "."]); + git(rootPath, ["commit", "-m", "base"]); + + git(rootPath, ["checkout", "-b", "feature"]); + fs.writeFileSync(path.join(rootPath, "alpha.txt"), "one\ntwo\nthree\n", "utf8"); + fs.writeFileSync(path.join(rootPath, "beta.txt"), "changed\n", "utf8"); + git(rootPath, ["add", "."]); + git(rootPath, ["commit", "-m", "feature"]); + + await expect(service.getLaneDiffStats("lane-1")).resolves.toEqual({ + additions: 2, + deletions: 1, + files: 2, + }); + } finally { + fs.rmSync(rootPath, { recursive: true, force: true }); + } + }); + + it("rejects missing lane id for line stats with a clear error", async () => { + const rootPath = fs.mkdtempSync(path.join(os.tmpdir(), "ade-diff-service-line-stats-missing-id-")); + const service = createDiffService({ + laneService: { + getLaneBaseAndBranch: vi.fn(), + list: vi.fn(), + } as any, + }); + + try { + await expect(service.getLaneDiffStats(undefined)).rejects.toThrow("laneId is required"); + await expect(service.getLaneDiffStats({ laneId: " " })).rejects.toThrow("laneId is required"); + } finally { + fs.rmSync(rootPath, { recursive: true, force: true }); + } + }); + + it("lists line stats only from non-archived lanes returned by the lane service", async () => { + const rootPath = fs.mkdtempSync(path.join(os.tmpdir(), "ade-diff-service-line-stats-list-")); + const list = vi.fn(async () => [ + { id: "lane-1" }, + ]); + const service = createDiffService({ + laneService: { + getLaneBaseAndBranch: () => ({ + baseRef: "main", + worktreePath: rootPath, + }), + list, + } as any, + }); + + try { + git(rootPath, ["init"]); + git(rootPath, ["config", "user.email", "ade@example.com"]); + git(rootPath, ["config", "user.name", "ADE"]); + git(rootPath, ["branch", "-M", "main"]); + fs.writeFileSync(path.join(rootPath, "alpha.txt"), "one\n", "utf8"); + git(rootPath, ["add", "."]); + git(rootPath, ["commit", "-m", "base"]); + git(rootPath, ["checkout", "-b", "feature"]); + fs.writeFileSync(path.join(rootPath, "alpha.txt"), "one\ntwo\n", "utf8"); + git(rootPath, ["add", "."]); + git(rootPath, ["commit", "-m", "feature"]); + + const stats = await service.listLaneDiffStats(); + + expect(list).toHaveBeenCalledWith({ includeArchived: false }); + expect(stats).toEqual({ + "lane-1": { additions: 1, deletions: 0, files: 1 }, + }); + } finally { + fs.rmSync(rootPath, { recursive: true, force: true }); + } + }); + it("returns change stats and rename metadata for staged and unstaged files", async () => { const rootPath = fs.mkdtempSync(path.join(os.tmpdir(), "ade-diff-service-status-")); const service = createDiffService({ laneService: createLaneServiceStub(rootPath) }); diff --git a/apps/desktop/src/main/services/diffs/diffService.ts b/apps/desktop/src/main/services/diffs/diffService.ts index 3829ceb49..96fe263bc 100644 --- a/apps/desktop/src/main/services/diffs/diffService.ts +++ b/apps/desktop/src/main/services/diffs/diffService.ts @@ -3,7 +3,7 @@ import path from "node:path"; import type { createLaneService } from "../lanes/laneService"; import { runGit } from "../git/git"; import { resolvePathWithinRoot } from "../shared/utils"; -import type { DiffChanges, DiffMode, FileDiff, FileChange, FilePatch } from "../../../shared/types"; +import type { DiffChanges, DiffLineStats, DiffMode, FileDiff, FileChange, FilePatch } from "../../../shared/types"; export const MAX_DIFF_SIDE_TEXT_BYTES = 192 * 1024; export const MAX_DIFF_PATCH_BYTES = 512 * 1024; @@ -117,6 +117,44 @@ function applyNumstat(changes: FileChange[], stdout: string): void { } } +function emptyDiffLineStats(): DiffLineStats { + return { additions: 0, deletions: 0, files: 0 }; +} + +function parseShortstat(stdout: string): DiffLineStats { + const text = stdout.trim(); + if (!text) return emptyDiffLineStats(); + + const parseCount = (pattern: RegExp): number => + Number.parseInt(text.match(pattern)?.[1] ?? "0", 10); + + return { + additions: parseCount(/(\d+)\s+insertions?\(\+\)/i), + deletions: parseCount(/(\d+)\s+deletions?\(-\)/i), + files: parseCount(/(\d+)\s+files?\s+changed/i), + }; +} + +function readLaneCompareRef(baseRef: string): string | null { + const ref = baseRef.trim(); + if (!ref || ref.startsWith("-")) return null; + return `${ref}...HEAD`; +} + +function readLaneIdArg(value: string | { laneId?: string } | null | undefined): string { + let laneId = ""; + if (typeof value === "string") { + laneId = value; + } else if (typeof value?.laneId === "string") { + laneId = value.laneId; + } + const trimmed = laneId.trim(); + if (!trimmed) { + throw new Error("laneId is required"); + } + return trimmed; +} + function detectBinary(buf: Buffer): boolean { // Simple heuristic: null byte indicates binary. return buf.includes(0); @@ -252,7 +290,40 @@ function resolveGitFilePath(worktreePath: string, filePath: string): { absPath: } export function createDiffService({ laneService }: { laneService: ReturnType }) { + const getLaneDiffStats = async (laneIdArg: string | { laneId?: string } | null | undefined): Promise => { + const laneId = readLaneIdArg(laneIdArg); + const { baseRef, worktreePath } = laneService.getLaneBaseAndBranch(laneId); + const compareRef = readLaneCompareRef(baseRef); + if (!compareRef) return emptyDiffLineStats(); + + const res = await runGit(["diff", "--shortstat", "--find-renames", compareRef], { + cwd: worktreePath, + env: { LANG: "C", LC_ALL: "C" }, + timeoutMs: 12_000, + maxOutputBytes: 64 * 1024, + }); + if (res.exitCode !== 0) return emptyDiffLineStats(); + return parseShortstat(res.stdout); + }; + return { + getLaneDiffStats, + + async listLaneDiffStats(args: { laneIds?: string[] } = {}): Promise> { + const requested = new Set((args.laneIds ?? []).map((id) => id.trim()).filter(Boolean)); + const lanes = await laneService.list({ includeArchived: false }); + const eligible = lanes.filter((lane) => requested.size === 0 || requested.has(lane.id)); + const entries = await Promise.all(eligible.map(async (lane) => { + try { + const stats = await getLaneDiffStats(lane.id); + return [lane.id, stats] as const; + } catch { + return [lane.id, emptyDiffLineStats()] as const; + } + })); + return Object.fromEntries(entries); + }, + async getChanges(laneId: string): Promise { const { worktreePath } = laneService.getLaneBaseAndBranch(laneId); const res = await runGit(["status", "--porcelain=v1", "-z"], { cwd: worktreePath, timeoutMs: 12_000 }); diff --git a/apps/desktop/src/main/services/ipc/registerIpc.ts b/apps/desktop/src/main/services/ipc/registerIpc.ts index baff46c85..4201bf417 100644 --- a/apps/desktop/src/main/services/ipc/registerIpc.ts +++ b/apps/desktop/src/main/services/ipc/registerIpc.ts @@ -717,6 +717,7 @@ import { detectCodexResumeStrategy, spawnInNewTerminalWindow, } from "../chat/codexCliLauncher"; +import { sanitizeResumeTargetId } from "../../utils/terminalSessionSignals"; export type AppContext = { db: AdeDb; @@ -1587,7 +1588,7 @@ function sessionNeedsResumeTargetHydration(session: { resumeMetadata?: { targetId?: string | null } | null; }): boolean { if (!session.tracked || session.status === "running") return false; - if (session.resumeMetadata?.targetId?.trim()) return false; + if (sanitizeResumeTargetId(session.resumeMetadata?.targetId ?? null)) return false; return ( session.toolType === "claude" || session.toolType === "codex" diff --git a/apps/desktop/src/main/services/lanes/laneListSnapshotService.test.ts b/apps/desktop/src/main/services/lanes/laneListSnapshotService.test.ts new file mode 100644 index 000000000..0dfb72fef --- /dev/null +++ b/apps/desktop/src/main/services/lanes/laneListSnapshotService.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, it, vi } from "vitest"; +import { buildLaneListSnapshots } from "./laneListSnapshotService"; + +function makeHarness(session: Record) { + return { + laneService: { + listStateSnapshots: vi.fn(() => []), + }, + sessionService: { + list: vi.fn(() => [session]), + }, + ptyService: { + enrichSessions: vi.fn((rows) => rows), + }, + logger: { + info: vi.fn(), + }, + }; +} + +describe("laneListSnapshotService", () => { + it("buckets idle AI CLI sessions as awaiting input", async () => { + const services = makeHarness({ + laneId: "lane-1", + status: "running", + runtimeState: "idle", + toolType: "codex", + lastOutputPreview: "Turn completed", + }); + + const [snapshot] = await buildLaneListSnapshots( + services as any, + [{ id: "lane-1", name: "Lane 1", laneType: "worktree", archivedAt: null }] as any, + { + includeConflictStatus: false, + includeRebaseSuggestions: false, + includeAutoRebaseStatus: false, + }, + ); + + expect(snapshot?.runtime).toMatchObject({ + bucket: "awaiting-input", + runningCount: 0, + awaitingInputCount: 1, + endedCount: 0, + sessionCount: 1, + }); + }); + + it("keeps idle shell sessions in the running bucket", async () => { + const services = makeHarness({ + laneId: "lane-1", + status: "running", + runtimeState: "idle", + toolType: "shell", + lastOutputPreview: "admin@Mac project %", + }); + + const [snapshot] = await buildLaneListSnapshots( + services as any, + [{ id: "lane-1", name: "Lane 1", laneType: "worktree", archivedAt: null }] as any, + { + includeConflictStatus: false, + includeRebaseSuggestions: false, + includeAutoRebaseStatus: false, + }, + ); + + expect(snapshot?.runtime).toMatchObject({ + bucket: "running", + runningCount: 1, + awaitingInputCount: 0, + endedCount: 0, + sessionCount: 1, + }); + }); +}); diff --git a/apps/desktop/src/main/services/lanes/laneListSnapshotService.ts b/apps/desktop/src/main/services/lanes/laneListSnapshotService.ts index 5b3d2dd0b..f42193fbd 100644 --- a/apps/desktop/src/main/services/lanes/laneListSnapshotService.ts +++ b/apps/desktop/src/main/services/lanes/laneListSnapshotService.ts @@ -64,13 +64,34 @@ function isChatToolType(toolType: string | null | undefined): boolean { return t === "cursor" || t.endsWith("-chat"); } +const IDLE_ATTENTION_TOOL_TYPES = new Set([ + "claude", + "codex", + "cursor-cli", + "droid", + "opencode", + "claude-orchestrated", + "codex-orchestrated", + "opencode-orchestrated", + "aider", + "continue", +]); + +function idleRuntimeNeedsAttention(toolType: string | null | undefined): boolean { + if (isChatToolType(toolType)) return true; + if (!toolType) return false; + return IDLE_ATTENTION_TOOL_TYPES.has(toolType.trim().toLowerCase()); +} + function sessionStatusBucket(args: { status: string; lastOutputPreview: string | null | undefined; runtimeState?: string | null; + toolType?: string | null; }): "running" | "awaiting-input" | "ended" { if (args.status === "running") { if (args.runtimeState === "waiting-input") return "awaiting-input"; + if (args.runtimeState === "idle" && idleRuntimeNeedsAttention(args.toolType)) return "awaiting-input"; const preview = args.lastOutputPreview ?? ""; if (/\b(?:waiting|awaiting)\b.{0,28}\b(?:input|confirmation|response|prompt)\b/i.test(preview)) { return "awaiting-input"; @@ -90,6 +111,7 @@ function summarizeLaneRuntime( status: string; lastOutputPreview: string | null; runtimeState?: string | null; + toolType?: string | null; }>, ): LaneRuntimeSummary { let runningCount = 0; diff --git a/apps/desktop/src/main/services/lanes/laneService.test.ts b/apps/desktop/src/main/services/lanes/laneService.test.ts index eb18f7eed..35a87e9ef 100644 --- a/apps/desktop/src/main/services/lanes/laneService.test.ts +++ b/apps/desktop/src/main/services/lanes/laneService.test.ts @@ -36,6 +36,36 @@ function defaultLaneBranchGitStub(args: string[]): { exitCode: number; stdout: s return null; } +function makeLinearIssue() { + return { + id: "issue-1", + identifier: "ABC-42", + title: "Fix flaky sync run", + description: "Occasional sync failure under load.", + url: "https://linear.app/acme/issue/ABC-42/fix-flaky-sync-run", + projectId: "project-1", + projectSlug: "acme-platform", + projectName: "Acme Platform", + teamId: "team-1", + teamKey: "ABC", + teamName: "Platform", + stateId: "state-1", + stateName: "In Progress", + stateType: "started", + priority: 2, + priorityLabel: "high" as const, + labels: ["bug"], + assigneeId: "user-1", + assigneeName: "Taylor", + creatorId: "creator-1", + creatorName: "Alex", + dueDate: null, + estimate: null, + createdAt: "2026-05-11T20:00:00.000Z", + updatedAt: "2026-05-12T19:00:00.000Z", + }; +} + async function seedProjectAndStack(db: any, args: { projectId: string; repoRoot: string }) { const now = "2026-03-11T12:00:00.000Z"; db.run( @@ -121,6 +151,53 @@ describe("laneService createFromUnstaged", () => { expect(lanes.filter((lane) => lane.laneType === "primary")).toHaveLength(2); }); + it("notifies when a new lane is linked to a Linear issue", async () => { + const repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-lane-service-linear-card-")); + const db = await openKvDb(path.join(repoRoot, "kv.sqlite"), createLogger()); + const now = "2026-05-12T20:00:00.000Z"; + db.run( + "insert into projects(id, root_path, display_name, default_base_ref, created_at, last_opened_at) values (?, ?, ?, ?, ?, ?)", + ["proj-linear-card", repoRoot, "demo", "main", now, now], + ); + + vi.mocked(runGit).mockImplementation(async (args: string[]) => { + const laneBranchGitStub = defaultLaneBranchGitStub(args); + if (laneBranchGitStub) return laneBranchGitStub; + if (args[0] === "rev-parse" && args[1] === "main") return { exitCode: 0, stdout: "sha-main\n", stderr: "" }; + if (args[0] === "show-ref" && args[1] === "--verify" && args[2] === "--quiet") return { exitCode: 1, stdout: "", stderr: "" }; + if (args[0] === "ls-remote") return { exitCode: 0, stdout: "", stderr: "" }; + if (args[0] === "push") return { exitCode: 0, stdout: "", stderr: "" }; + if (args[0] === "status") return { exitCode: 0, stdout: "", stderr: "" }; + if (args[0] === "rev-list" && args[1] === "--left-right") return { exitCode: 0, stdout: "0\t0\n", stderr: "" }; + if (args[0] === "rev-parse" && args.includes("@{upstream}")) return { exitCode: 1, stdout: "", stderr: "" }; + if (args[0] === "rev-parse" && args.includes("--git-dir")) return { exitCode: 1, stdout: "", stderr: "" }; + throw new Error(`Unexpected git call: ${args.join(" ")}`); + }); + vi.mocked(runGitOrThrow).mockResolvedValue(""); + + const onLinearIssueLinked = vi.fn(); + const service = createLaneService({ + db, + projectRoot: repoRoot, + projectId: "proj-linear-card", + defaultBaseRef: "main", + worktreesDir: path.join(repoRoot, "worktrees"), + onLinearIssueLinked, + }); + + const lane = await service.create({ + name: "ABC-42 Fix flaky sync run", + linearIssue: makeLinearIssue(), + }); + + expect(lane.linearIssue?.identifier).toBe("ABC-42"); + expect(onLinearIssueLinked).toHaveBeenCalledWith(expect.objectContaining({ + lane: expect.objectContaining({ id: lane.id, name: "ABC-42 Fix flaky sync run" }), + issue: expect.objectContaining({ id: "issue-1", identifier: "ABC-42" }), + linkedAt: lane.createdAt, + })); + }); + it("moves unstaged and untracked changes into a new child lane", async () => { const repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-lane-service-rescue-success-")); const db = await openKvDb(path.join(repoRoot, "kv.sqlite"), createLogger()); diff --git a/apps/desktop/src/main/services/lanes/laneService.ts b/apps/desktop/src/main/services/lanes/laneService.ts index 5aaf2ccd7..0bc7cb222 100644 --- a/apps/desktop/src/main/services/lanes/laneService.ts +++ b/apps/desktop/src/main/services/lanes/laneService.ts @@ -685,6 +685,7 @@ export function createLaneService({ onHeadChanged, onRebaseEvent, onDeleteEvent, + onLinearIssueLinked, teardownDeps, logger: injectedLogger }: { @@ -697,6 +698,7 @@ export function createLaneService({ onHeadChanged?: (args: { laneId: string; reason: string; preHeadSha: string | null; postHeadSha: string | null }) => void; onRebaseEvent?: (event: RebaseRunEventPayload) => void; onDeleteEvent?: (event: LaneDeleteEvent) => void; + onLinearIssueLinked?: (args: { lane: LaneSummary; issue: LaneLinearIssue; linkedAt: string }) => void | Promise; teardownDeps?: LaneDeleteTeardownDeps; logger?: Logger; }) { @@ -707,6 +709,25 @@ export function createLaneService({ error: (event, meta) => console.error(event, meta ?? ""), }; + const notifyLinearIssueLinked = (lane: LaneSummary, issue: LaneLinearIssue): void => { + if (!onLinearIssueLinked) return; + const logFailure = (error: unknown): void => { + logger.warn("laneService.linear_issue_link_notify_failed", { + laneId: lane.id, + issueId: issue.id, + error: error instanceof Error ? error.message : String(error), + }); + }; + try { + const result = onLinearIssueLinked({ lane, issue, linkedAt: lane.createdAt }); + if (result && typeof (result as Promise).catch === "function") { + void (result as Promise).catch(logFailure); + } + } catch (error) { + logFailure(error); + } + }; + const linkExistingDependencyInstalls = (worktreePath: string): void => { if (!fs.existsSync(worktreePath)) return; @@ -1776,7 +1797,7 @@ export function createLaneService({ })() : null; - return toLaneSummary({ + const summary = toLaneSummary({ row, status, parentStatus, @@ -1785,6 +1806,8 @@ export function createLaneService({ activeBranchProfile: ensureBranchProfileForRow(row), linearIssue, }); + if (linearIssue) notifyLinearIssueLinked(summary, linearIssue); + return summary; }; const getRowsById = (includeArchived = true): Map => diff --git a/apps/desktop/src/main/services/opencode/openCodeRuntime.test.ts b/apps/desktop/src/main/services/opencode/openCodeRuntime.test.ts index aa1663b4c..d5b7575fd 100644 --- a/apps/desktop/src/main/services/opencode/openCodeRuntime.test.ts +++ b/apps/desktop/src/main/services/opencode/openCodeRuntime.test.ts @@ -129,7 +129,7 @@ describe("openCodeRuntime", () => { config: expect.objectContaining({ agent: expect.objectContaining({ "ade-plan": expect.objectContaining({ - tools: expect.objectContaining({ task: false }), + tools: expect.objectContaining({ code_search: false, web_search: false }), }), }), }), diff --git a/apps/desktop/src/main/services/opencode/openCodeRuntime.ts b/apps/desktop/src/main/services/opencode/openCodeRuntime.ts index b801e9758..d554b2c05 100644 --- a/apps/desktop/src/main/services/opencode/openCodeRuntime.ts +++ b/apps/desktop/src/main/services/opencode/openCodeRuntime.ts @@ -42,8 +42,8 @@ export type OpenCodeAgentProfile = "ade-plan" | "ade-edit" | "ade-full-auto" | " const ADE_PLAN_TOOL_SELECTION: Record = { // ADE mission planning must go through coordinator tools such as - // spawn_worker; OpenCode's native task subagent bypasses mission state. - task: false, + // spawn_worker. The native `task` subagent tool is intentionally allowed so + // OpenCode child sessions surface in the desktop / TUI subagents panes. codesearch: false, code_search: false, filesearch: false, diff --git a/apps/desktop/src/main/services/prs/prService.ts b/apps/desktop/src/main/services/prs/prService.ts index 77c743876..05b2d816e 100644 --- a/apps/desktop/src/main/services/prs/prService.ts +++ b/apps/desktop/src/main/services/prs/prService.ts @@ -51,6 +51,7 @@ import type { PrCreationStrategy, PrGroupMemberRole, PrHealth, + PrLaneSummary, PrMergeContext, PrReview, PrReviewStatus, @@ -1023,6 +1024,17 @@ export function createPrService({ [projectId] ); + const rowToLanePrSummary = (row: PullRequestRow, checks: PrCheck[] = []): PrLaneSummary => { + const state: PrLaneSummary["state"] = row.state === "merged" || row.state === "closed" ? row.state : "open"; + return { + laneId: row.lane_id, + number: Number(row.github_pr_number), + state, + checksPassed: checks.filter((check) => check.status === "completed" && check.conclusion === "success").length, + checksTotal: checks.length, + }; + }; + const HOT_REFRESH_PHASE_ONE_MS = 60_000; const HOT_REFRESH_PHASE_TWO_MS = 3 * 60_000; const HOT_REFRESH_INTERVAL_PHASE_ONE_MS = 5_000; @@ -6067,6 +6079,15 @@ export function createPrService({ return laneId ? summaries.filter((pr) => pr.laneId === laneId) : summaries; }, + async listPrsByLane(): Promise { + const laneIds = Array.from(new Set(listRows().map((row) => row.lane_id).filter(Boolean))); + const rows = laneIds + .map((laneId) => getDisplayRowForCurrentLaneBranch(laneId)) + .filter((row): row is PullRequestRow => row != null); + const checksByPrId = new Map(listSnapshotRows().map((snapshot) => [snapshot.prId, snapshot.checks] as const)); + return rows.map((row) => rowToLanePrSummary(row, isActivePrState(row.state) ? checksByPrId.get(row.id) ?? [] : [])); + }, + /** * Returns a flat list of open PRs in the project's GitHub repo, keyed by * head branch. Used by the branch picker to attach PR pills to branches. diff --git a/apps/desktop/src/main/services/pty/ptyService.test.ts b/apps/desktop/src/main/services/pty/ptyService.test.ts index 1a745d1d6..e01676906 100644 --- a/apps/desktop/src/main/services/pty/ptyService.test.ts +++ b/apps/desktop/src/main/services/pty/ptyService.test.ts @@ -3,6 +3,7 @@ import { EventEmitter } from "node:events"; import os from "node:os"; import path from "node:path"; import type { IPty } from "node-pty"; +import type * as TerminalSessionSignals from "../../utils/terminalSessionSignals"; // --------------------------------------------------------------------------- // Hoisted mocks @@ -162,7 +163,7 @@ vi.mock("../../utils/terminalPreview", () => ({ })); vi.mock("../../utils/terminalSessionSignals", async () => { - const actual = await vi.importActual( + const actual = await vi.importActual( "../../utils/terminalSessionSignals", ); return { @@ -989,6 +990,78 @@ describe("ptyService", () => { expect(mockPty.write).toHaveBeenCalledWith("claude --resume claude-session-123\r"); }); + it("backfills a missing Codex storage target before launching the resumed PTY", async () => { + vi.useFakeTimers(); + try { + const fakeNow = new Date("2026-04-15T22:00:00.000Z"); + vi.setSystemTime(fakeNow); + + const homedir = os.homedir(); + const sessionsBase = path.join(homedir, ".codex", "sessions"); + const dirPath = path.join(sessionsBase, "2026", "04", "15"); + const filePath = path.join(dirPath, "rollout-2026-04-15T21-30-00-thread-resume.jsonl"); + const startedAt = "2026-04-15T21:30:00.000Z"; + const firstLine = JSON.stringify({ + timestamp: startedAt, + type: "session_meta", + payload: { + id: "thread-resume", + timestamp: startedAt, + cwd: "/tmp/test-worktree", + }, + }); + + mocks.existsSyncResults.set(sessionsBase, true); + mocks.existsSyncResults.set(dirPath, true); + mocks.dirEntries.set(dirPath, [path.basename(filePath)]); + mocks.fileContents.set(filePath, `${firstLine}\n`); + mocks.fileStats.set(filePath, { size: firstLine.length, mtimeMs: fakeNow.getTime() - 30_000, isDirectory: false }); + + const { service, sessionService, mockPty } = createHarness(); + sessionService.create({ + sessionId: "session-codex-picker", + laneId: "lane-1", + ptyId: null, + tracked: true, + title: "Codex CLI", + startedAt, + transcriptPath: "/tmp/test-worktree/.ade/transcripts/session-codex-picker.log", + toolType: "codex", + resumeCommand: "codex --no-alt-screen --dangerously-bypass-approvals-and-sandbox resume '\u001b[>7u'", + resumeMetadata: { + provider: "codex", + targetKind: "thread", + targetId: "\u001b[>7u", + launch: { permissionMode: "full-auto" }, + }, + }); + sessionService.end({ + sessionId: "session-codex-picker", + endedAt: "2026-04-15T21:40:00.000Z", + exitCode: 0, + status: "completed", + }); + + await service.create({ + sessionId: "session-codex-picker", + laneId: "lane-1", + title: "Codex CLI", + cols: 80, + rows: 24, + toolType: "codex", + startupCommand: "codex --no-alt-screen --dangerously-bypass-approvals-and-sandbox resume", + }); + + expect(sessionService.setResumeCommand).toHaveBeenCalledWith( + "session-codex-picker", + "codex resume thread-resume", + ); + expect(mockPty.write).toHaveBeenCalledWith("codex resume thread-resume\r"); + } finally { + vi.useRealTimers(); + } + }); + it("preserves the strict resume path when a requested session id does not exist", async () => { const { service } = createHarness(); @@ -1569,6 +1642,53 @@ describe("ptyService", () => { expect(state).toBe("running"); }); + it("emits an idle runtime signal when a live CLI session stops outputting", async () => { + vi.useFakeTimers(); + try { + const { service, mockPty, onSessionRuntimeSignal } = createHarness(); + const { sessionId } = await service.create({ + laneId: "lane-1", + title: "Claude CLI", + cols: 80, + rows: 24, + toolType: "claude", + }); + + mockPty._emitter.emit("data", "working...\n"); + onSessionRuntimeSignal.mockClear(); + + await vi.advanceTimersByTimeAsync(12_499); + expect(onSessionRuntimeSignal).not.toHaveBeenCalledWith( + expect.objectContaining({ sessionId, runtimeState: "idle" }), + ); + + await vi.advanceTimersByTimeAsync(1); + + expect(service.getRuntimeState(sessionId, "running")).toBe("idle"); + expect(onSessionRuntimeSignal).toHaveBeenCalledWith( + expect.objectContaining({ + laneId: "lane-1", + sessionId, + runtimeState: "idle", + }), + ); + + onSessionRuntimeSignal.mockClear(); + mockPty._emitter.emit("data", "more work\n"); + + expect(service.getRuntimeState(sessionId, "running")).toBe("running"); + expect(onSessionRuntimeSignal).toHaveBeenCalledWith( + expect.objectContaining({ + laneId: "lane-1", + sessionId, + runtimeState: "running", + }), + ); + } finally { + vi.useRealTimers(); + } + }); + it("derives state from fallback status for unknown sessions", () => { const { service } = createHarness(); expect(service.getRuntimeState("unknown-session", "completed")).toBe("exited"); @@ -1895,10 +2015,8 @@ describe("ptyService", () => { it("backfills Codex storage resume targets during session-list hydration", async () => { // The session-list path is how older sessions (whose transcripts no // longer contain an explicit resume command) get their resume target - // backfilled. Excluding `session-list` from the Codex storage fallback - // breaks resumption of those sessions, so the fallback must run here. - // Only `resume-launch` is excluded — that flow uses the live capture - // poll for fresh sessions and the storage scan would slow launch. + // backfilled. The same storage fallback also runs from resume-launch so + // pressing Resume does not fall through to Codex's picker. vi.useFakeTimers(); try { const fakeNow = new Date("2026-04-15T22:00:00.000Z"); @@ -2019,9 +2137,15 @@ describe("ptyService", () => { mocks.existsSyncResults.set(dirPath, true); mocks.dirEntries.set(dirPath, [path.basename(stalePath), path.basename(freshPath)]); mocks.fileContents.set(stalePath, `${staleFirstLine}\n`); - mocks.fileContents.set(freshPath, `${freshFirstLine}\n{"timestamp":"2026-04-15T22:00:01.500Z","type":"event_msg","payload":{"type":"user_message","message":"ADE session guidance"}}\n`); + const freshContent = [ + freshFirstLine, + JSON.stringify({ timestamp: "2026-04-15T22:00:01.200Z", type: "response_item", payload: { type: "message", role: "developer", content: "x".repeat(70_000) } }), + JSON.stringify({ timestamp: "2026-04-15T22:00:01.500Z", type: "event_msg", payload: { type: "user_message", message: "ADE session guidance" } }), + JSON.stringify({ timestamp: "2026-04-15T22:00:03.000Z", type: "event_msg", payload: { type: "thread_name_updated", thread_id: "thread-fresh", thread_name: "Runtime title from Codex" } }), + ].join("\n") + "\n"; + mocks.fileContents.set(freshPath, freshContent); mocks.fileStats.set(stalePath, { size: staleFirstLine.length, mtimeMs: fakeNow.getTime() - 30 * 60_000, isDirectory: false }); - mocks.fileStats.set(freshPath, { size: freshFirstLine.length, mtimeMs: fakeNow.getTime() + 1_000, isDirectory: false }); + mocks.fileStats.set(freshPath, { size: freshContent.length, mtimeMs: fakeNow.getTime() + 1_000, isDirectory: false }); const { service, sessionService } = createHarness(); const created = await service.create({ @@ -2037,6 +2161,14 @@ describe("ptyService", () => { expect(sessionService.setResumeCommand).toHaveBeenCalledWith(created.sessionId, "codex resume thread-fresh"); expect(sessionService.setResumeCommand).not.toHaveBeenCalledWith(created.sessionId, "codex resume thread-stale"); + expect(sessionService.updateMeta).toHaveBeenCalledWith( + expect.objectContaining({ + sessionId: created.sessionId, + title: "Runtime title from Codex", + manuallyNamed: false, + }), + ); + expect(sessionService.get(created.sessionId)?.title).toBe("Runtime title from Codex"); } finally { vi.useRealTimers(); } diff --git a/apps/desktop/src/main/services/pty/ptyService.ts b/apps/desktop/src/main/services/pty/ptyService.ts index 510b2c81f..20fa4401c 100644 --- a/apps/desktop/src/main/services/pty/ptyService.ts +++ b/apps/desktop/src/main/services/pty/ptyService.ts @@ -39,7 +39,8 @@ import { defaultResumeCommandForTool, extractResumeCommandFromOutput, parseTrackedCliLaunchConfig, - runtimeStateFromOsc133Chunk + runtimeStateFromOsc133Chunk, + sanitizeResumeTargetId, } from "../../utils/terminalSessionSignals"; /** Delay before auto-generating a title from CLI output; keep in sync with tests. */ @@ -63,11 +64,19 @@ function shouldScheduleOutputSnippetTitle(tool: TerminalToolType | null): boolea const CLI_USER_TITLE_SEED_MIN_LEN = 3; const CLI_USER_TITLE_SEED_MAX_LEN = 180; const CLI_USER_TITLE_FALLBACK_MAX_LEN = 72; +const CODEX_ADE_GUIDANCE_SCAN_BYTES = 160 * 1024; +const CODEX_THREAD_NAME_SCAN_BYTES = 512 * 1024; const PTY_DATA_BATCH_INTERVAL_MS = 16; const PTY_DATA_BATCH_MAX_CHARS = 64 * 1024; const PTY_DATA_SUMMARY_INTERVAL_MS = 10_000; const DEFAULT_TERMINAL_READ_MAX_BYTES = 220_000; +type CodexStorageSessionMatch = { + id: string; + filePath: string; + threadName: string | null; +}; + function hasEnvValue(env: NodeJS.ProcessEnv, key: string): boolean { return typeof env[key] === "string" && env[key]!.trim().length > 0; } @@ -674,6 +683,24 @@ export function createPtyService({ current.state = "idle"; current.updatedAt = Date.now(); current.idleTimer = null; + const live = Array.from(ptys.values()).find((entry) => entry.sessionId === sessionId && !entry.disposed) ?? null; + if (live?.tracked && onSessionRuntimeSignal) { + const at = new Date(current.updatedAt).toISOString(); + live.lastRuntimeSignalAt = current.updatedAt; + live.lastRuntimeSignalState = "idle"; + live.lastRuntimeSignalPreview = live.latestPreviewLine ?? live.lastPreviewWritten ?? null; + try { + onSessionRuntimeSignal({ + laneId: live.laneId, + sessionId: live.sessionId, + runtimeState: "idle", + lastOutputPreview: live.lastRuntimeSignalPreview, + at, + }); + } catch { + // ignore callback failures + } + } }, 12_500); }; @@ -940,19 +967,118 @@ export function createPtyService({ } } + function readFileSuffix(filePath: string, maxBytes = 512 * 1024): string | null { + let fd: number | null = null; + try { + const size = Math.max(0, Number(fs.statSync(filePath).size) || 0); + const readBytes = Math.min(maxBytes, size); + if (readBytes <= 0) return null; + fd = fs.openSync(filePath, "r"); + const buf = Buffer.alloc(readBytes); + const bytesRead = fs.readSync(fd, buf, 0, readBytes, Math.max(0, size - readBytes)); + if (bytesRead <= 0) return null; + return buf.subarray(0, bytesRead).toString("utf8"); + } catch { + return null; + } finally { + if (fd !== null) { + try { + fs.closeSync(fd); + } catch { + // Ignore close errors while scanning best-effort session metadata. + } + } + } + } + + function sanitizeCodexRuntimeThreadName(raw: unknown): string | null { + const title = sanitizeGeneratedCliTitle(typeof raw === "string" ? raw : ""); + if (!title) return null; + const normalized = title.toLowerCase().replace(/[^\p{L}\p{N}_-]+/gu, "").trim(); + if (/^ade-[a-z0-9_-]+$/iu.test(normalized)) return null; + return title; + } + + function threadNameFromCodexRecord(record: unknown, codexSessionId: string): string | null { + if (!record || typeof record !== "object") return null; + const obj = record as Record; + const payload = obj.payload && typeof obj.payload === "object" ? obj.payload as Record : obj; + const type = typeof payload.type === "string" ? payload.type : typeof obj.type === "string" ? obj.type : ""; + const method = typeof obj.method === "string" ? obj.method : typeof payload.method === "string" ? payload.method : ""; + const params = payload.params && typeof payload.params === "object" ? payload.params as Record : payload; + const threadId = typeof params.thread_id === "string" + ? params.thread_id + : typeof params.threadId === "string" + ? params.threadId + : ""; + const isNameUpdate = + type === "thread_name_updated" + || type === "thread_updated" + || method === "thread/name/updated" + || method === "thread/updated"; + if (!isNameUpdate) return null; + if (threadId && threadId !== codexSessionId) return null; + return sanitizeCodexRuntimeThreadName( + params.thread_name + ?? params.threadName + ?? params.name + ?? params.title, + ); + } + + function readCodexThreadNameFromSessionFile(filePath: string, codexSessionId: string): string | null { + const prefix = readFilePrefix(filePath, CODEX_THREAD_NAME_SCAN_BYTES) ?? ""; + const suffix = readFileSuffix(filePath, CODEX_THREAD_NAME_SCAN_BYTES) ?? ""; + const text = prefix && suffix && prefix !== suffix ? `${prefix}\n${suffix}` : (suffix || prefix); + if (!text) return null; + const lines = text.split(/\r?\n/).filter(Boolean); + for (let i = lines.length - 1; i >= 0; i -= 1) { + try { + const title = threadNameFromCodexRecord(JSON.parse(lines[i]!), codexSessionId); + if (title) return title; + } catch { + // Ignore malformed or partial JSONL fragments from prefix/suffix reads. + } + } + return null; + } + + function readCodexThreadNameFromIndex(codexSessionId: string): string | null { + const indexPath = path.join(os.homedir(), ".codex", "session_index.jsonl"); + const text = readFileSuffix(indexPath, CODEX_THREAD_NAME_SCAN_BYTES); + if (!text) return null; + const lines = text.split(/\r?\n/).filter(Boolean); + for (let i = lines.length - 1; i >= 0; i -= 1) { + try { + const entry = JSON.parse(lines[i]!) as Record; + if (entry.id !== codexSessionId) continue; + const title = sanitizeCodexRuntimeThreadName(entry.thread_name ?? entry.threadName ?? entry.name); + if (title) return title; + } catch { + // Ignore malformed or partial JSONL fragments. + } + } + return null; + } + + function readCodexRuntimeThreadName(filePath: string, codexSessionId: string): string | null { + return readCodexThreadNameFromIndex(codexSessionId) + ?? readCodexThreadNameFromSessionFile(filePath, codexSessionId); + } + /** * Try to find the Codex session ID from Codex's local storage. * Codex stores sessions at ~/.codex/sessions/YYYY/MM/DD/rollout--.jsonl. * Each JSONL starts with a session_meta event containing `payload.id` and `payload.cwd`. * We score recent candidates by cwd match and closeness to ADE's session startedAt. */ - const resolveCodexSessionIdFromStorage = (args: { + const resolveCodexSessionFromStorage = (args: { cwd: string; startedAt?: string | null; maxStartDeltaMs?: number; notBeforeMs?: number; requiredText?: string; - }): string | null => { + }): CodexStorageSessionMatch | null => { try { const sessionsBase = path.join(os.homedir(), ".codex", "sessions"); if (!fs.existsSync(sessionsBase)) return null; @@ -980,7 +1106,7 @@ export function createPtyService({ if (!candidates.length) return null; candidates.sort((a, b) => b.mtimeMs - a.mtimeMs); - let bestMatch: { id: string; score: number; mtimeMs: number } | null = null; + let bestMatch: { id: string; filePath: string; score: number; mtimeMs: number } | null = null; for (const candidate of candidates.slice(0, 80)) { const firstLine = readJsonlFirstLine(candidate.filePath); if (!firstLine) continue; @@ -996,14 +1122,20 @@ export function createPtyService({ const cwd = typeof payload?.cwd === "string" ? payload.cwd.trim() : ""; if (type !== "session_meta" || !id || cwd !== args.cwd) continue; if (args.requiredText) { - // The Codex session_meta record sits at the very top of the JSONL, - // so 16 KB is more than enough to scan for the marker without - // pulling half a megabyte off disk per candidate inside the poll. - const prefix = readFilePrefix(candidate.filePath, 16 * 1024); + // ADE's injected session guidance can land after a large session_meta + // line plus restored context, so scan beyond the first few KB while + // still keeping the live poll bounded. + const prefix = readFilePrefix(candidate.filePath, CODEX_ADE_GUIDANCE_SCAN_BYTES); if (!prefix?.includes(args.requiredText)) continue; } - if (!hasStartedAt) return id; + if (!hasStartedAt) { + return { + id, + filePath: candidate.filePath, + threadName: readCodexRuntimeThreadName(candidate.filePath, id), + }; + } const payloadTimestamp = typeof payload?.timestamp === "string" ? payload.timestamp : ""; const payloadTimestampMs = Date.parse(payloadTimestamp); @@ -1012,10 +1144,16 @@ export function createPtyService({ const score = Math.abs(referenceMs - requestedStartedAtMs); if (typeof args.maxStartDeltaMs === "number" && score > args.maxStartDeltaMs) continue; if (!bestMatch || score < bestMatch.score || (score === bestMatch.score && candidate.mtimeMs > bestMatch.mtimeMs)) { - bestMatch = { id, score, mtimeMs: candidate.mtimeMs }; + bestMatch = { id, filePath: candidate.filePath, score, mtimeMs: candidate.mtimeMs }; } } - return bestMatch?.id ?? null; + return bestMatch + ? { + id: bestMatch.id, + filePath: bestMatch.filePath, + threadName: readCodexRuntimeThreadName(bestMatch.filePath, bestMatch.id), + } + : null; } catch { return null; } @@ -1132,7 +1270,7 @@ export function createPtyService({ if (!session?.tracked) return false; const effectiveToolType = preferredToolType ?? session.toolType ?? null; if (!isTrackedCliToolType(effectiveToolType)) return false; - if (session.resumeMetadata?.targetId?.trim()) return true; + if (sanitizeResumeTargetId(session.resumeMetadata?.targetId ?? null)) return true; const recentMissing = missingResumeTargetBackfillFailures.get(sessionId); if ( reason === "session-list" @@ -1166,22 +1304,23 @@ export function createPtyService({ } } - // The session-list path NEEDS this Codex storage fallback: it's how we - // backfill resume targets for older sessions whose transcripts no longer - // contain an explicit resume command. Only resume-launch is excluded — - // that flow already has the live capture poll for fresh sessions, and - // running the storage scan inline would slow launch. - if ((effectiveToolType === "codex" || effectiveToolType === "codex-orchestrated") && cwd && reason !== "resume-launch") { - const codexSessionId = resolveCodexSessionIdFromStorage({ + // The session-list and resume-launch paths both need this Codex storage + // fallback. Fresh launches still use the live capture watcher below; this + // path handles existing tracked sessions whose transcript did not yield a + // usable Codex thread id before the user presses Resume. + if ((effectiveToolType === "codex" || effectiveToolType === "codex-orchestrated") && cwd) { + const codexSession = resolveCodexSessionFromStorage({ cwd, startedAt: session.startedAt, maxStartDeltaMs: 10 * 60_000, }); - if (codexSessionId) { - const resumeCmd = `codex resume ${codexSessionId}`; + if (codexSession) { + const resumeCmd = `codex resume ${codexSession.id}`; missingResumeTargetBackfillFailures.delete(sessionId); sessionService.setResumeCommand(sessionId, resumeCmd); - logger.info("pty.resume_target_backfilled", { sessionId, toolType: effectiveToolType, reason, source: "codex-storage", codexSessionId }); + adoptCodexRuntimeThreadName(sessionId, codexSession.threadName, "codex-storage-backfill"); + scheduleCodexRuntimeTitleCaptureBestEffort(sessionId, codexSession.id, codexSession.filePath); + logger.info("pty.resume_target_backfilled", { sessionId, toolType: effectiveToolType, reason, source: "codex-storage", codexSessionId: codexSession.id }); return true; } } @@ -1246,48 +1385,57 @@ export function createPtyService({ // Cadence is intentionally aggressive at the start (codex usually writes session_meta within // ~1 s) and falls off so a slow startup doesn't keep timers alive forever. const CODEX_FALLBACK_POLL_DELAYS_MS = [500, 2_000, 5_000, 12_000, 30_000]; + const CODEX_TITLE_POLL_DELAYS_MS = [2_000, 5_000, 12_000, 30_000, 60_000]; const CODEX_LIVE_CAPTURE_HARD_TIMEOUT_MS = 60_000; const CODEX_WATCH_DEBOUNCE_MS = 200; - /** - * Stable thread name we register against the freshly-discovered codex UUID. From this point - * forward, `codex resume ade-` resolves through `~/.codex/session_index.jsonl` regardless - * of where the rollout file ends up on disk. We control this name space (`ade-*`) so it never - * collides with user-chosen names. - */ - const buildCodexAdeName = (sessionId: string): string => { - const stripped = sessionId.replace(/[^a-zA-Z0-9_-]/g, "").slice(0, 24); - return `ade-${stripped || "session"}`; + const adoptCodexRuntimeThreadName = ( + sessionId: string, + rawTitle: string | null | undefined, + source: string, + ): boolean => { + const title = sanitizeCodexRuntimeThreadName(rawTitle ?? ""); + if (!title) return false; + if (isSessionManuallyNamed(sessionService, sessionId)) { + logger.info("pty.codex_runtime_title_skipped_user_renamed", { sessionId, source }); + return true; + } + const session = sessionService.get(sessionId); + if (!session) return true; + if (session.title?.trim() === title) return true; + sessionService.updateMeta({ sessionId, title, manuallyNamed: false }); + logger.info("pty.codex_runtime_title_adopted", { + sessionId, + source, + titleLength: title.length, + }); + return true; }; - /** - * Append `{id, thread_name, updated_at}` to ~/.codex/session_index.jsonl so codex's resume - * picker can find the session by our chosen name. Codex normally writes this file from its - * `SetThreadName` op, but the format is a public on-disk contract — appending one line with - * an atomic write is well under PIPE_BUF and safe vs. concurrent codex writers. Returns true - * on successful write. - */ - const registerCodexThreadNameInIndex = (uuid: string, threadName: string): boolean => { - if (typeof (fs as { appendFileSync?: unknown }).appendFileSync !== "function") return false; - try { - const indexPath = path.join(os.homedir(), ".codex", "session_index.jsonl"); - const line = JSON.stringify({ - id: uuid, - thread_name: threadName, - updated_at: new Date().toISOString(), - }) + "\n"; - fs.appendFileSync(indexPath, line, { encoding: "utf8" }); - return true; - } catch { - return false; + const scheduleCodexRuntimeTitleCaptureBestEffort = ( + sessionId: string, + codexSessionId: string, + filePath: string, + ): void => { + const tryTitle = (source: string): boolean => { + const title = readCodexRuntimeThreadName(filePath, codexSessionId); + return adoptCodexRuntimeThreadName(sessionId, title, source); + }; + + if (tryTitle("codex-storage-initial")) return; + for (let i = 0; i < CODEX_TITLE_POLL_DELAYS_MS.length; i += 1) { + const timer = setTimeout(() => { + tryTitle(`codex-storage-poll-${i}`); + }, CODEX_TITLE_POLL_DELAYS_MS[i]); + timer.unref?.(); } }; // Codex CLI has no pre-assigned session ID flag (unlike Claude's --session-id), so the - // rollout JSONL is the only handle on the session's UUID. We watch the day directory for the - // file's appearance, then claim a stable `ade-` thread name so future resumes don't - // depend on filesystem heuristics. A staggered poll covers environments where fs.watch is - // missing/unreliable (network mounts, Linux on some FSes, the test harness). + // rollout JSONL is the only handle on the session's UUID. We watch the day directory for + // the file's appearance, then store the UUID directly for resume and separately adopt any + // runtime-generated thread name Codex writes. A staggered poll covers environments where + // fs.watch is missing/unreliable (network mounts, Linux on some FSes, the test harness). const scheduleCodexSessionIdCaptureBestEffort = ( sessionId: string, cwd: string, @@ -1328,28 +1476,27 @@ export function createPtyService({ cleanup(); return true; } - if (session.resumeMetadata?.targetId?.trim()) { + if (sanitizeResumeTargetId(session.resumeMetadata?.targetId ?? null)) { cleanup(); return true; } - const codexUuid = resolveCodexSessionIdFromStorage({ + const codexSession = resolveCodexSessionFromStorage({ cwd, startedAt, maxStartDeltaMs: 5 * 60_000, ...(startedAtFinite !== null ? { notBeforeMs: startedAtFinite - 1_000 } : {}), requiredText: "ADE session guidance", }); - if (!codexUuid) return false; + if (!codexSession) return false; captured = true; - const adeName = buildCodexAdeName(sessionId); - const indexed = registerCodexThreadNameInIndex(codexUuid, adeName); - const resumeCmd = indexed ? `codex resume ${adeName}` : `codex resume ${codexUuid}`; + const resumeCmd = `codex resume ${codexSession.id}`; sessionService.setResumeCommand(sessionId, resumeCmd); + adoptCodexRuntimeThreadName(sessionId, codexSession.threadName, "codex-storage-live"); + scheduleCodexRuntimeTitleCaptureBestEffort(sessionId, codexSession.id, codexSession.filePath); logger.info("pty.codex_session_id_captured_live", { sessionId, - codexSessionId: codexUuid, - adeName: indexed ? adeName : null, + codexSessionId: codexSession.id, source, attempt, }); @@ -1832,7 +1979,7 @@ export function createPtyService({ const shouldBackfillResumeTarget = existingSession && isTrackedCliToolType(toolTypeHint) - && !existingSession.resumeMetadata?.targetId?.trim(); + && !sanitizeResumeTargetId(existingSession.resumeMetadata?.targetId ?? null); if (shouldBackfillResumeTarget) { const backfilled = await tryBackfillResumeTarget(sessionId, toolTypeHint, "resume-launch", cwd); const updatedSession = backfilled ? sessionService.get(sessionId) : null; @@ -2023,7 +2170,10 @@ export function createPtyService({ enqueuePtyData(entry, { ptyId, sessionId, data }); const prevState = runtimeStates.get(sessionId)?.state ?? "running"; - const runtimeState = runtimeStateFromOsc133Chunk(data, prevState); + const markerState = runtimeStateFromOsc133Chunk(data, prevState); + const runtimeState = markerState === prevState && prevState === "idle" && data.length > 0 + ? "running" + : markerState; setRuntimeState(sessionId, runtimeState); if (runtimeState === "running") { scheduleIdleTransition(sessionId); diff --git a/apps/desktop/src/main/services/sessions/sessionService.ts b/apps/desktop/src/main/services/sessions/sessionService.ts index bd75b11d9..8b1af8b78 100644 --- a/apps/desktop/src/main/services/sessions/sessionService.ts +++ b/apps/desktop/src/main/services/sessions/sessionService.ts @@ -20,6 +20,7 @@ import { parseTrackedCliLaunchConfig, parseTrackedCliResumeCommand, providerFromTool, + sanitizeResumeTargetId, } from "../../utils/terminalSessionSignals"; type SessionRow = { @@ -106,7 +107,8 @@ function normalizeResumeMetadata(raw: unknown): TerminalResumeMetadata | null { const provider = isResumeProvider(record.provider) ? record.provider : null; const targetKind = record.targetKind === "session" || record.targetKind === "thread" ? record.targetKind : null; const legacyTarget = typeof record.target === "string" ? record.target.trim() : ""; - const targetId = typeof record.targetId === "string" ? record.targetId.trim() : legacyTarget; + const rawTargetId = typeof record.targetId === "string" ? record.targetId.trim() : legacyTarget; + const targetId = sanitizeResumeTargetId(rawTargetId); const launchRecord = record.launch != null && typeof record.launch === "object" && !Array.isArray(record.launch) ? (record.launch as Record) : {}; @@ -125,7 +127,7 @@ function normalizeResumeMetadata(raw: unknown): TerminalResumeMetadata | null { return { provider, targetKind, - targetId: targetId.length ? targetId : null, + targetId, launch: { ...(permissionMode ? { permissionMode } : {}), ...(claudePermissionMode ? { claudePermissionMode: claudePermissionMode as TerminalResumeMetadata["launch"]["claudePermissionMode"] } : {}), diff --git a/apps/desktop/src/main/services/sync/syncHostService.test.ts b/apps/desktop/src/main/services/sync/syncHostService.test.ts index 2b00660b0..fb3d2fbd1 100644 --- a/apps/desktop/src/main/services/sync/syncHostService.test.ts +++ b/apps/desktop/src/main/services/sync/syncHostService.test.ts @@ -12,12 +12,18 @@ import type { SyncPinStore } from "./syncPinStore"; import { encodeSyncEnvelope, parseSyncEnvelope } from "./syncProtocol"; import type { ParsedSyncEnvelope } from "./syncProtocol"; -const { execFileMock } = vi.hoisted(() => ({ +const { execFileMock, spawnMock } = vi.hoisted(() => ({ execFileMock: vi.fn(), + spawnMock: vi.fn(() => ({ + kill: vi.fn(), + once: vi.fn(), + unref: vi.fn(), + })), })); vi.mock("node:child_process", () => ({ execFile: execFileMock, + spawn: spawnMock, })); function createStubPinStore(initialPin: string | null = null): SyncPinStore { diff --git a/apps/desktop/src/main/services/sync/syncService.test.ts b/apps/desktop/src/main/services/sync/syncService.test.ts index d0c41975f..308c86313 100644 --- a/apps/desktop/src/main/services/sync/syncService.test.ts +++ b/apps/desktop/src/main/services/sync/syncService.test.ts @@ -6,13 +6,16 @@ import { isCrsqliteAvailable } from "../state/crsqliteExtension"; import { openKvDb } from "../state/kvDb"; import { createSyncService } from "./syncService"; -const { createSyncHostServiceMock } = vi.hoisted(() => ({ +const { createSyncHostServiceMock, syncHostServiceMockState } = vi.hoisted(() => ({ + syncHostServiceMockState: { + port: 8787, + }, createSyncHostServiceMock: vi.fn(() => ({ async waitUntilListening() { - return 8787; + return syncHostServiceMockState.port; }, getPort() { - return 8787; + return syncHostServiceMockState.port; }, getBootstrapToken() { return "test-bootstrap-token"; @@ -107,6 +110,7 @@ const activeDisposers: Array<() => Promise> = []; beforeEach(() => { createSyncHostServiceMock.mockClear(); + syncHostServiceMockState.port = 8787; }); afterEach(async () => { @@ -506,6 +510,47 @@ describe.skipIf(!isCrsqliteAvailable())("syncService", () => { expect(addressCandidates.some((c) => c.kind === "loopback" && c.host === "127.0.0.1")).toBe(true); }, 30_000); + it("reports the live host port in pairing info when the sync host falls back", async () => { + syncHostServiceMockState.port = 8788; + const projectRoot = makeProjectRoot("ade-sync-service-fallback-port-"); + const db = await openKvDb( + path.join(projectRoot, ".ade", "ade.db"), + createLogger() as any, + ); + + const service = createSyncService({ + db, + logger: createLogger() as any, + projectRoot, + fileService: { dispose: () => {} } as any, + laneService: { + list: async () => [], + create: async () => ({}), + archive: async () => {}, + } as any, + prService: {} as any, + sessionService: { list: () => [] } as any, + ptyService: {} as any, + computerUseArtifactBrokerService: {} as any, + missionService: { list: () => [] } as any, + agentChatService: { listSessions: async () => [] } as any, + processService: { listRuntime: () => [] } as any, + forceHostRole: true, + hostStartupEnabled: true, + }); + + activeDisposers.push(async () => { + await service.dispose(); + db.close(); + }); + + await service.initialize(); + const status = await service.getStatus(); + expect(status.localDevice.lastPort).toBe(8788); + expect(status.currentBrain?.lastPort).toBe(8788); + expect(status.pairingConnectInfo?.port).toBe(8788); + }, 30_000); + it("does not start the sync host or expose pairing details when host startup is disabled", async () => { const projectRoot = makeProjectRoot("ade-sync-service-host-disabled-"); const db = await openKvDb( diff --git a/apps/desktop/src/main/services/usage/usageTrackingService.test.ts b/apps/desktop/src/main/services/usage/usageTrackingService.test.ts index 7cbaf834f..d672665d8 100644 --- a/apps/desktop/src/main/services/usage/usageTrackingService.test.ts +++ b/apps/desktop/src/main/services/usage/usageTrackingService.test.ts @@ -3,6 +3,7 @@ import { EventEmitter } from "node:events"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; +import type { UsageWindow } from "../../../shared/types"; const mockState = vi.hoisted(() => ({ spawn: vi.fn(), @@ -23,6 +24,7 @@ import { createUsageTrackingService, _testing } from "./usageTrackingService"; const { aggregateCosts, + bucketDaily7d, calculatePacing, MIN_POLL_INTERVAL_MS, MAX_POLL_INTERVAL_MS, @@ -30,9 +32,8 @@ const { isTokenExpiredOrExpiring, parseClaudeWindows, parseCodexRateLimitWindows, - parseCursorSpendUsage, - pollCursorUsage, calculatePacingByProvider, + detectThresholdCrossings, pollCodexViaCliRpc, resolveTokenPrice, } = _testing; @@ -459,111 +460,71 @@ describe("parseCodexRateLimitWindows", () => { }); }); -describe("parseCursorSpendUsage", () => { - it("normalizes Cursor team spend into a monthly used-percent window", () => { - const cycleStart = Date.UTC(2026, 4, 1); - const result = parseCursorSpendUsage({ - subscriptionCycleStart: cycleStart, - teamMemberSpend: [ - { spendCents: 2500, hardLimitOverrideDollars: 100, fastPremiumRequests: 50 }, - { spendCents: 500, hardLimitOverrideDollars: 50, fastPremiumRequests: 20 }, - ], - }); - - expect(result.windows).toHaveLength(1); - expect(result.windows[0]?.provider).toBe("cursor"); - expect(result.windows[0]?.windowType).toBe("monthly"); - expect(result.windows[0]?.percentUsed).toBe(20); - expect(result.windows[0]?.windowDurationMs).toBeGreaterThan(0); - expect(result.extraUsage?.usedCreditsUsd).toBe(30); - expect(result.extraUsage?.monthlyLimitUsd).toBe(150); - expect(result.extraUsage?.utilization).toBe(20); +describe("detectThresholdCrossings", () => { + const weeklyReset = "2099-05-15T07:00:00.000Z"; + const makeWindow = ( + provider: "claude" | "codex", + percent: number, + resetsAt = weeklyReset, + windowType: UsageWindow["windowType"] = "weekly", + ): UsageWindow => ({ + provider, + windowType, + percentUsed: percent, + resetsAt, + resetsInMs: 86_400_000, + }); + + it("fires the lowest crossed thresholds for a fresh cycle", () => { + const { events, nextState } = detectThresholdCrossings( + [makeWindow("claude", 30)], + {}, + ); + expect(events.map((e) => e.threshold)).toEqual([25]); + expect(nextState.claude?.firedThresholds).toEqual([25]); }); - it("keeps Cursor spend as extra usage when no monthly limit is configured", () => { - const result = parseCursorSpendUsage({ - teamMemberSpend: [ - { spendCents: 1250, hardLimitOverrideDollars: 0, fastPremiumRequests: 10 }, + it("uses the weekly window before monthly so threshold state is stable", () => { + const monthlyReset = "2099-06-01T07:00:00.000Z"; + const prev = { claude: { resetsAt: weeklyReset, firedThresholds: [25, 50] } }; + const { events, nextState } = detectThresholdCrossings( + [ + makeWindow("claude", 90, monthlyReset, "monthly"), + makeWindow("claude", 60, weeklyReset, "weekly"), ], - }); - - expect(result.windows).toEqual([]); - expect(result.extraUsage?.provider).toBe("cursor"); - expect(result.extraUsage?.usedCreditsUsd).toBe(12.5); - expect(result.extraUsage?.monthlyLimitUsd).toBe(0); - expect(result.extraUsage?.utilization).toBeNull(); - }); - - it("prefers overallSpendCents over on-demand spendCents when both are present", () => { - const cycleStart = Date.UTC(2026, 4, 1); - const result = parseCursorSpendUsage({ - subscriptionCycleStart: cycleStart, - teamMemberSpend: [ - { spendCents: 1000, overallSpendCents: 5000, hardLimitOverrideDollars: 100 }, - ], - }); - - expect(result.extraUsage?.usedCreditsUsd).toBe(50); - expect(result.windows[0]?.percentUsed).toBe(50); + prev, + ); + expect(events).toEqual([]); + expect(nextState.claude?.resetsAt).toBe(weeklyReset); + expect(nextState.claude?.firedThresholds).toEqual([25, 50]); }); - it("falls back to monthlyLimitDollars when no hard-limit override is set", () => { - const cycleStart = Date.UTC(2026, 4, 1); - const result = parseCursorSpendUsage({ - subscriptionCycleStart: cycleStart, - teamMemberSpend: [ - { overallSpendCents: 2500, monthlyLimitDollars: 100 }, - { overallSpendCents: 0, monthlyLimitDollars: 100 }, - ], - }); + it("fires every threshold that has been crossed at once on a cold start", () => { + const { events } = detectThresholdCrossings( + [makeWindow("claude", 80)], + {}, + ); + expect(events.map((e) => e.threshold)).toEqual([25, 50, 75]); + }); - expect(result.extraUsage?.monthlyLimitUsd).toBe(200); - expect(result.extraUsage?.usedCreditsUsd).toBe(25); - expect(result.windows[0]?.percentUsed).toBe(12.5); - }); - - it("allows quota-only Cursor member responses without spend data", async () => { - const originalFetch = globalThis.fetch; - const prevAdminKey = process.env.CURSOR_ADMIN_API_KEY; - process.env.CURSOR_ADMIN_API_KEY = "key_cursor_admin_test"; - globalThis.fetch = vi.fn(async () => ({ - ok: true, - status: 200, - json: async () => ({ - teamMemberSpend: [ - { fastPremiumRequests: 250, spendCents: 0, overallSpendCents: 0 }, - ], - }), - } as Response)); - try { - const result = await pollCursorUsage(); - expect(result.windows).toEqual([]); - expect(result.extraUsage).toBeNull(); - expect(result.errors).toEqual([]); - } finally { - globalThis.fetch = originalFetch; - if (prevAdminKey === undefined) delete process.env.CURSOR_ADMIN_API_KEY; - else process.env.CURSOR_ADMIN_API_KEY = prevAdminKey; - } + it("does not refire thresholds already recorded for the same cycle", () => { + const prev = { claude: { resetsAt: weeklyReset, firedThresholds: [25, 50] } }; + const { events, nextState } = detectThresholdCrossings( + [makeWindow("claude", 60)], + prev, + ); + expect(events).toEqual([]); + expect(nextState.claude?.firedThresholds).toEqual([25, 50]); }); - it("reports malformed Cursor spend responses with no member array", async () => { - const originalFetch = globalThis.fetch; - const prevAdminKey = process.env.CURSOR_ADMIN_API_KEY; - process.env.CURSOR_ADMIN_API_KEY = "key_cursor_admin_test"; - globalThis.fetch = vi.fn(async () => ({ - ok: true, - status: 200, - json: async () => ({ subscriptionCycleStart: Date.UTC(2026, 4, 1) }), - } as Response)); - try { - const result = await pollCursorUsage(); - expect(result.errors).toEqual(["cursor: usage response contained no recognized spend data"]); - } finally { - globalThis.fetch = originalFetch; - if (prevAdminKey === undefined) delete process.env.CURSOR_ADMIN_API_KEY; - else process.env.CURSOR_ADMIN_API_KEY = prevAdminKey; - } + it("resets fired thresholds when the cycle reset date changes", () => { + const prev = { claude: { resetsAt: "2099-05-08T07:00:00.000Z", firedThresholds: [25, 50, 75] } }; + const { events, nextState } = detectThresholdCrossings( + [makeWindow("claude", 30, weeklyReset)], + prev, + ); + expect(events.map((e) => e.threshold)).toEqual([25]); + expect(nextState.claude?.firedThresholds).toEqual([25]); }); }); @@ -709,14 +670,23 @@ describe("createUsageTrackingService", () => { const createFastDependencies = () => ({ pollClaudeUsage: vi.fn(async () => ({ windows: [] as never[], extraUsage: null, errors: [] as never[] })), pollCodexUsage: vi.fn(async () => ({ windows: [] as never[], errors: [] as never[] })), - pollCursorUsage: vi.fn(async () => ({ windows: [] as never[], extraUsage: null, errors: [] as never[] })), scanClaudeLogs: vi.fn(async () => [] as never[]), scanCodexLogs: vi.fn(async () => [] as never[]), }); + const createInMemoryThresholdStore = () => { + let state: Record = {}; + return { + load: () => ({ ...state }), + save: (next: Record) => { + state = { ...next }; + }, + }; + }; + it("returns an empty snapshot before polling", () => { const logger = createLogger(); - const service = createUsageTrackingService({ logger }); + const service = createUsageTrackingService({ logger, thresholdStore: createInMemoryThresholdStore() }); const snapshot = service.getUsageSnapshot(); expect(snapshot.windows).toEqual([]); @@ -733,12 +703,12 @@ describe("createUsageTrackingService", () => { const dependencies = createFastDependencies(); const setIntervalSpy = vi.spyOn(globalThis, "setInterval"); - const service1 = createUsageTrackingService({ logger, pollIntervalMs: 100, dependencies }); + const service1 = createUsageTrackingService({ logger, pollIntervalMs: 100, dependencies, thresholdStore: createInMemoryThresholdStore() }); service1.start(); expect(setIntervalSpy).toHaveBeenLastCalledWith(expect.any(Function), MIN_POLL_INTERVAL_MS); service1.dispose(); - const service2 = createUsageTrackingService({ logger, pollIntervalMs: 60 * 60 * 1000, dependencies }); + const service2 = createUsageTrackingService({ logger, pollIntervalMs: 60 * 60 * 1000, dependencies, thresholdStore: createInMemoryThresholdStore() }); service2.start(); expect(setIntervalSpy).toHaveBeenLastCalledWith(expect.any(Function), MAX_POLL_INTERVAL_MS); service2.dispose(); @@ -753,6 +723,7 @@ describe("createUsageTrackingService", () => { logger, onUpdate, dependencies: createFastDependencies(), + thresholdStore: createInMemoryThresholdStore(), }); const snapshot = await service.poll(); @@ -764,29 +735,25 @@ describe("createUsageTrackingService", () => { service.dispose(); }); - it("calculates pacing separately for Claude, Codex, and Cursor windows", async () => { + it("calculates pacing separately for Claude and Codex windows", async () => { const now = Date.now(); const weeklyResetMs = 3.5 * 24 * 60 * 60 * 1000; - const monthlyResetMs = 24 * 24 * 60 * 60 * 1000; const weeklyReset = new Date(now + weeklyResetMs).toISOString(); - const monthlyReset = new Date(now + monthlyResetMs).toISOString(); const windows = [ { provider: "claude" as const, windowType: "weekly" as const, percentUsed: 40, resetsAt: weeklyReset, resetsInMs: weeklyResetMs }, { provider: "codex" as const, windowType: "weekly" as const, percentUsed: 65, resetsAt: weeklyReset, resetsInMs: weeklyResetMs }, - { provider: "cursor" as const, windowType: "monthly" as const, percentUsed: 15, resetsAt: monthlyReset, resetsInMs: monthlyResetMs, windowDurationMs: 30 * 24 * 60 * 60 * 1000 }, ]; const pacing = calculatePacingByProvider(windows); expect(pacing?.claude?.status).toBe("behind"); expect(pacing?.codex?.status).toBe("far-ahead"); - expect(pacing?.cursor?.status).toBe("slightly-behind"); }); it("forceRefresh invalidates cost cache and re-polls", async () => { const logger = createLogger(); const dependencies = createFastDependencies(); - const service = createUsageTrackingService({ logger, dependencies }); + const service = createUsageTrackingService({ logger, dependencies, thresholdStore: createInMemoryThresholdStore() }); const s1 = await service.forceRefresh(); expect(s1).toBeDefined(); @@ -806,6 +773,7 @@ describe("createUsageTrackingService", () => { logger, onUpdate, dependencies: createFastDependencies(), + thresholdStore: createInMemoryThresholdStore(), }); // Should not throw @@ -820,6 +788,7 @@ describe("createUsageTrackingService", () => { const service = createUsageTrackingService({ logger, dependencies: createFastDependencies(), + thresholdStore: createInMemoryThresholdStore(), }); // Fire two polls concurrently diff --git a/apps/desktop/src/main/services/usage/usageTrackingService.ts b/apps/desktop/src/main/services/usage/usageTrackingService.ts index 0825e145f..5ec0ed562 100644 --- a/apps/desktop/src/main/services/usage/usageTrackingService.ts +++ b/apps/desktop/src/main/services/usage/usageTrackingService.ts @@ -1,7 +1,7 @@ /** * usageTrackingService.ts * - * Polls live usage data from Claude, Codex, and Cursor providers. + * Polls live usage data from Claude and Codex providers. * Scans local JSONL logs for cost/token aggregation. * Computes pacing relative to provider reset windows. */ @@ -19,6 +19,7 @@ import type { CostSnapshot, ExtraUsage, UsageSnapshot, + UsageThresholdEvent, } from "../../../shared/types"; import { isRecord, nowIso, getErrorMessage, safeJsonParse } from "../shared/utils"; import { @@ -30,8 +31,6 @@ import { readCodexCredentials, refreshClaudeCredentials, } from "../ai/providerCredentialSources"; -import { getAllApiKeys } from "../ai/apiKeyStore"; -import { isCursorAdminApiKey } from "../ai/utils"; import { resolveCodexExecutable } from "../ai/codexExecutable"; import { resolveCliSpawnInvocation, terminateProcessTree } from "../shared/processExecution"; @@ -55,7 +54,9 @@ function isBenignStdinCloseError(error: unknown): boolean { const CLAUDE_USAGE_URL = "https://api.anthropic.com/api/oauth/usage"; const CODEX_USAGE_URL = "https://chatgpt.com/backend-api/wham/usage"; -const CURSOR_SPEND_URL = "https://api.cursor.com/teams/spend"; + +const USAGE_THRESHOLDS = [25, 50, 75, 100] as const; +const TRACKED_PROVIDERS: UsageProvider[] = ["claude", "codex"]; // Per-million token prices for cost estimation const TOKEN_PRICES: Record = { @@ -260,153 +261,10 @@ function parseCodexRateLimitWindows(data: Record): UsageWindow[ return windows; } -// ── Cursor Usage Polling ───────────────────────────────────────── - -type CursorSpendMember = { - spendCents?: number; - overallSpendCents?: number; - fastPremiumRequests?: number; - hardLimitOverrideDollars?: number; - monthlyLimitDollars?: number; -}; - -type CursorSpendResponse = { - teamMemberSpend?: CursorSpendMember[]; - subscriptionCycleStart?: number; -}; - -function getCursorApiKey(): { key: string; source: "cursor-admin-env" | "cursor-env" | "cursor-api-key-store" } | null { - const adminEnvKey = process.env.CURSOR_ADMIN_API_KEY?.trim(); - if (isCursorAdminApiKey(adminEnvKey)) return { key: adminEnvKey!, source: "cursor-admin-env" }; - const envKey = process.env.CURSOR_API_KEY?.trim(); - if (isCursorAdminApiKey(envKey)) return { key: envKey!, source: "cursor-env" }; - try { - const stored = getAllApiKeys().cursor?.trim(); - if (isCursorAdminApiKey(stored)) return { key: stored!, source: "cursor-api-key-store" }; - } catch { - // The API key store can be unavailable during early startup. Treat this as - // "no key" for usage polling; provider status surfaces the store issue. - } - return null; -} - -function finiteOrZero(value: unknown): number { - return typeof value === "number" && Number.isFinite(value) ? value : 0; -} - -function addOneMonth(timestampMs: number): number { - const next = new Date(timestampMs); - if (!Number.isFinite(next.getTime())) return 0; - next.setMonth(next.getMonth() + 1); - return next.getTime(); -} - -function parseCursorSpendUsage(data: CursorSpendResponse): { - windows: UsageWindow[]; - extraUsage: ExtraUsage | null; -} { - const members = Array.isArray(data.teamMemberSpend) ? data.teamMemberSpend : []; - if (members.length === 0) return { windows: [], extraUsage: null }; - - // overallSpendCents = on-demand + included usage (the real total spend); - // spendCents alone captures only on-demand pay-as-you-go. - const memberSpendCents = (member: CursorSpendMember): number => { - if (typeof member.overallSpendCents === "number" && Number.isFinite(member.overallSpendCents)) { - return member.overallSpendCents; - } - return finiteOrZero(member.spendCents); - }; - // hardLimitOverrideDollars is the per-user override; fall back to the - // per-member default monthlyLimitDollars when no override is configured. - const memberLimitCents = (member: CursorSpendMember): number => { - const overrideDollars = finiteOrZero(member.hardLimitOverrideDollars); - if (overrideDollars > 0) return overrideDollars * 100; - const monthlyDollars = finiteOrZero(member.monthlyLimitDollars); - return monthlyDollars > 0 ? monthlyDollars * 100 : 0; - }; - - const totalSpendCents = members.reduce((sum, member) => sum + memberSpendCents(member), 0); - const totalLimitCents = members.reduce((sum, member) => sum + memberLimitCents(member), 0); - - // Cursor documents subscriptionCycleStart as epoch milliseconds. - const cycleStartMs = finiteOrZero(data.subscriptionCycleStart); - const resetMs = cycleStartMs > 0 ? addOneMonth(cycleStartMs) : 0; - const resetsAt = resetMs > 0 ? new Date(resetMs).toISOString() : ""; - const windowDurationMs = resetMs > 0 ? Math.max(0, resetMs - cycleStartMs) : undefined; - - const windows: UsageWindow[] = []; - const utilization = totalLimitCents > 0 ? Math.min(100, (totalSpendCents / totalLimitCents) * 100) : null; - if (utilization != null) { - windows.push({ - provider: "cursor", - windowType: "monthly", - percentUsed: Math.round(utilization * 10) / 10, - resetsAt, - resetsInMs: computeResetsInMs(resetsAt), - ...(windowDurationMs ? { windowDurationMs } : {}), - }); - } - - const extraUsage: ExtraUsage | null = - totalSpendCents > 0 || totalLimitCents > 0 - ? { - provider: "cursor", - isEnabled: true, - usedCreditsUsd: Math.round(totalSpendCents) / 100, - monthlyLimitUsd: Math.round(totalLimitCents) / 100, - utilization, - currency: "usd", - } - : null; - - return { windows, extraUsage }; -} - -async function pollCursorUsage(): Promise<{ windows: UsageWindow[]; extraUsage: ExtraUsage | null; errors: string[] }> { - const credential = getCursorApiKey(); - if (!credential) { - return { windows: [], extraUsage: null, errors: [] }; - } - - try { - const auth = Buffer.from(`${credential.key}:`, "utf8").toString("base64"); - const result = await fetchJson( - CURSOR_SPEND_URL, - { - Authorization: `Basic ${auth}`, - "Content-Type": "application/json", - Accept: "application/json", - }, - 15_000, - { method: "POST", body: JSON.stringify({ pageSize: 100 }) }, - ); - - if (!result.ok) { - return { - windows: [], - extraUsage: null, - errors: [`cursor: Admin API returned ${result.status}`], - }; - } - - const response = result.data as CursorSpendResponse; - if (!Array.isArray(response.teamMemberSpend)) { - return { - windows: [], - extraUsage: null, - errors: ["cursor: usage response contained no recognized spend data"], - }; - } - const parsed = parseCursorSpendUsage(response); - return { ...parsed, errors: [] }; - } catch (err) { - return { - windows: [], - extraUsage: null, - errors: [`cursor: ${getErrorMessage(err)}`], - }; - } -} +// Cursor usage polling was removed in 2026-05 — Cursor only exposes +// team-admin endpoints (/teams/spend, /teams/filtered-usage-events, +// /teams/daily-usage-data) with no personal-user surface, so the per-user +// drawer state could never be meaningful for the typical ADE user. async function pollClaudeUsage(logger: Logger): Promise<{ windows: UsageWindow[]; extraUsage: ExtraUsage | null; errors: string[] }> { const windows: UsageWindow[] = []; @@ -853,6 +711,22 @@ async function* readJsonlLines(filePath: string): AsyncGenerator { } } +function bucketDaily7d(entries: TokenEntry[], nowMs: number): number[] { + const buckets = new Array(7).fill(0); + const todayStart = new Date(nowMs); + todayStart.setHours(0, 0, 0, 0); + const todayStartMs = todayStart.getTime(); + const oldestStart = todayStartMs - 6 * 86_400_000; + for (const entry of entries) { + if (entry.timestamp < oldestStart) continue; + if (entry.timestamp > nowMs) continue; + const dayIndex = Math.floor((entry.timestamp - oldestStart) / 86_400_000); + const bucketIndex = Math.min(6, Math.max(0, dayIndex)); + buckets[bucketIndex] += entry.inputTokens + entry.outputTokens; + } + return buckets; +} + function aggregateCosts( entries: TokenEntry[], provider: UsageProvider @@ -1021,20 +895,88 @@ export type UsageTrackingService = ReturnType type UsageTrackingDependencies = { pollClaudeUsage?: () => Promise<{ windows: UsageWindow[]; extraUsage: ExtraUsage | null; errors: string[] }>; pollCodexUsage?: () => Promise<{ windows: UsageWindow[]; errors: string[] }>; - pollCursorUsage?: () => Promise<{ windows: UsageWindow[]; extraUsage: ExtraUsage | null; errors: string[] }>; scanClaudeLogs?: () => Promise; scanCodexLogs?: () => Promise; }; +type ThresholdState = Partial>; + +type ThresholdStore = { + load: () => ThresholdState; + save: (state: ThresholdState) => void; +}; + +function createFileThresholdStore(filePath: string, logger: Logger): ThresholdStore { + return { + load: () => { + try { + const raw = fs.readFileSync(filePath, "utf8"); + const parsed = safeJsonParse(raw, {}); + return parsed; + } catch { + return {}; + } + }, + save: (state) => { + try { + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, JSON.stringify(state), "utf8"); + } catch (err) { + logger.warn("usage.threshold_persist_failed", { error: getErrorMessage(err) }); + } + }, + }; +} + +function detectThresholdCrossings( + windows: UsageWindow[], + prevState: ThresholdState, +): { events: UsageThresholdEvent[]; nextState: ThresholdState } { + const nextState: ThresholdState = { ...prevState }; + const events: UsageThresholdEvent[] = []; + for (const provider of TRACKED_PROVIDERS) { + const primaryWindow = + windows.find((w) => w.provider === provider && w.windowType === "weekly") ?? + windows.find((w) => w.provider === provider && w.windowType === "monthly"); + if (!primaryWindow || !primaryWindow.resetsAt) continue; + + const prev = prevState[provider]; + const cycleChanged = !prev || prev.resetsAt !== primaryWindow.resetsAt; + const firedThresholds = cycleChanged ? [] : [...(prev?.firedThresholds ?? [])]; + + const percent = Math.max(0, Math.min(100, primaryWindow.percentUsed)); + for (const threshold of USAGE_THRESHOLDS) { + if (percent >= threshold && !firedThresholds.includes(threshold)) { + firedThresholds.push(threshold); + events.push({ + provider, + threshold, + percent, + resetsAt: primaryWindow.resetsAt, + firedAt: nowIso(), + }); + } + } + nextState[provider] = { resetsAt: primaryWindow.resetsAt, firedThresholds }; + } + return { events, nextState }; +} + export function createUsageTrackingService({ logger, pollIntervalMs: configuredInterval, onUpdate, + onThresholdEvent, + thresholdStatePath, + thresholdStore, dependencies, }: { logger: Logger; pollIntervalMs?: number; onUpdate?: (snapshot: UsageSnapshot) => void; + onThresholdEvent?: (event: UsageThresholdEvent) => void; + thresholdStatePath?: string; + thresholdStore?: ThresholdStore; dependencies?: UsageTrackingDependencies; }) { const pollIntervalMs = Math.max( @@ -1045,14 +987,22 @@ export function createUsageTrackingService({ let lastSnapshot: UsageSnapshot | null = null; let costCacheTimestamp = 0; let cachedCosts: CostSnapshot[] = []; + let cachedDaily7d: Partial> = {}; let pollTimer: ReturnType | null = null; let inFlightPoll: Promise | null = null; const runClaudeUsagePoll = dependencies?.pollClaudeUsage ?? (() => pollClaudeUsage(logger)); const runCodexUsagePoll = dependencies?.pollCodexUsage ?? (() => pollCodexUsage(logger)); - const runCursorUsagePoll = dependencies?.pollCursorUsage ?? pollCursorUsage; const scanClaudeCostLogs = dependencies?.scanClaudeLogs ?? scanClaudeLogs; const scanCodexCostLogs = dependencies?.scanCodexLogs ?? scanCodexLogs; + const resolvedThresholdStore: ThresholdStore = + thresholdStore ?? + createFileThresholdStore( + thresholdStatePath ?? path.join(os.homedir(), ".ade", "usage-thresholds.json"), + logger, + ); + let thresholdState: ThresholdState = resolvedThresholdStore.load(); + const emptySnapshot = (): UsageSnapshot => ({ windows: [], pacing: emptyPacing(), @@ -1084,7 +1034,12 @@ export function createUsageTrackingService({ if (claudeEntries.length > 0) costs.push(aggregateCosts(claudeEntries, "claude")); if (codexEntries.length > 0) costs.push(aggregateCosts(codexEntries, "codex")); + const daily7d: Partial> = {}; + if (claudeEntries.length > 0) daily7d.claude = bucketDaily7d(claudeEntries, now); + if (codexEntries.length > 0) daily7d.codex = bucketDaily7d(codexEntries, now); + cachedCosts = costs; + cachedDaily7d = daily7d; costCacheTimestamp = now; return costs; } @@ -1099,7 +1054,7 @@ export function createUsageTrackingService({ let allWindows: UsageWindow[] = []; try { - const [claudeResult, codexResult, cursorResult, costs] = await Promise.all([ + const [claudeResult, codexResult, costs] = await Promise.all([ runClaudeUsagePoll().catch((err) => { const msg = `claude: poll failed: ${getErrorMessage(err)}`; logger.warn("usage.poll.claude_failed", { error: msg }); @@ -1110,22 +1065,16 @@ export function createUsageTrackingService({ logger.warn("usage.poll.codex_failed", { error: msg }); return { windows: [] as UsageWindow[], errors: [msg] }; }), - runCursorUsagePoll().catch((err) => { - const msg = `cursor: poll failed: ${getErrorMessage(err)}`; - logger.warn("usage.poll.cursor_failed", { error: msg }); - return { windows: [] as UsageWindow[], extraUsage: null as ExtraUsage | null, errors: [msg] }; - }), pollCosts(), ]); - allWindows = [...claudeResult.windows, ...codexResult.windows, ...cursorResult.windows]; - errors.push(...claudeResult.errors, ...codexResult.errors, ...cursorResult.errors); + allWindows = [...claudeResult.windows, ...codexResult.windows]; + errors.push(...claudeResult.errors, ...codexResult.errors); const pacing = calculatePacing(allWindows); const pacingByProvider = calculatePacingByProvider(allWindows); const extraUsage: ExtraUsage[] = []; if (claudeResult.extraUsage) extraUsage.push(claudeResult.extraUsage); - if (cursorResult.extraUsage) extraUsage.push(cursorResult.extraUsage); const snapshot: UsageSnapshot = { windows: allWindows, @@ -1133,12 +1082,30 @@ export function createUsageTrackingService({ pacingByProvider, costs, extraUsage, + dailyUsage7d: { ...cachedDaily7d }, lastPolledAt: nowIso(), errors, }; lastSnapshot = snapshot; + try { + const { events, nextState } = detectThresholdCrossings(allWindows, thresholdState); + if (events.length > 0 || JSON.stringify(nextState) !== JSON.stringify(thresholdState)) { + thresholdState = nextState; + resolvedThresholdStore.save(nextState); + } + for (const event of events) { + try { + onThresholdEvent?.(event); + } catch { + // Never crash on listener error + } + } + } catch (err) { + logger.warn("usage.threshold_detection_failed", { error: getErrorMessage(err) }); + } + try { onUpdate?.(snapshot); } catch { @@ -1209,6 +1176,7 @@ export function createUsageTrackingService({ export const _testing = { MIN_POLL_INTERVAL_MS, MAX_POLL_INTERVAL_MS, + USAGE_THRESHOLDS, readClaudeCredentials, readCodexCredentials, isCodexTokenStale, @@ -1217,13 +1185,12 @@ export const _testing = { refreshClaudeCredentials, parseClaudeWindows, parseCodexRateLimitWindows, - parseCursorSpendUsage, pollClaudeUsage, pollCodexUsage, - pollCursorUsage, scanClaudeLogs, scanCodexLogs, aggregateCosts, + bucketDaily7d, calculatePacing, calculatePacingByProvider, calculatePacingForWindow, @@ -1231,4 +1198,5 @@ export const _testing = { findJsonlFiles, resolveTokenPrice, pollCodexViaCliRpc, + detectThresholdCrossings, }; diff --git a/apps/desktop/src/main/utils/terminalSessionSignals.test.ts b/apps/desktop/src/main/utils/terminalSessionSignals.test.ts index e0502cd5f..2a12214fc 100644 --- a/apps/desktop/src/main/utils/terminalSessionSignals.test.ts +++ b/apps/desktop/src/main/utils/terminalSessionSignals.test.ts @@ -20,6 +20,14 @@ describe("terminalSessionSignals", () => { expect(extractResumeCommandFromOutput(chunk, "codex")).toBe("codex resume session_abc123 --last"); }); + it("does not treat terminal CSI replies as Codex resume targets", () => { + const chunk = "codex --no-alt-screen --dangerously-bypass-approvals-and-sandbox resume \u001b[>7u"; + expect(parseTrackedCliResumeCommand(chunk, "codex")).toBeNull(); + expect(extractResumeCommandFromOutput(chunk, "codex")).toBe( + "codex --no-alt-screen --dangerously-bypass-approvals-and-sandbox resume", + ); + }); + it("respects preferred tool when both tools appear", () => { const chunk = [ "claude --resume abc", diff --git a/apps/desktop/src/main/utils/terminalSessionSignals.ts b/apps/desktop/src/main/utils/terminalSessionSignals.ts index 5ff929f17..5881731da 100644 --- a/apps/desktop/src/main/utils/terminalSessionSignals.ts +++ b/apps/desktop/src/main/utils/terminalSessionSignals.ts @@ -22,6 +22,14 @@ function commandArrayToLine(parts: string[]): string { return parts.map(shellQuote).join(" "); } +export function sanitizeResumeTargetId(value: string | null | undefined): string | null { + const target = String(value ?? "").trim(); + if (!target) return null; + if (/[\x00-\x1F\x7F]/.test(target)) return null; + if (target.startsWith("-")) return null; + return target; +} + function normalizeCommand(raw: string): string { return raw .trim() @@ -289,17 +297,23 @@ function extractWrappedProviderCommand(command: string, binary: string): string function parseProviderResumeTarget(provider: TerminalResumeProvider, command: string): string | null | undefined { if (provider === "claude") { const match = command.match(/^claude(?:(?:\s+--[^\s]+)(?:\s+[^\s]+)?)*\s+(?:--resume|-r|resume)(?:\s+([^\s]+))?(?:\s|$)/i); - return match ? match[1] ?? null : undefined; + if (!match) return undefined; + if (match[1] == null) return null; + return sanitizeResumeTargetId(match[1]) ?? undefined; } if (provider === "codex") { const match = command.match(/^codex(?:(?:\s+--no-alt-screen)|(?:\s+--full-auto)|(?:\s+--dangerously-bypass-approvals-and-sandbox)|(?:\s+--yolo)|(?:\s+--sandbox\s+[^\s]+)|(?:\s+-s\s+[^\s]+)|(?:\s+--ask-for-approval\s+[^\s]+)|(?:\s+-a\s+[^\s]+)|(?:\s+-c\s+[^\s]+))*\s+resume(?:\s+([^\s]+))?(?:\s|$)/i); - return match ? match[1] ?? null : undefined; + if (!match) return undefined; + if (match[1] == null) return null; + return sanitizeResumeTargetId(match[1]) ?? undefined; } if (provider === "cursor") { const match = command.match(/^cursor-agent\b.*?(?:--resume(?:=|\s+)([^\s]+)|--continue\b|\bresume\b)(?:\s|$)/i); - return match ? match[1] ?? null : undefined; + if (!match) return undefined; + if (match[1] == null) return null; + return sanitizeResumeTargetId(match[1]) ?? undefined; } if (provider === "droid") { @@ -307,11 +321,17 @@ function parseProviderResumeTarget(provider: TerminalResumeProvider, command: st const match = droidCommand.match( /^droid\b.*?(?:--resume(?:=|\s+)?([^;\s]+)?|-r\s+([^;\s]+)|\bexec\b.*?(?:--session-id|-s)\s+([^;\s]+))(?=\s*(?:[;&]|$))/i, ); - return match ? match[1] ?? match[2] ?? match[3] ?? null : undefined; + if (!match) return undefined; + const raw = match[1] ?? match[2] ?? match[3]; + if (raw == null) return null; + return sanitizeResumeTargetId(raw) ?? undefined; } const match = command.match(/^opencode\b.*?(?:--session(?:=|\s+)([^\s]+)|-s\s+([^\s]+)|--continue\b|-c\b)(?:\s|$)/i); - return match ? match[1] ?? match[2] ?? null : undefined; + if (!match) return undefined; + const raw = match[1] ?? match[2]; + if (raw == null) return null; + return sanitizeResumeTargetId(raw) ?? undefined; } export function parseTrackedCliResumeCommand( @@ -335,7 +355,7 @@ export function buildTrackedCliResumeCommand(metadata: TerminalResumeMetadata | if (!metadata) return null; const provider = metadata.provider; const permissionMode = metadata.launch.permissionMode ?? null; - const targetId = typeof metadata.targetId === "string" ? metadata.targetId.trim() : ""; + const targetId = sanitizeResumeTargetId(metadata.targetId) ?? ""; if (provider === "claude") { const parts = ["claude", ...permissionModeToClaudeFlag(permissionMode)]; @@ -410,7 +430,7 @@ export function defaultResumeCommandForTool(toolType: TerminalToolType | null | /** Strip ANSI escape codes so resume-command regexes can match TUI output. */ function stripAnsiCodes(text: string): string { - return text.replace(/\x1b\[[0-9;?]*[a-zA-Z]|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)|\x1b[()][A-Z0-9]|\x1b\[[\d;]*m/g, ""); + return text.replace(/\x1b\[[0-9;?=><]*[ -/]*[@-~]|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)|\x1b[()][A-Z0-9]/g, ""); } export function extractResumeCommandFromOutput( diff --git a/apps/desktop/src/preload/global.d.ts b/apps/desktop/src/preload/global.d.ts index db5d0b1be..14663c28e 100644 --- a/apps/desktop/src/preload/global.d.ts +++ b/apps/desktop/src/preload/global.d.ts @@ -150,6 +150,7 @@ import type { ReviewStartRunArgs, AdeActionRegistryEntry, UsageSnapshot, + UsageThresholdEvent, BudgetCheckResult, BudgetCapScope, BudgetCapProvider, @@ -1110,6 +1111,7 @@ declare global { getBudgetConfig: () => Promise; saveBudgetConfig: (config: BudgetCapConfig) => Promise; onUpdate: (cb: (snapshot: UsageSnapshot) => void) => () => void; + onThreshold: (cb: (event: UsageThresholdEvent) => void) => () => void; }; missions: { list: (args?: ListMissionsArgs) => Promise; diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts index 44434c939..bd12b2227 100644 --- a/apps/desktop/src/preload/preload.ts +++ b/apps/desktop/src/preload/preload.ts @@ -619,6 +619,7 @@ import type { GetAggregatedUsageArgs, AggregatedUsageStats, UsageSnapshot, + UsageThresholdEvent, BudgetCheckResult, BudgetCapScope, BudgetCapProvider, @@ -3397,6 +3398,14 @@ contextBridge.exposeInMainWorld("ade", { ipcRenderer.on(IPC.usageEvent, listener); return () => ipcRenderer.removeListener(IPC.usageEvent, listener); }, + onThreshold: (cb: (event: UsageThresholdEvent) => void) => { + const listener = ( + _event: Electron.IpcRendererEvent, + thresholdEvent: UsageThresholdEvent, + ) => cb(thresholdEvent); + ipcRenderer.on(IPC.usageThresholdEvent, listener); + return () => ipcRenderer.removeListener(IPC.usageThresholdEvent, listener); + }, }, missions: { list: async (args: ListMissionsArgs = {}): Promise => @@ -5056,9 +5065,11 @@ contextBridge.exposeInMainWorld("ade", { const runtime = await callProjectRuntimeActionIfBound< AgentChatSlashCommand[] >("chat", "getSlashCommands", { args }); - return runtime.handled - ? runtime.result - : ipcRenderer.invoke(IPC.agentChatSlashCommands, args); + if (runtime.handled) { + const result = Array.isArray(runtime.result) ? runtime.result : []; + if (result.length > 0 || args.sessionId || !args.provider) return result; + } + return ipcRenderer.invoke(IPC.agentChatSlashCommands, args); }, getClaudeMcpStatus: async ( args: AgentChatClaudeMcpStatusArgs, diff --git a/apps/desktop/src/renderer/components/app/AppShell.tsx b/apps/desktop/src/renderer/components/app/AppShell.tsx index bd24e9f07..146e4167c 100644 --- a/apps/desktop/src/renderer/components/app/AppShell.tsx +++ b/apps/desktop/src/renderer/components/app/AppShell.tsx @@ -33,7 +33,10 @@ import type { ProjectInfo, OpenProjectBinding, TerminalSessionSummary, + UsageThresholdEvent, } from "../../../shared/types"; +import { ClaudeLogo, CodexLogo } from "../terminals/ToolLogos"; +import { OPEN_USAGE_EVENT } from "../usage/HeaderUsageControl"; import { eventMatchesBinding, getEffectiveBinding, @@ -141,6 +144,37 @@ function shortId(id: string): string { return trimmed.length <= 8 ? trimmed : trimmed.slice(0, 8); } +function usageThresholdToneColor(threshold: number): string { + if (threshold >= 100) return "#EF4444"; + if (threshold >= 75) return "#F59E0B"; + return "#22C55E"; +} + +function usageProviderLabel(provider: UsageThresholdEvent["provider"]): string { + switch (provider) { + case "claude": return "Claude"; + case "codex": return "Codex"; + default: return provider; + } +} + +function usageProviderIcon(provider: UsageThresholdEvent["provider"]): typeof ClaudeLogo | null { + switch (provider) { + case "claude": return ClaudeLogo; + case "codex": return CodexLogo; + default: return null; + } +} + +function formatUsageResetCountdown(resetsAt: string, nowMs: number): string { + const resetMs = Math.max(0, new Date(resetsAt).getTime() - nowMs); + if (resetMs <= 0) return "resets now"; + const days = Math.floor(resetMs / 86_400_000); + const hours = Math.floor((resetMs % 86_400_000) / 3_600_000); + if (days > 0) return `${days}d ${hours}h until reset`; + return `${hours}h until reset`; +} + function describeGithubBanner(status: GitHubStatus): { message: string; linkLabel: string } { if (!status.tokenStored) { return { @@ -273,6 +307,16 @@ export function AppShell({ children }: { children: React.ReactNode }) { LinearWorkflowToast[] >([]); const linearToastTimersRef = useRef>(new Map()); + const [usageThresholdToasts, setUsageThresholdToasts] = useState< + Array<{ id: string; event: UsageThresholdEvent }> + >([]); + const usageToastTimersRef = useRef>(new Map()); + const dismissUsageToast = (id: string) => { + setUsageThresholdToasts((prev) => prev.filter((t) => t.id !== id)); + const timer = usageToastTimersRef.current.get(id); + if (timer != null) window.clearTimeout(timer); + usageToastTimersRef.current.delete(id); + }; const [staleCliNotice, setStaleCliNotice] = useState(null); const dismissedStaleCliNoticeKeyRef = useRef(null); @@ -700,7 +744,7 @@ export function AppShell({ children }: { children: React.ReactNode }) { return () => { cancelled = true; }; - }, [location.pathname, project?.rootPath, showWelcome]); + }, [project?.rootPath, showWelcome]); useEffect(() => { const handler = (event: Event) => { @@ -969,6 +1013,26 @@ export function AppShell({ children }: { children: React.ReactNode }) { }; }, []); + useEffect(() => { + const onThreshold = window.ade?.usage?.onThreshold; + if (typeof onThreshold !== "function") return; + const unsub = onThreshold((event) => { + const id = globalThis.crypto?.randomUUID + ? globalThis.crypto.randomUUID() + : `${Date.now()}-${Math.random()}`; + setUsageThresholdToasts((prev) => [{ id, event }, ...prev].slice(0, 4)); + const timer = window.setTimeout(() => dismissUsageToast(id), 6_000); + usageToastTimersRef.current.set(id, timer); + }); + return () => { + unsub(); + for (const timer of usageToastTimersRef.current.values()) { + window.clearTimeout(timer); + } + usageToastTimersRef.current.clear(); + }; + }, []); + const tintClass = useMemo(() => { const tintMap: Record = { "/project": "tab-tint-project", @@ -1184,7 +1248,7 @@ export function AppShell({ children }: { children: React.ReactNode }) { children )} - {staleCliNotice || prToasts.length > 0 ? ( + {staleCliNotice || prToasts.length > 0 || usageThresholdToasts.length > 0 ? (
{staleCliNotice ? (
@@ -1390,6 +1454,60 @@ export function AppShell({ children }: { children: React.ReactNode }) {
); })} + {usageThresholdToasts.map(({ id, event }) => { + const tone = usageThresholdToneColor(event.threshold); + const providerLabel = usageProviderLabel(event.provider); + const ProviderIcon = usageProviderIcon(event.provider); + const countdown = formatUsageResetCountdown(event.resetsAt, Date.now()); + return ( +
+
+
+ {ProviderIcon ? : null} +
+
+
+
+ {providerLabel} at {event.threshold}% weekly +
+ +
+
{countdown}
+
+ +
+
+
+
+ ); + })}
) : null} diff --git a/apps/desktop/src/renderer/components/app/TopBar.test.tsx b/apps/desktop/src/renderer/components/app/TopBar.test.tsx index f8a92d063..faf078181 100644 --- a/apps/desktop/src/renderer/components/app/TopBar.test.tsx +++ b/apps/desktop/src/renderer/components/app/TopBar.test.tsx @@ -22,6 +22,10 @@ vi.mock("../onboarding/HelpMenu", () => ({ HelpMenu: () => null, })); +vi.mock("../usage/HeaderUsageControl", () => ({ + HeaderUsageControl: () => null, +})); + vi.mock("../../lib/sessions", () => ({ isRunOwnedSession: () => false, })); @@ -403,6 +407,25 @@ describe("TopBar", () => { expect(await screen.findByText("1 phone connected to ADE Desktop")).toBeTruthy(); }); + it("labels disabled local runtime sync as unavailable", async () => { + const snapshot = makeSyncSnapshot(); + globalThis.window.ade.sync.getStatus = vi.fn(async () => makeSyncSnapshot({ + connectedPeers: [], + pairingConnectInfo: null, + localDevice: { + ...snapshot.localDevice, + deviceId: "local-runtime-disabled", + siteId: "local-runtime-disabled", + metadata: { unavailableReason: "local_runtime_daemon_disabled" }, + }, + })) as any; + + render(); + + expect(await screen.findByText("Phone sync unavailable")).toBeTruthy(); + expect(screen.queryByText("Phone sync ready")).toBeNull(); + }); + it("does not refresh phone sync status on an idle interval", async () => { vi.useFakeTimers(); try { diff --git a/apps/desktop/src/renderer/components/app/TopBar.tsx b/apps/desktop/src/renderer/components/app/TopBar.tsx index b40892277..b5973da0f 100644 --- a/apps/desktop/src/renderer/components/app/TopBar.tsx +++ b/apps/desktop/src/renderer/components/app/TopBar.tsx @@ -65,7 +65,49 @@ const PROJECT_ICON_CACHE_MAX = 24; const projectIconCache = new Map(); const PROJECT_ICON_ACCENT_CACHE_MAX = 48; const projectIconAccentCache = new Map(); +const RECENT_PROJECTS_CACHE_TTL_MS = 2_500; +let recentProjectsCache: + | { rows: RecentProjectSummary[]; fetchedAtMs: number } + | null = null; +let recentProjectsInFlight: Promise | null = null; +let recentProjectsCacheSource: + | (() => Promise) + | null = null; type RemoteProjectTab = Extract; + +function rememberRecentProjects(rows: RecentProjectSummary[]): void { + recentProjectsCache = { rows, fetchedAtMs: Date.now() }; +} + +function listRecentProjectsCached(options?: { + force?: boolean; +}): Promise { + const source = window.ade.project.listRecent; + if (recentProjectsCacheSource !== source) { + recentProjectsCacheSource = source; + recentProjectsCache = null; + recentProjectsInFlight = null; + } + const now = Date.now(); + if ( + !options?.force && + recentProjectsCache && + now - recentProjectsCache.fetchedAtMs < RECENT_PROJECTS_CACHE_TTL_MS + ) { + return Promise.resolve(recentProjectsCache.rows); + } + if (!options?.force && recentProjectsInFlight) return recentProjectsInFlight; + recentProjectsInFlight = window.ade.project + .listRecent() + .then((rows) => { + rememberRecentProjects(rows); + return rows; + }) + .finally(() => { + recentProjectsInFlight = null; + }); + return recentProjectsInFlight; +} function getProjectIconFromCache(rootPath: string): ProjectIcon | undefined { const cached = projectIconCache.get(rootPath); if (cached === undefined) return undefined; @@ -243,6 +285,15 @@ function confirmProjectTabRemoval(projectName: string): boolean { function deriveSyncLabel(snapshot: SyncRoleSnapshot | null): string | null { if (!snapshot) return null; + const unavailableReason = typeof snapshot.localDevice.metadata?.unavailableReason === "string" + ? snapshot.localDevice.metadata.unavailableReason + : null; + if ( + unavailableReason === "local_runtime_daemon_disabled" + || snapshot.localDevice.deviceId === "local-runtime-disabled" + ) { + return "Phone sync unavailable"; + } if (snapshot.client.state === "error") return "Phone sync error"; if (snapshot.role === "brain") { const count = snapshot.connectedPeers.length; @@ -650,15 +701,14 @@ export function TopBar() { const zoomIn = useCallback(() => applyZoom(zoom + 10), [applyZoom, zoom]); const zoomOut = useCallback(() => applyZoom(zoom - 10), [applyZoom, zoom]); - const fetchRecent = useCallback(() => { - window.ade.project - .listRecent() + const fetchRecent = useCallback((options?: { force?: boolean }) => { + listRecentProjectsCached(options) .then((rows) => setRecentProjects(rows)) .catch(() => {}); }, []); useEffect(() => { - fetchRecent(); + fetchRecent({ force: true }); }, [project?.rootPath, fetchRecent]); useEffect(() => { @@ -795,7 +845,7 @@ export function TopBar() { // Re-fetch when the main process reports a missing project. useEffect(() => { - const unsub = window.ade.project.onMissing(() => fetchRecent()); + const unsub = window.ade.project.onMissing(() => fetchRecent({ force: true })); return unsub; }, [fetchRecent]); @@ -1056,7 +1106,10 @@ export function TopBar() { const nextRows = await window.ade.project .forgetRecent(oldPath) .catch(() => null); - if (nextRows) setRecentProjects(nextRows); + if (nextRows) { + rememberRecentProjects(nextRows); + setRecentProjects(nextRows); + } })() .catch(() => {}) .finally(() => setRelocatingPath(null)); @@ -1735,6 +1788,9 @@ export function TopBar() { ) : null} + {/* Trailing groups: status · actions · view, gap-6 between, gap-2 within */} +
+
+ {/* /status group */} + +
+ + + + + +
+ {/* /actions group */} + + {/* Zoom controls (view group) */} +
+ + + {zoom}% + + +
+
+ {/* /trailing groups */} + + {/* Overlay panels & modals — kept outside the gap-6 wrapper so they + never participate in flex gap accounting when toggled open. */} {remotePanelOpen ? (
) : null} - - - - - - - - - - {/* Zoom controls */} -
- - - {zoom}% - - -
); } diff --git a/apps/desktop/src/renderer/components/automations/adeActionSchemas.ts b/apps/desktop/src/renderer/components/automations/adeActionSchemas.ts index 5f2ba9148..0250fb53e 100644 --- a/apps/desktop/src/renderer/components/automations/adeActionSchemas.ts +++ b/apps/desktop/src/renderer/components/automations/adeActionSchemas.ts @@ -1117,8 +1117,12 @@ export const ADE_ACTION_SCHEMAS: readonly AdeActionSchema[] = [ domain: "chat", action: "getSlashCommands", label: "Get slash commands", - description: "Return the slash commands available in a chat session.", - params: [{ name: "sessionId", type: "string", required: true }], + description: "Return the slash commands available in a chat session or draft lane/provider.", + params: [ + { name: "sessionId", type: "string" }, + { name: "laneId", type: "string" }, + { name: "provider", type: "string" }, + ], }, { domain: "chat", diff --git a/apps/desktop/src/renderer/components/chat/AgentChatComposer.test.tsx b/apps/desktop/src/renderer/components/chat/AgentChatComposer.test.tsx index 85c60fd18..8867c9ca8 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatComposer.test.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatComposer.test.tsx @@ -226,7 +226,7 @@ describe("AgentChatComposer", () => { it("selects a slash command from the command picker", async () => { const onDraftChange = vi.fn(); - renderComposer({ + const { container } = renderComposer({ turnActive: false, draft: "", onDraftChange, @@ -238,11 +238,78 @@ describe("AgentChatComposer", () => { }); fireEvent.click(screen.getByLabelText("Open command picker")); - fireEvent.click(await screen.findByText("/status")); + const statusCommand = await screen.findByText("/status"); + const menu = statusCommand.closest(".ade-chat-drawer-glass"); + const composerShell = container.querySelector("[data-chat-composer-mode]"); + expect(menu?.className).toContain("fixed"); + expect(menu?.parentElement).toBe(document.body); + expect(composerShell?.contains(menu)).toBe(false); + expect((menu as HTMLElement | null)?.style.width).toBe("420px"); + fireEvent.click(statusCommand); expect(onDraftChange).toHaveBeenCalledWith("/status "); }); + it("shows slash commands when typing a leading slash", async () => { + renderComposer({ + turnActive: false, + draft: "", + sdkSlashCommands: [{ + name: "status", + description: "Summarize current state", + source: "sdk", + }], + }); + + fireEvent.change(screen.getByRole("textbox"), { + target: { value: "/st", selectionStart: 3 }, + }); + + expect(await screen.findByText("/status")).toBeTruthy(); + }); + + it("shows a slash command hint for a bare slash before commands are available", async () => { + const { container } = renderComposer({ + turnActive: false, + draft: "", + sdkSlashCommands: [], + }); + + fireEvent.change(screen.getByRole("textbox"), { + target: { value: "/", selectionStart: 1 }, + }); + + const hint = await screen.findByText("Type to search commands"); + const menu = hint.closest(".ade-chat-drawer-glass"); + const composerShell = container.querySelector("[data-chat-composer-mode]"); + expect(menu?.parentElement).toBe(document.body); + expect(composerShell?.contains(menu)).toBe(false); + }); + + it("shows file matches when typing an at-command", async () => { + const fileSearch = vi.fn().mockResolvedValue([{ path: "src/App.tsx" }]); + (window as any).ade = { + agentChat: { + fileSearch, + }, + }; + + renderComposer({ + turnActive: false, + draft: "", + sessionId: "session-1", + }); + + fireEvent.change(screen.getByRole("textbox"), { + target: { value: "@src", selectionStart: 4 }, + }); + + await waitFor(() => { + expect(fileSearch).toHaveBeenCalledWith({ sessionId: "session-1", query: "src" }); + }); + expect(await screen.findByText("App.tsx")).toBeTruthy(); + }); + it("dismisses an attachment error from the composer preview row", async () => { const view = renderComposer({ turnActive: false, diff --git a/apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx b/apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx index d6e7f5102..d4449a245 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx @@ -136,6 +136,18 @@ type SlashCommandEntry = { source: "sdk" | "local"; }; +type CommandMenuAnchor = { top: number; left: number; bottom: number }; + +function getCommandMenuAnchor(element: HTMLElement | null): CommandMenuAnchor | null { + if (!element) return null; + const rect = element.getBoundingClientRect(); + return { + top: rect.top, + bottom: rect.bottom, + left: rect.left + 16, + }; +} + function iosMetadataRecord(value: unknown): Record | null { return value && typeof value === "object" && !Array.isArray(value) ? value as Record : null; } @@ -978,7 +990,7 @@ export function AgentChatComposer({ const issueContextButtonRef = useRef(null); const [dragActive, setDragActive] = useState(false); const [commandMenuTrigger, setCommandMenuTrigger] = useState<{ type: "at" | "slash"; query: string; cursorIndex: number } | null>(null); - const [commandMenuAnchor, setCommandMenuAnchor] = useState<{ top: number; left: number } | null>(null); + const [commandMenuAnchor, setCommandMenuAnchor] = useState(null); const commandMenuRef = useRef(null); const attachmentInputRef = useRef(null); @@ -2382,14 +2394,14 @@ export function AgentChatComposer({ if (!editor) return; const val = serializeRichEditor(); onDraftChange(val); - const rect = editor.getBoundingClientRect(); + const anchor = getCommandMenuAnchor(editor); if (val.startsWith("/") && !val.slice(1).includes("\n")) { const afterSlash = val.slice(1); if (!/\s/.test(afterSlash)) { const query = afterSlash.match(/^[^\s/]*/)?.[0] ?? ""; setCommandMenuTrigger({ type: "slash", query, cursorIndex: 0 }); - setCommandMenuAnchor({ top: rect.top - 8, left: rect.left + 16 }); + if (anchor) setCommandMenuAnchor(anchor); captureRichSelection(); return; } @@ -2403,7 +2415,7 @@ export function AgentChatComposer({ const atMatch = textBeforeCursor.match(/@([^\s@]*)$/); if (atMatch) { setCommandMenuTrigger({ type: "at", query: atMatch[1], cursorIndex: cursorPos - atMatch[0].length }); - setCommandMenuAnchor({ top: rect.top - 8, left: rect.left + 16 }); + if (anchor) setCommandMenuAnchor(anchor); } else { setCommandMenuTrigger(null); } @@ -3296,13 +3308,13 @@ export function AgentChatComposer({ const currentDraft = useRichComposer ? serializeRichEditor() : el?.value ?? ""; if (!currentDraft.length) onDraftChange("/"); if (useRichComposer && !currentDraft.length) setRichEditorText("/"); - const rect = (useRichComposer ? richEl : el)?.getBoundingClientRect(); setCommandMenuTrigger({ type: "slash", query: currentDraft.startsWith("/") ? currentDraft.slice(1).match(/^[^\s/]*/)?.[0] ?? "" : "", cursorIndex: 0, }); - if (rect) setCommandMenuAnchor({ top: rect.top - 8, left: rect.left + 16 }); + const anchor = getCommandMenuAnchor(useRichComposer ? richEl : el); + if (anchor) setCommandMenuAnchor(anchor); (useRichComposer ? richEl : el)?.focus(); }} aria-label="Open command picker" @@ -3667,7 +3679,7 @@ export function AgentChatComposer({ const val = event.target.value; onDraftChange(val); lastPlainSelectionRef.current = event.target.selectionStart ?? val.length; - const rect = event.target.getBoundingClientRect(); + const anchor = getCommandMenuAnchor(event.currentTarget); if (val.startsWith("/") && !val.slice(1).includes("\n")) { // Once the user types a space after the command name they have @@ -3678,7 +3690,7 @@ export function AgentChatComposer({ if (!/\s/.test(afterSlash)) { const query = afterSlash.match(/^[^\s/]*/)?.[0] ?? ""; setCommandMenuTrigger({ type: "slash", query, cursorIndex: 0 }); - setCommandMenuAnchor({ top: rect.top - 8, left: rect.left + 16 }); + if (anchor) setCommandMenuAnchor(anchor); return; } setCommandMenuTrigger(null); @@ -3691,7 +3703,7 @@ export function AgentChatComposer({ const atMatch = textBeforeCursor.match(/@([^\s@]*)$/); if (atMatch) { setCommandMenuTrigger({ type: "at", query: atMatch[1], cursorIndex: cursorPos - atMatch[0].length }); - setCommandMenuAnchor({ top: rect.top - 8, left: rect.left + 16 }); + if (anchor) setCommandMenuAnchor(anchor); } else { setCommandMenuTrigger(null); } diff --git a/apps/desktop/src/renderer/components/chat/AgentChatPane.submit.test.tsx b/apps/desktop/src/renderer/components/chat/AgentChatPane.submit.test.tsx index 3cb6c4c70..77d2e864b 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatPane.submit.test.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatPane.submit.test.tsx @@ -412,6 +412,40 @@ function expectSessionTabOrder(expectedTitles: string[]) { } describe("AgentChatPane submit recovery", () => { + 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) => { + if (args.provider === "claude") { + return [{ + name: "/agents", + description: "Manage agent configurations.", + source: "sdk", + }]; + } + return []; + }); + + renderParallelDraftPane({ + availableModelIdsOverride: ["anthropic/claude-sonnet-4-6"], + }); + + const modelTrigger = await screen.findByRole("button", { name: "Select model" }); + fireEvent.click(modelTrigger); + fireEvent.click(await screen.findByRole("button", { name: /^Claude$/i })); + await clickEnabledModelOption(/Claude Sonnet 4\.6/i); + + await waitFor(() => { + expect(window.ade.agentChat.slashCommands).toHaveBeenCalledWith({ + laneId: "lane-1", + provider: "claude", + }); + }); + + fireEvent.click(await screen.findByLabelText("Open command picker")); + + expect(await screen.findByText("/agents")).toBeTruthy(); + }); + it("opens the chat terminal drawer when a CLI-created terminal belongs to the active chat", async () => { const session = buildSession("session-1", { status: "idle" }); const { emitSessionChanged } = installAdeMocks({ sessions: [session] }); diff --git a/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx b/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx index ff12df4bd..02203f3d0 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx @@ -3594,15 +3594,19 @@ export function AgentChatPane({ optimisticOutgoingMessageRef.current = optimisticOutgoingMessage; }, [optimisticOutgoingMessage]); - // Fetch SDK slash commands when session changes + // Fetch provider slash commands when session, lane, or draft provider changes. useEffect(() => { - if (!selectedSessionId || !isTileActive) { setSdkSlashCommands([]); return; } + if (!isTileActive) { setSdkSlashCommands([]); return; } + if (!selectedSessionId && !laneId) { setSdkSlashCommands([]); return; } let cancelled = false; - window.ade.agentChat.slashCommands({ sessionId: selectedSessionId }) + const args = selectedSessionId + ? { sessionId: selectedSessionId } + : { laneId, provider: sessionProvider }; + window.ade.agentChat.slashCommands(args) .then((cmds) => { if (!cancelled) setSdkSlashCommands(cmds); }) .catch(() => { if (!cancelled) setSdkSlashCommands([]); }); return () => { cancelled = true; }; - }, [isTileActive, selectedSessionId]); + }, [isTileActive, laneId, selectedSessionId, sessionProvider]); // Fetch git diff stats when the session changes or a turn completes useEffect(() => { diff --git a/apps/desktop/src/renderer/components/chat/ChatCommandMenu.tsx b/apps/desktop/src/renderer/components/chat/ChatCommandMenu.tsx index 7692e3f5c..ce87e1243 100644 --- a/apps/desktop/src/renderer/components/chat/ChatCommandMenu.tsx +++ b/apps/desktop/src/renderer/components/chat/ChatCommandMenu.tsx @@ -6,7 +6,9 @@ import { useMemo, useRef, useState, + type CSSProperties, } from "react"; +import { createPortal } from "react-dom"; import { AnimatePresence, motion } from "motion/react"; import { Command, File, MagnifyingGlass, SpinnerGap } from "@phosphor-icons/react"; import { cn } from "../ui/cn"; @@ -32,8 +34,8 @@ type ChatCommandMenuProps = { slashCommands: Array<{ name: string; description: string; argumentHint?: string; source?: "sdk" | "local" }>; /** Session ID for file search. */ sessionId: string | null; - /** Anchor position: { top, left } relative to the container. */ - anchor: { top: number; left: number } | null; + /** Anchor position in viewport coordinates. */ + anchor: { top: number; left: number; bottom?: number } | null; /** Called when user selects an item. */ onSelect: (item: ChatCommandMenuItem) => void; /** Called when menu should close. */ @@ -71,8 +73,39 @@ function splitPath(filePath: string): { dir: string; base: string } { const MAX_FILE_RESULTS = 8; const MAX_COMMAND_RESULTS = 10; +const MENU_WIDTH = 420; +const MENU_HEIGHT = 328; +const VIEWPORT_GUTTER = 8; +const MENU_GAP = 8; const DEBOUNCE_MS = 300; +function getViewportMenuStyle(anchor: NonNullable): CSSProperties { + const viewportWidth = typeof window === "undefined" ? MENU_WIDTH + VIEWPORT_GUTTER * 2 : window.innerWidth; + const viewportHeight = typeof window === "undefined" ? MENU_HEIGHT + VIEWPORT_GUTTER * 2 : window.innerHeight; + const width = Math.max(260, Math.min(MENU_WIDTH, viewportWidth - VIEWPORT_GUTTER * 2)); + const maxLeft = Math.max(VIEWPORT_GUTTER, viewportWidth - width - VIEWPORT_GUTTER); + const left = Math.min(Math.max(VIEWPORT_GUTTER, anchor.left), maxLeft); + const anchorBottom = typeof anchor.bottom === "number" ? anchor.bottom : anchor.top; + const roomAbove = Math.max(0, anchor.top - VIEWPORT_GUTTER); + const roomBelow = Math.max(0, viewportHeight - anchorBottom - VIEWPORT_GUTTER); + + if (roomAbove >= MENU_HEIGHT || roomAbove >= roomBelow) { + return { + left, + width, + bottom: Math.max(VIEWPORT_GUTTER, viewportHeight - anchor.top + MENU_GAP), + maxHeight: Math.max(160, Math.min(MENU_HEIGHT, roomAbove - MENU_GAP)), + }; + } + + return { + left, + width, + top: Math.min(viewportHeight - VIEWPORT_GUTTER, anchorBottom + MENU_GAP), + maxHeight: Math.max(160, Math.min(MENU_HEIGHT, roomBelow - MENU_GAP)), + }; +} + export const ChatCommandMenu = forwardRef( function ChatCommandMenu({ trigger, slashCommands, sessionId, anchor, onSelect, onClose }, ref) { const [selectedIndex, setSelectedIndex] = useState(0); @@ -198,8 +231,9 @@ export const ChatCommandMenu = forwardRef {visible && ( {/* Header hint */}
@@ -293,7 +327,10 @@ export const ChatCommandMenu = forwardRef handleSelect(i)} > - /{item.name} + /{item.name} {command?.argumentHint ? ( {command.argumentHint} ) : null} @@ -308,6 +345,8 @@ export const ChatCommandMenu = forwardRef ); + + return typeof document === "undefined" ? menu : createPortal(menu, document.body); }, ); diff --git a/apps/desktop/src/renderer/components/lanes/laneDesignTokens.ts b/apps/desktop/src/renderer/components/lanes/laneDesignTokens.ts index 4d42bcefb..0732e6ca0 100644 --- a/apps/desktop/src/renderer/components/lanes/laneDesignTokens.ts +++ b/apps/desktop/src/renderer/components/lanes/laneDesignTokens.ts @@ -86,6 +86,7 @@ export function inlineBadge(color: string, overrides?: CSSProperties): CSSProper export function laneSurfaceTint( color: string | null | undefined, strength: "soft" | "default" = "default", + alpha?: number, ): { background: string; border: string; @@ -101,7 +102,9 @@ export function laneSurfaceTint( }; } const c = String(color).trim(); - const p = strength === "soft" ? 10 : 16; + const p = alpha != null && Number.isFinite(alpha) + ? Math.max(0, Math.min(100, Math.round(alpha * 100))) + : strength === "soft" ? 10 : 16; return { background: `color-mix(in srgb, ${c} ${p}%, rgba(10, 10, 12, 0.65))`, border: `1px solid color-mix(in srgb, ${c} 28%, rgba(255, 255, 255, 0.06))`, diff --git a/apps/desktop/src/renderer/components/lanes/useLaneWorkSessions.ts b/apps/desktop/src/renderer/components/lanes/useLaneWorkSessions.ts index 1f73b2d35..c5ce3755a 100644 --- a/apps/desktop/src/renderer/components/lanes/useLaneWorkSessions.ts +++ b/apps/desktop/src/renderer/components/lanes/useLaneWorkSessions.ts @@ -29,6 +29,8 @@ const EMPTY_WORK_STATE: WorkProjectViewState = { workSidebarOpen: false, workSidebarTab: "git", workSidebarWidthPct: 36, + laneSessionOrder: {}, + pinnedSessionIds: [], }; type QueuedRefresh = { diff --git a/apps/desktop/src/renderer/components/settings/SyncDevicesSection.tsx b/apps/desktop/src/renderer/components/settings/SyncDevicesSection.tsx index 41bc3bd98..7dd3ef2dc 100644 --- a/apps/desktop/src/renderer/components/settings/SyncDevicesSection.tsx +++ b/apps/desktop/src/renderer/components/settings/SyncDevicesSection.tsx @@ -270,7 +270,11 @@ export function SyncDevicesSection() { return (
- 0} peerCount={peerCount} /> + 0} + peerCount={peerCount} + ready={isPhoneSyncReady(status)} + /> {isLocalHost ? ( -
+
Runtime address
-
+
{primaryEndpoint}
@@ -638,7 +670,7 @@ function EndpointList({ connectInfo }: { connectInfo: SyncPairingConnectInfo | n {addressKindLabel(candidate.kind)} - + {formatEndpoint(candidate.host, connectInfo?.port ?? 8787)}
diff --git a/apps/desktop/src/renderer/components/terminals/LaneChip.tsx b/apps/desktop/src/renderer/components/terminals/LaneChip.tsx new file mode 100644 index 000000000..95107bb21 --- /dev/null +++ b/apps/desktop/src/renderer/components/terminals/LaneChip.tsx @@ -0,0 +1,74 @@ +import type { ButtonHTMLAttributes, HTMLAttributes } from "react"; +import { cn } from "../ui/cn"; + +export type LaneChipProps = { + laneName: string; + laneColor?: string | null; + maxWidth?: number; + className?: string; + onClick?: () => void; +} & Omit, "onClick" | "children">; + +export function LaneChip({ + laneName, + laneColor, + maxWidth = 140, + className, + onClick, + style, + ...rest +}: LaneChipProps) { + const hasColor = laneColor != null && String(laneColor).trim() !== ""; + const color = hasColor ? String(laneColor).trim() : null; + const background = color + ? `color-mix(in srgb, ${color} 85%, rgba(10,10,12,0.4))` + : "color-mix(in srgb, var(--color-fg) 6%, transparent)"; + const border = color + ? `1px solid color-mix(in srgb, ${color} 55%, rgba(255,255,255,0.08))` + : "1px solid color-mix(in srgb, var(--color-border) 70%, transparent)"; + const textColor = color ? "rgba(255,255,255,0.96)" : "var(--color-fg)"; + const chipClassName = cn( + "inline-flex items-center text-[11px] font-medium leading-none rounded-full select-none transition-opacity", + onClick ? "cursor-pointer hover:opacity-85" : null, + className, + ); + const chipStyle = { + maxWidth, + padding: "3px 8px", + background, + border, + color: textColor, + ...style, + }; + const label = ( + + {laneName} + + ); + + if (onClick) { + return ( + + ); + } + + return ( + + {label} + + ); +} diff --git a/apps/desktop/src/renderer/components/terminals/ProviderChip.tsx b/apps/desktop/src/renderer/components/terminals/ProviderChip.tsx new file mode 100644 index 000000000..21e96c4b0 --- /dev/null +++ b/apps/desktop/src/renderer/components/terminals/ProviderChip.tsx @@ -0,0 +1,55 @@ +import type { HTMLAttributes } from "react"; +import { PROVIDER_BADGE_COLORS, PROVIDER_GROUP_COLORS } from "../../../shared/modelCatalog"; +import type { TerminalToolType } from "../../../shared/types"; +import type { AgentChatProvider } from "../../../shared/types/chat"; +import { chatToolTypeForProvider } from "../../lib/sessions"; +import { cn } from "../ui/cn"; +import { ToolLogo } from "./ToolLogos"; + +type ProviderChipSize = "sm" | "md"; + +export type ProviderChipProps = { + provider: AgentChatProvider | string; + toolType?: TerminalToolType | null; + size?: ProviderChipSize; + className?: string; +} & Omit, "children">; + +const SIZE_PX: Record = { sm: 18, md: 22 }; + +function resolveProviderColor(provider: string): string { + return ( + PROVIDER_BADGE_COLORS[provider] + ?? PROVIDER_GROUP_COLORS[provider as keyof typeof PROVIDER_GROUP_COLORS] + ?? "#6B7280" + ); +} + +export function ProviderChip({ + provider, + toolType: explicitToolType, + size = "md", + className, + style, + ...rest +}: ProviderChipProps) { + const diameter = SIZE_PX[size]; + const glyph = Math.round(diameter * 0.6); + const bg = resolveProviderColor(String(provider)); + const toolType = explicitToolType ?? chatToolTypeForProvider(provider); + return ( + + + + ); +} diff --git a/apps/desktop/src/renderer/components/terminals/SessionCard.tsx b/apps/desktop/src/renderer/components/terminals/SessionCard.tsx index 76effdb04..246e7041f 100644 --- a/apps/desktop/src/renderer/components/terminals/SessionCard.tsx +++ b/apps/desktop/src/renderer/components/terminals/SessionCard.tsx @@ -72,6 +72,13 @@ export const SessionCard = React.memo(function SessionCard({ ? "1px solid rgba(255,255,255,0.08)" : "1px solid transparent"; const laneAccent = lane?.color ?? null; + const useLaneGlow = Boolean(isHighlighted && laneAccent); + const laneTint = useLaneGlow + ? `color-mix(in srgb, ${laneAccent} 14%, transparent)` + : null; + const laneRing = useLaneGlow + ? `color-mix(in srgb, ${laneAccent} 32%, transparent)` + : null; const showClaudeCacheTimer = shouldShowClaudeCacheTtl({ provider: session.toolType === "claude-chat" ? "claude" : null, status: session.runtimeState === "idle" ? "idle" : "active", @@ -85,15 +92,24 @@ export const SessionCard = React.memo(function SessionCard({ type="button" className={cn( "relative w-full overflow-hidden text-left transition-all duration-100 rounded-lg border-l-2", - isHighlighted - ? "border-l-accent bg-white/[0.06] hover:bg-white/[0.07]" - : "border-l-transparent bg-transparent hover:bg-white/[0.03]", + useLaneGlow + ? "border-l-transparent" + : isHighlighted + ? "border-l-accent bg-white/[0.06] hover:bg-white/[0.07]" + : "border-l-transparent bg-transparent hover:bg-white/[0.03]", isMultiSelected && "ring-1 ring-accent/35", )} style={{ borderTop: highlightedBorder, borderRight: highlightedBorder, borderBottom: highlightedBorder, + ...(useLaneGlow + ? { + background: laneTint ?? undefined, + boxShadow: `inset 0 0 0 1px ${laneRing}`, + borderLeftColor: laneAccent ?? undefined, + } + : {}), }} onClick={(event) => onSelect(session.id, event)} > diff --git a/apps/desktop/src/renderer/components/terminals/SessionContextMenu.tsx b/apps/desktop/src/renderer/components/terminals/SessionContextMenu.tsx index 7651f29de..a6ed76c1d 100644 --- a/apps/desktop/src/renderer/components/terminals/SessionContextMenu.tsx +++ b/apps/desktop/src/renderer/components/terminals/SessionContextMenu.tsx @@ -22,6 +22,9 @@ type SessionContextMenuProps = { onGoToLane: (session: TerminalSessionSummary) => void; onCopySessionId: (id: string) => void; onRename: (session: TerminalSessionSummary, newTitle: string) => void; + onCopySessionDeepLink?: (session: TerminalSessionSummary) => void; + onTogglePinned?: (session: TerminalSessionSummary) => void; + pinnedSessionIds?: string[]; }; export function SessionContextMenu({ @@ -37,6 +40,9 @@ export function SessionContextMenu({ onGoToLane, onCopySessionId, onRename, + onCopySessionDeepLink, + onTogglePinned, + pinnedSessionIds, }: SessionContextMenuProps) { const [renaming, setRenaming] = useState(false); const [draft, setDraft] = useState(""); @@ -221,6 +227,24 @@ export function SessionContextMenu({ > Copy session ID + + {onCopySessionDeepLink ? ( + + ) : null} + + {onTogglePinned ? ( + + ) : null}
); diff --git a/apps/desktop/src/renderer/components/terminals/TerminalsPage.tsx b/apps/desktop/src/renderer/components/terminals/TerminalsPage.tsx index 0977b2518..39f4aaff0 100644 --- a/apps/desktop/src/renderer/components/terminals/TerminalsPage.tsx +++ b/apps/desktop/src/renderer/components/terminals/TerminalsPage.tsx @@ -542,6 +542,7 @@ export function TerminalsPage({ active = true }: { active?: boolean }) { const workViewArea = useMemo( () => ( { + work.setViewMode("tabs"); + work.openSessionTab(sessionId); + work.setActiveItemId(sessionId); + }} + onGoToLane={work.selectLane} /> ), [ sortedLanes, + active, work.gridLayoutId, work.sessions, work.visibleSessions, @@ -597,6 +606,9 @@ export function TerminalsPage({ active = true }: { active?: boolean }) { handleOpenChatSession, handleResumeSession, handleContextMenu, + work.reorderLaneSessions, + work.openSessionTab, + work.selectLane, ], ); @@ -624,6 +636,7 @@ export function TerminalsPage({ active = true }: { active?: boolean }) { style={{ flexGrow: work.workSidebarWidthPct, maxWidth: "55%" }} > navigator.clipboard.writeText(cmd).catch(() => {})} onGoToLane={handleGoToLane} onCopySessionId={(id) => navigator.clipboard.writeText(id).catch(() => {})} + onCopySessionDeepLink={(session) => { + const href = `/work?laneId=${encodeURIComponent(session.laneId)}&sessionId=${encodeURIComponent(session.id)}`; + navigator.clipboard.writeText(href).catch(() => {}); + }} + onTogglePinned={(session) => work.togglePinnedSession(session.id)} + pinnedSessionIds={work.pinnedSessionIds} onRename={(session, newTitle) => { setSessionActionError(null); const renamePromise = isChatToolType(session.toolType) diff --git a/apps/desktop/src/renderer/components/terminals/WorkSidebar.tsx b/apps/desktop/src/renderer/components/terminals/WorkSidebar.tsx index 2c0dc16fc..731e3f48c 100644 --- a/apps/desktop/src/renderer/components/terminals/WorkSidebar.tsx +++ b/apps/desktop/src/renderer/components/terminals/WorkSidebar.tsx @@ -80,6 +80,7 @@ function WarningBanner({ message }: { message: string }) { } export function WorkSidebar({ + active = true, laneId, lanes, activeSession, @@ -89,6 +90,7 @@ export function WorkSidebar({ attachChatSessionId, attachDisabledReason, }: { + active?: boolean; laneId: string | null; lanes: LaneSummary[]; activeSession: TerminalSessionSummary | null; @@ -135,15 +137,16 @@ export function WorkSidebar({ const previousBrowserTabRef = useRef(tab === "browser"); useEffect(() => { const wasBrowser = previousBrowserTabRef.current; - const isBrowser = tab === "browser"; + const isBrowser = active && tab === "browser"; if (wasBrowser && !isBrowser) hideBuiltInBrowserView(); previousBrowserTabRef.current = isBrowser; return () => { if (previousBrowserTabRef.current) hideBuiltInBrowserView(); }; - }, [tab]); + }, [active, tab]); useEffect(() => { + if (!active) return undefined; if (tab !== "app-control") return undefined; let cancelled = false; void window.ade.appControl.getStatus() @@ -164,9 +167,10 @@ export function WorkSidebar({ cancelled = true; unsubscribe(); }; - }, [tab]); + }, [active, tab]); useEffect(() => { + if (!active) return undefined; if (tab !== "ios") return undefined; let cancelled = false; void window.ade.iosSimulator.getStatus() @@ -187,7 +191,7 @@ export function WorkSidebar({ cancelled = true; unsubscribe(); }; - }, [tab]); + }, [active, tab]); function resolveLaneMismatchReason(): string | null { if (!laneId) return null; @@ -233,6 +237,7 @@ export function WorkSidebar({ }, [dispatchToChat]); const content = useMemo(() => { + if (!active) return null; if (tab === "browser") { return (
@@ -364,6 +369,7 @@ export function WorkSidebar({ selectedCommit, selectedMode, selectedPath, + active, tab, ]); diff --git a/apps/desktop/src/renderer/components/terminals/WorkViewArea.test.tsx b/apps/desktop/src/renderer/components/terminals/WorkViewArea.test.tsx index 471c2c3c4..8365e462b 100644 --- a/apps/desktop/src/renderer/components/terminals/WorkViewArea.test.tsx +++ b/apps/desktop/src/renderer/components/terminals/WorkViewArea.test.tsx @@ -106,10 +106,12 @@ vi.mock("../../lib/sessions", () => ({ secondarySessionLabel: vi.fn(() => null), truncateSessionLabel: vi.fn((label: string) => label), formatToolTypeLabel: vi.fn((toolType: string | null | undefined) => toolType ?? "Tool"), + chatToolTypeForProvider: vi.fn(() => "opencode-chat"), })); vi.mock("../../lib/terminalAttention", () => ({ sessionStatusDot: vi.fn(() => ({ cls: "ade-status-dot", label: "Idle", spinning: false })), + sessionStatusBucket: vi.fn(() => "running"), })); function makeSession(): TerminalSessionSummary { @@ -281,7 +283,7 @@ describe("WorkViewArea", () => { const session = makeSession(); const onCloseItem = vi.fn(); - render( + const view = render( { ); const closeTabs = Array.from( - document.querySelectorAll('[data-close-tab-session-id="session-1"]'), + view.container.querySelectorAll('[data-close-tab-session-id="session-1"]'), ); expect(closeTabs.length).toBeGreaterThan(0); const closeTab = closeTabs.find((node) => node.closest('[role="tab"]')) ?? closeTabs[closeTabs.length - 1]!; @@ -609,6 +611,57 @@ describe("WorkViewArea", () => { expect(secondTile?.getAttribute("data-tile-visible")).toBe("true"); }); + it("keeps chat panes mounted but inactive while the Work page is parked", () => { + vi.mocked(isChatToolType).mockImplementation((toolType) => toolType === "codex-chat"); + const session = makeChatSession("chat-1"); + + const view = render( + {}} + onSelectItem={() => {}} + onCloseItem={() => {}} + onOpenChatSession={() => {}} + onLaunchPtySession={async () => ({})} + onShowDraftKind={() => {}} + onToggleTabGroupCollapsed={() => {}} + closingPtyIds={new Set()} + />, + ); + + const pane = within(view.container).getByTestId("agent-chat-pane"); + expect(pane.getAttribute("data-tile-active")).toBe("false"); + expect(pane.getAttribute("data-tile-visible")).toBe("false"); + expect(chatPaneLifecycle.mounts.get("chat-1")).toBe(1); + expect(chatPaneLifecycle.unmounts.get("chat-1")).toBeUndefined(); + }); + it("keeps open chat tabs mounted while switching the active tab", () => { vi.mocked(isChatToolType).mockImplementation((toolType) => toolType === "codex-chat"); const first = makeChatSession("chat-1"); diff --git a/apps/desktop/src/renderer/components/terminals/WorkViewArea.tsx b/apps/desktop/src/renderer/components/terminals/WorkViewArea.tsx index b4d80d8b1..82b765f58 100644 --- a/apps/desktop/src/renderer/components/terminals/WorkViewArea.tsx +++ b/apps/desktop/src/renderer/components/terminals/WorkViewArea.tsx @@ -1,6 +1,7 @@ import { useMemo, useCallback, useEffect, useRef, useState } from "react"; import { AnimatePresence, motion } from "motion/react"; import { + ArrowSquareOut, CaretDown, CaretRight, Chats, @@ -8,7 +9,9 @@ import { Clipboard, Code, Columns, + Crosshair, DotsSixVertical, + Funnel, GitBranch, GridFour, List, @@ -23,10 +26,11 @@ import type { AgentChatSession, LaneLinearIssue, LaneSummary, TerminalSessionSum import type { WorkDraftKind, WorkViewMode } from "../../state/appStore"; import { TerminalView } from "./TerminalView"; import { ToolLogo } from "./ToolLogos"; +import { LaneChip } from "./LaneChip"; import { AgentChatPane } from "../chat/AgentChatPane"; import { WorkStartSurface } from "./WorkStartSurface"; import { isChatToolType, primarySessionLabel, truncateSessionLabel, formatToolTypeLabel } from "../../lib/sessions"; -import { sessionStatusDot } from "../../lib/terminalAttention"; +import { sessionStatusBucket, sessionStatusDot } from "../../lib/terminalAttention"; import type { WorkTabGroup } from "./useWorkSessions"; import { SmartTooltip } from "../ui/SmartTooltip"; import { useFloatingPaneEmbeddedChrome, type FloatingPaneEmbeddedChrome } from "../ui/FloatingPane"; @@ -36,6 +40,15 @@ import { resolveTrackedCliResumeCommand, type LaunchProfile } from "./cliLaunch" import { buildWorkSessionTilingTree, type TilingPreset } from "./workSessionTiling"; import { laneSurfaceTint } from "../lanes/laneDesignTokens"; +function isSessionAwaitingInput(session: TerminalSessionSummary): boolean { + return sessionStatusBucket({ + status: session.status, + lastOutputPreview: session.lastOutputPreview, + runtimeState: session.runtimeState, + toolType: session.toolType, + }) === "awaiting-input"; +} + function isRunningPtySession( session: TerminalSessionSummary | null | undefined, ): session is TerminalSessionSummary & { ptyId: string } { @@ -50,6 +63,7 @@ function isRunningPtySession( function SessionSurface({ session, isActive, + pageActive = true, shouldAutofocus = false, layoutVariant = "standard", terminalVisible = isActive, @@ -58,6 +72,7 @@ function SessionSurface({ }: { session: TerminalSessionSummary; isActive: boolean; + pageActive?: boolean; shouldAutofocus?: boolean; layoutVariant?: "standard" | "grid-tile"; terminalVisible?: boolean; @@ -65,6 +80,8 @@ function SessionSurface({ onResume?: (session: TerminalSessionSummary) => void; }) { const isChat = isChatToolType(session.toolType); + const surfaceActive = pageActive && isActive; + const surfaceVisible = pageActive && (layoutVariant === "grid-tile" ? true : isActive); if (isChat) { return ( ); } @@ -87,8 +104,8 @@ function SessionSurface({ key={session.id} ptyId={session.ptyId} sessionId={session.id} - isActive={isActive} - isVisible={terminalVisible} + isActive={surfaceActive} + isVisible={pageActive && terminalVisible} className="h-full w-full" /> ); @@ -409,7 +426,150 @@ function WorkSidebarToggle({ ); } +type WorkTabProps = { + session: TerminalSessionSummary; + isActive: boolean; + isBusy: boolean; + laneColor: string | null; + grouped?: boolean; + awaiting: boolean; + dropEdge?: "before" | "after" | null; + onSelect: () => void; + onClose: () => void; + onContextMenu: (e: React.MouseEvent) => void; + dragProps?: { + draggable: boolean; + onDragStart: (e: React.DragEvent) => void; + onDragEnter: (e: React.DragEvent) => void; + onDragOver: (e: React.DragEvent) => void; + onDragLeave: (e: React.DragEvent) => void; + onDrop: (e: React.DragEvent) => void; + onDragEnd: (e: React.DragEvent) => void; + }; +}; + +function WorkTab({ + session, + isActive, + isBusy, + laneColor, + grouped = false, + awaiting, + dropEdge = null, + onSelect, + onClose, + onContextMenu, + dragProps, +}: WorkTabProps) { + const dot = sessionStatusDot(session); + const primary = primarySessionLabel(session); + const trimmedLaneColor = laneColor?.trim() || null; + const tabTint = trimmedLaneColor + ? `color-mix(in srgb, ${trimmedLaneColor} ${isActive ? 22 : 8}%, transparent)` + : "transparent"; + const ring = trimmedLaneColor + ? `color-mix(in srgb, ${trimmedLaneColor} ${isActive ? 40 : 24}%, transparent)` + : "color-mix(in srgb, var(--color-fg) 18%, transparent)"; + const cssVars = { + "--lane-tab-tint": tabTint, + "--lane-tab-active-ring": ring, + "--lane-drop-indicator": trimmedLaneColor ?? "color-mix(in srgb, var(--color-fg) 60%, transparent)", + } as React.CSSProperties; + return ( + +
+ + +
+
+ ); +} + export function WorkViewArea({ + pageActive = true, gridLayoutId, lanes, sessions, @@ -438,7 +598,11 @@ export function WorkViewArea({ onToggleWorkSidebar, initialLinearIssueContext = null, onInitialLinearIssueContextConsumed, + onReorderLaneSessions, + onOpenSessionInTabsView, + onGoToLane, }: { + pageActive?: boolean; gridLayoutId: string; lanes: LaneSummary[]; sessions: TerminalSessionSummary[]; @@ -467,6 +631,9 @@ export function WorkViewArea({ closingPtyIds: Set; onContextMenu?: (session: TerminalSessionSummary, e: React.MouseEvent) => void; onResumeSession?: (session: TerminalSessionSummary) => void; + onReorderLaneSessions?: (laneId: string, movedSessionId: string, targetSessionId: string, edge: "before" | "after") => void; + onOpenSessionInTabsView?: (sessionId: string) => void; + onGoToLane?: (laneId: string) => void; /** When the work sessions list pane is collapsed, show expand control in the work header. */ sessionsPaneCollapsed?: boolean; onExpandSessionsPane?: () => void; @@ -541,19 +708,55 @@ export function WorkViewArea({ const isActive = activeItemId === session.id; const rawLaneColor = laneColorById.get(session.laneId) ?? null; const laneAccentColor = rawLaneColor?.trim() ? rawLaneColor.trim() : null; + const openInTabs = () => onOpenSessionInTabsView?.(session.id); + const gotoLane = () => onGoToLane?.(session.laneId); return [session.id, { title: truncateSessionLabel(primarySessionLabel(session)), - meta: session.laneName, + meta: ( + + + + ), minimizable: false, laneAccentColor, className: cn("h-full ade-work-glass-tile", isActive && "ade-work-glass-tile-active"), bodyClassName: "overflow-hidden", headerActions: ( <> + + + + + + + - + session={session} + isActive={isActive} + isBusy={isBusy} + laneColor={laneColor} + awaiting={awaiting} + dropEdge={dropEdge} + onSelect={() => onSelectItem(session.id)} + onClose={() => onCloseItem(session.id)} + onContextMenu={(e) => handleContextMenu(session, e)} + dragProps={buildLaneDragProps({ laneId: session.laneId, sessionId: session.id, index })} + /> ); })} @@ -805,8 +1013,8 @@ export function WorkViewArea({ type="button" className="ade-work-new-chat-btn inline-flex shrink-0 items-center justify-center" style={{ - width: 22, - height: 22, + width: 24, + height: 24, marginLeft: 4, cursor: "pointer", }} @@ -830,171 +1038,144 @@ export function WorkViewArea({
-
+
-
+
{resolvedTabGroups.map((group) => { const hasActive = group.sessionIds.includes(activeSession?.id ?? ""); - const groupTint = group.kind === "lane" && group.laneColor - ? laneSurfaceTint(group.laneColor, "default") - : null; + const isLaneGroup = group.kind === "lane"; + const laneId = isLaneGroup && group.id.startsWith("lane:") ? group.id.slice("lane:".length) : null; + const laneColor = group.laneColor; + const someAwaiting = group.sessions.some(isSessionAwaitingInput); + const bandColor = laneColor?.trim() || null; + const bandTint = bandColor + ? laneSurfaceTint(bandColor, "default", 0.08) + : laneSurfaceTint(null); + const bandCssVars = { + "--lane-band-color": bandColor ?? "color-mix(in srgb, var(--color-fg) 28%, transparent)", + "--lane-band-bg": bandTint.background, + "--lane-band-header-bg": bandColor + ? `color-mix(in srgb, ${bandColor} 12%, transparent)` + : "transparent", + } as React.CSSProperties; + const GroupIcon = isLaneGroup ? GitBranch : Funnel; + if (group.collapsed) { + return ( + + + + ); + } return (
-
- +
toggleTabGroupCollapsed(group.id)} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + toggleTabGroupCollapsed(group.id); + } }} > -
+
+
+ {group.sessions.map((session, index) => { + const isActive = activeSession?.id === session.id; + const isBusy = session.ptyId ? closingPtyIds.has(session.ptyId) : false; + const awaiting = isSessionAwaitingInput(session); + const dropEdge = dragState + && laneId + && dragState.laneId === laneId + && dragState.overIndex === index + && dragState.sessionId !== session.id + ? dragState.overEdge + : null; + return ( + onSelectItem(session.id)} + onClose={() => onCloseItem(session.id)} + onContextMenu={(e) => handleContextMenu(session, e)} + dragProps={laneId ? buildLaneDragProps({ laneId, sessionId: session.id, index }) : undefined} /> - ) : null} - {group.label} - - {group.sessions.length} - - {group.collapsed ? ( - - ) : ( - - )} - - - {!group.collapsed ? ( -
-
- {group.sessions.map((session) => { - const isActive = activeSession?.id === session.id; - const dot = sessionStatusDot(session); - const isBusy = session.ptyId ? closingPtyIds.has(session.ptyId) : false; - const primary = primarySessionLabel(session); - return ( - - - - ); - })} -
-
- ) : null} + ); + })}
); @@ -1004,14 +1185,15 @@ export function WorkViewArea({ type="button" className="ade-work-new-chat-btn inline-flex shrink-0 items-center justify-center" style={{ - width: 24, - height: 24, + width: 28, + height: 28, + marginLeft: 4, cursor: "pointer", }} onClick={() => onShowDraftKind("chat")} aria-label="Start a new chat" > - +
diff --git a/apps/desktop/src/renderer/components/terminals/useWorkSessions.test.ts b/apps/desktop/src/renderer/components/terminals/useWorkSessions.test.ts index afb749656..7b89a53f6 100644 --- a/apps/desktop/src/renderer/components/terminals/useWorkSessions.test.ts +++ b/apps/desktop/src/renderer/components/terminals/useWorkSessions.test.ts @@ -97,7 +97,7 @@ vi.mock("../../state/appStore", () => ({ // --------------------------------------------------------------------------- // Import the hook under test (after mocks are declared) // --------------------------------------------------------------------------- -import { buildWorkTabGroupModel, useWorkSessions } from "./useWorkSessions"; +import { buildWorkTabGroupModel, reorderLaneSessionIdsForDisplay, useWorkSessions } from "./useWorkSessions"; import { invalidateSessionListCache } from "../../lib/sessionListCache"; import { shouldRefreshSessionListForChatEvent } from "../../lib/chatSessionEvents"; @@ -349,6 +349,32 @@ describe("useWorkSessions — refresh-before-focus ordering", () => { }); }); + it("re-enters the Work route without forcing a blocking session-list refresh after the first load", async () => { + const session = makeSession("session-a", "lane-a"); + listSessionsCachedMock.mockResolvedValue([session]); + + const { rerender, result } = renderHook( + ({ active }: { active: boolean }) => useWorkSessions({ active }), + { initialProps: { active: true } }, + ); + + await waitFor(() => { + expect(result.current.sessions).toHaveLength(1); + }); + + listSessionsCachedMock.mockClear(); + rerender({ active: false }); + await act(async () => { + await new Promise((r) => setTimeout(r, 0)); + }); + rerender({ active: true }); + + await waitFor(() => { + expect(listSessionsCachedMock).toHaveBeenCalled(); + }); + expect(listSessionsCachedMock).toHaveBeenLastCalledWith({ limit: 500 }, undefined); + }); + it("setActiveItemId leaves the selected lane alone in grid mode", async () => { const sessionA = makeSession("session-a", "lane-a"); const sessionB = makeSession("session-b", "lane-b"); @@ -1272,4 +1298,14 @@ describe("useWorkSessions — grouping defaults and derived tab order", () => { expect(byTime.groups.map((group) => group.id)).toEqual(["time:today", "time:yesterday", "time:older"]); expect(byTime.sessionIds).toEqual(["session-a1", "session-a2", "session-b1", "session-c1"]); }); + + it("reorders from the displayed pinned tab order", () => { + expect(reorderLaneSessionIdsForDisplay({ + baseOrder: ["unpinned-a", "pinned-b", "unpinned-c"], + pinnedSessionIds: ["pinned-b"], + movedSessionId: "pinned-b", + targetSessionId: "unpinned-c", + edge: "after", + })).toEqual(["unpinned-a", "unpinned-c", "pinned-b"]); + }); }); diff --git a/apps/desktop/src/renderer/components/terminals/useWorkSessions.ts b/apps/desktop/src/renderer/components/terminals/useWorkSessions.ts index 80c2bb594..dc87d134f 100644 --- a/apps/desktop/src/renderer/components/terminals/useWorkSessions.ts +++ b/apps/desktop/src/renderer/components/terminals/useWorkSessions.ts @@ -41,6 +41,8 @@ const DEFAULT_PROJECT_WORK_STATE: WorkProjectViewState = { workSidebarOpen: false, workSidebarTab: "git", workSidebarWidthPct: 36, + laneSessionOrder: {}, + pinnedSessionIds: [], }; type WorkTabGroupKind = "lane" | "status" | "time"; @@ -119,11 +121,15 @@ export function buildWorkTabGroupModel(args: { lanes: WorkTabGroupLane[]; organization: WorkSessionListOrganization; collapsedGroupIds: string[]; + laneSessionOrder?: Record; + pinnedSessionIds?: string[]; }): WorkTabGroupModel { const orderedSessions = [...args.sessions].sort((left, right) => ( new Date(right.startedAt).getTime() - new Date(left.startedAt).getTime() )); const collapseSet = new Set(args.collapsedGroupIds); + const pinnedSet = new Set(args.pinnedSessionIds ?? []); + const laneOrderMap = args.laneSessionOrder ?? {}; if (args.organization === "by-lane") { const laneOrder = new Map(sortLanesForTabs(args.lanes).map((lane, index) => [lane.id, index] as const)); @@ -154,15 +160,44 @@ export function buildWorkTabGroupModel(args: { const laneId = group.id.startsWith("lane:") ? group.id.slice("lane:".length) : null; const lane = laneId ? args.lanes.find((l) => l.id === laneId) : null; const collapsed = collapseSet.has(group.id); - if (!collapsed) visibleSessions.push(...group.sessions); + + const customOrder = laneId ? laneOrderMap[laneId] : undefined; + let arranged = group.sessions; + if (customOrder && customOrder.length > 0) { + const sessionById = new Map(group.sessions.map((s) => [s.id, s] as const)); + const used = new Set(); + const ordered: TerminalSessionSummary[] = []; + for (const id of customOrder) { + const s = sessionById.get(id); + if (s && !used.has(id)) { + ordered.push(s); + used.add(id); + } + } + for (const s of group.sessions) { + if (!used.has(s.id)) ordered.push(s); + } + arranged = ordered; + } + if (pinnedSet.size > 0) { + const pinned: TerminalSessionSummary[] = []; + const others: TerminalSessionSummary[] = []; + for (const s of arranged) { + if (pinnedSet.has(s.id)) pinned.push(s); + else others.push(s); + } + arranged = [...pinned, ...others]; + } + + if (!collapsed) visibleSessions.push(...arranged); return { id: group.id, label: group.label, kind: group.kind, laneColor: group.kind === "lane" ? (lane?.color ?? null) : null, collapsed, - sessionIds: group.sessions.map((session) => session.id), - sessions: group.sessions, + sessionIds: arranged.map((session) => session.id), + sessions: arranged, } satisfies WorkTabGroup; }); return { groups: finalGroups, sessionIds: visibleSessions.map((session) => session.id), visibleSessions }; @@ -254,6 +289,34 @@ function arraysEqual(a: string[], b: string[]): boolean { return true; } +export function reorderLaneSessionIdsForDisplay(args: { + baseOrder: string[]; + pinnedSessionIds: string[]; + movedSessionId: string; + targetSessionId: string; + edge: "before" | "after"; +}): string[] | null { + if (!args.movedSessionId || !args.targetSessionId || args.movedSessionId === args.targetSessionId) { + return null; + } + const pinned = new Set(args.pinnedSessionIds); + const displayedOrder = [ + ...args.baseOrder.filter((id) => pinned.has(id)), + ...args.baseOrder.filter((id) => !pinned.has(id)), + ]; + const fromIndex = displayedOrder.indexOf(args.movedSessionId); + const targetIndex = displayedOrder.indexOf(args.targetSessionId); + if (fromIndex < 0 || targetIndex < 0) return null; + + const next = [...displayedOrder]; + const [moved] = next.splice(fromIndex, 1); + if (!moved) return null; + const targetAfterRemoval = next.indexOf(args.targetSessionId); + if (targetAfterRemoval < 0) return null; + next.splice(args.edge === "after" ? targetAfterRemoval + 1 : targetAfterRemoval, 0, moved); + return arraysEqual(args.baseOrder, next) ? null : next; +} + function mapUrlStatusFilter(statusParamRaw: string): WorkStatusFilter | null { const statusParam = statusParamRaw.trim().toLowerCase(); if (!statusParam) return null; @@ -344,6 +407,8 @@ export function useWorkSessions({ active = true }: UseWorkSessionsOptions = {}) const workSidebarOpen = projectViewState.workSidebarOpen ?? false; const workSidebarTab = projectViewState.workSidebarTab ?? "git"; const workSidebarWidthPct = projectViewState.workSidebarWidthPct ?? 36; + const laneSessionOrder = projectViewState.laneSessionOrder ?? {}; + const pinnedSessionIds = projectViewState.pinnedSessionIds ?? []; const sessionsById = useMemo(() => { const map = new Map(); for (const session of sessions) map.set(session.id, session); @@ -377,8 +442,10 @@ export function useWorkSessions({ active = true }: UseWorkSessionsOptions = {}) lanes, organization: sessionListOrganization, collapsedGroupIds: workCollapsedTabGroupIds, + laneSessionOrder, + pinnedSessionIds, }), - [lanes, openSessions, sessionListOrganization, workCollapsedTabGroupIds], + [lanes, openSessions, sessionListOrganization, workCollapsedTabGroupIds, laneSessionOrder, pinnedSessionIds], ); const visibleSessions = openSessions; @@ -451,6 +518,59 @@ export function useWorkSessions({ active = true }: UseWorkSessionsOptions = {}) [makeCollapsedToggle], ); + const reorderLaneSessions = useCallback( + (laneId: string, movedSessionId: string, targetSessionId: string, edge: "before" | "after") => { + if (!laneId || !movedSessionId || !targetSessionId || movedSessionId === targetSessionId) return; + setProjectViewState((prev) => { + const sessionsInLane = prev.openItemIds + .map((id) => sessionsById.get(id)) + .filter((s): s is TerminalSessionSummary => s != null && s.laneId === laneId); + if (sessionsInLane.length === 0) return prev; + + const existing = prev.laneSessionOrder?.[laneId]; + const baseOrder = existing && existing.length > 0 + ? [ + ...existing.filter((id) => sessionsInLane.some((s) => s.id === id)), + ...sessionsInLane.filter((s) => !existing.includes(s.id)).map((s) => s.id), + ] + : sessionsInLane.map((s) => s.id); + + const next = reorderLaneSessionIdsForDisplay({ + baseOrder, + pinnedSessionIds: prev.pinnedSessionIds ?? [], + movedSessionId, + targetSessionId, + edge, + }); + if (!next) return prev; + + return { + ...prev, + laneSessionOrder: { + ...(prev.laneSessionOrder ?? {}), + [laneId]: next, + }, + }; + }); + }, + [sessionsById, setProjectViewState], + ); + + const togglePinnedSession = useCallback( + (sessionId: string) => { + if (!sessionId) return; + setProjectViewState((prev) => { + const cur = prev.pinnedSessionIds ?? []; + const has = cur.includes(sessionId); + return { + ...prev, + pinnedSessionIds: has ? cur.filter((id) => id !== sessionId) : [...cur, sessionId], + }; + }); + }, + [setProjectViewState], + ); + const setWorkFocusSessionsHidden = useCallback( (hidden: boolean) => { setProjectViewState({ workFocusSessionsHidden: hidden }); @@ -688,7 +808,8 @@ export function useWorkSessions({ active = true }: UseWorkSessionsOptions = {}) useEffect(() => { if (!projectRoot || !isWorkRoute) return; - refresh({ showLoading: true, force: true }).catch(() => {}); + const isInitialLoad = !hasLoadedOnceRef.current; + refresh({ showLoading: isInitialLoad, force: isInitialLoad }).catch(() => {}); }, [isWorkRoute, projectRoot, refresh]); useEffect(() => { @@ -1213,6 +1334,10 @@ export function useWorkSessions({ active = true }: UseWorkSessionsOptions = {}) sessionsGroupedByLane, tabGroups: tabGroupModel.groups, tabVisibleSessionIds, + laneSessionOrder, + pinnedSessionIds, + reorderLaneSessions, + togglePinnedSession, workFocusSessionsHidden, setWorkFocusSessionsHidden, diff --git a/apps/desktop/src/renderer/components/usage/HeaderUsageControl.tsx b/apps/desktop/src/renderer/components/usage/HeaderUsageControl.tsx index 9f05bbdfa..51c6e9288 100644 --- a/apps/desktop/src/renderer/components/usage/HeaderUsageControl.tsx +++ b/apps/desktop/src/renderer/components/usage/HeaderUsageControl.tsx @@ -1,6 +1,7 @@ -import { useCallback, useEffect, useRef, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { CaretDown, CaretRight, Gauge, X } from "@phosphor-icons/react"; import type { + AiProviderConnections, BudgetCapConfig, UsageProvider, UsageSnapshot, @@ -8,23 +9,13 @@ import type { import { cn } from "../ui/cn"; import { BudgetCapEditor } from "../settings/BudgetCapEditor"; import { UsageQuotaPanel } from "./UsageQuotaPanel"; +import { ClaudeLogo, CodexLogo } from "../terminals/ToolLogos"; function extractError(err: unknown): string { return err instanceof Error ? err.message : String(err); } -function usageTone(percent: number, hasErrors: boolean): string { - if (percent >= 90) return "#EF4444"; - if (percent >= 70) return "#F59E0B"; - if (percent > 0) return "#4ADE80"; - if (hasErrors) return "#F59E0B"; - return "var(--color-muted-fg)"; -} - -function summaryPercent(snapshot: UsageSnapshot | null): number { - if (!snapshot || snapshot.windows.length === 0) return 0; - return Math.max(...snapshot.windows.map((window) => Math.max(0, Math.min(100, window.percentUsed)))); -} +const TRACKED_PROVIDERS: UsageProvider[] = ["claude", "codex"]; const PROVIDER_LABEL: Record = { claude: "Claude", @@ -32,32 +23,41 @@ const PROVIDER_LABEL: Record = { cursor: "Cursor", }; -function summaryTitle(snapshot: UsageSnapshot | null, percent: number, hasErrors: boolean): string { - if (!snapshot || snapshot.windows.length === 0) { - return hasErrors ? "Usage — provider polling has warnings" : "Usage"; - } - const byProvider = new Map(); - for (const window of snapshot.windows) { - const prev = byProvider.get(window.provider) ?? 0; - byProvider.set(window.provider, Math.max(prev, Math.max(0, Math.min(100, window.percentUsed)))); - } - const lines = Array.from(byProvider.entries()) - .map(([provider, value]) => `${PROVIDER_LABEL[provider]} ${Math.round(value)}%`); - const head = `Usage ${Math.round(percent)}% peak`; - const detail = lines.length > 0 ? ` (${lines.join(" · ")})` : ""; - const tail = hasErrors ? " — warnings" : ""; - return `${head}${detail}${tail}`; +function ProviderLogo({ provider, size = 14 }: { provider: UsageProvider; size?: number }) { + if (provider === "claude") return ; + if (provider === "codex") return ; + return null; +} + +function thresholdColor(percent: number): string { + if (percent >= 100) return "#EF4444"; + if (percent >= 75) return "#F59E0B"; + return "#22C55E"; +} + +function weeklyPercentFor(snapshot: UsageSnapshot | null, provider: UsageProvider): number | null { + if (!snapshot) return null; + const weekly = snapshot.windows.find( + (w) => w.provider === provider && (w.windowType === "weekly" || w.windowType === "monthly"), + ); + if (!weekly) return null; + return Math.max(0, Math.min(100, weekly.percentUsed)); } +const OPEN_USAGE_EVENT = "ade-open-usage-drawer"; + export function HeaderUsageControl() { const [open, setOpen] = useState(false); const [snapshot, setSnapshot] = useState(null); + const [providerConnections, setProviderConnections] = useState(null); const [budgetConfig, setBudgetConfig] = useState(null); const [budgetSaving, setBudgetSaving] = useState(false); const [budgetError, setBudgetError] = useState(null); const [guardrailsOpen, setGuardrailsOpen] = useState(false); const panelRef = useRef(null); + // Initial snapshot + live updates (always subscribed so the button reflects + // the latest poll whether or not the drawer is open). useEffect(() => { if (!window.ade?.usage) return; let cancelled = false; @@ -68,17 +68,61 @@ export function HeaderUsageControl() { .catch(() => { if (!cancelled) setSnapshot(null); }); + const unsubscribe = window.ade.usage.onUpdate?.((next) => { + if (!cancelled) setSnapshot(next); + }); return () => { cancelled = true; + unsubscribe?.(); }; }, []); + // Fetch provider connection status so we can hide providers whose CLI is + // not installed on this machine. useEffect(() => { - if (open || !window.ade?.usage) return; - const unsubscribe = window.ade.usage.onUpdate((nextSnapshot) => { - setSnapshot(nextSnapshot); - }); - return unsubscribe; + if (!window.ade?.ai?.getStatus) return; + let cancelled = false; + window.ade.ai.getStatus() + .then((status) => { + if (!cancelled) setProviderConnections(status.providerConnections ?? null); + }) + .catch(() => { + if (!cancelled) setProviderConnections(null); + }); + return () => { + cancelled = true; + }; + }, []); + + // Listen for programmatic open requests (used by the threshold toast). + useEffect(() => { + const handler = () => setOpen(true); + window.addEventListener(OPEN_USAGE_EVENT, handler); + return () => window.removeEventListener(OPEN_USAGE_EVENT, handler); + }, []); + + const detectedProviders = useMemo(() => { + if (!providerConnections) return TRACKED_PROVIDERS; + return TRACKED_PROVIDERS.filter( + (provider) => providerConnections[provider]?.runtimeDetected !== false, + ); + }, [providerConnections]); + + // Refresh on drawer open so the snapshot reflects current usage without + // waiting for the next background poll. + useEffect(() => { + if (!open || !window.ade?.usage?.refresh) return; + let cancelled = false; + void window.ade.usage.refresh() + .then((next) => { + if (!cancelled && next) setSnapshot(next); + }) + .catch(() => { + // Refresh errors are surfaced by the drawer itself. + }); + return () => { + cancelled = true; + }; }, [open]); useEffect(() => { @@ -122,37 +166,75 @@ export function HeaderUsageControl() { return () => window.cancelAnimationFrame(frame); }, [open]); - const percent = summaryPercent(snapshot); + const providersWithUsage = useMemo( + () => + detectedProviders.map((provider) => ({ + provider, + percent: weeklyPercentFor(snapshot, provider), + })), + [detectedProviders, snapshot], + ); const hasErrors = (snapshot?.errors.length ?? 0) > 0; - const tone = usageTone(percent, hasErrors); - const title = summaryTitle(snapshot, percent, hasErrors); - const showDot = percent > 0 || hasErrors; + + const titleParts: string[] = []; + for (const { provider, percent } of providersWithUsage) { + if (percent == null) continue; + titleParts.push(`${PROVIDER_LABEL[provider]} ${Math.round(percent)}%`); + } + let buttonTitle: string; + if (titleParts.length > 0) { + buttonTitle = `Usage · ${titleParts.join(" · ")}${hasErrors ? " · warnings" : ""}`; + } else if (hasErrors) { + buttonTitle = "Usage · warnings"; + } else { + buttonTitle = "Usage"; + } + + // Render per-provider chips only when (a) we have at least one detected + // provider AND (b) we have a real percent for at least one of them. + // Otherwise we fall back to the gauge icon — empty em-dashes ("Claude —") + // are worse UX than a single neutral icon during the initial poll. + const hasAnyChip = + providersWithUsage.length > 0 && + providersWithUsage.some(({ percent }) => percent != null); return ( <> {open ? ( @@ -239,3 +321,5 @@ export function HeaderUsageControl() { ); } + +export { OPEN_USAGE_EVENT }; diff --git a/apps/desktop/src/renderer/components/usage/UsageQuotaPanel.test.tsx b/apps/desktop/src/renderer/components/usage/UsageQuotaPanel.test.tsx index b86c1ca1f..297c6e960 100644 --- a/apps/desktop/src/renderer/components/usage/UsageQuotaPanel.test.tsx +++ b/apps/desktop/src/renderer/components/usage/UsageQuotaPanel.test.tsx @@ -105,8 +105,9 @@ function makeAiStatus(providerConnections: Partial = {}): }, features: [], providerConnections: { - claude: makeProviderConnection("claude"), - codex: makeProviderConnection("codex"), + // Both CLIs detected by default so the panel renders both cards. + claude: makeProviderConnection("claude", { runtimeDetected: true, authAvailable: true }), + codex: makeProviderConnection("codex", { runtimeDetected: true, authAvailable: true }), cursor: makeProviderConnection("cursor"), droid: makeProviderConnection("droid"), ...providerConnections, @@ -139,7 +140,7 @@ describe("UsageQuotaPanel", () => { globalThis.window.ade = originalAde; }); - it("shows Codex as percent used, not percent remaining", async () => { + it("renders the weekly used percent for each authed provider", async () => { render(); expect((await screen.findAllByText("Codex")).length).toBeGreaterThan(0); @@ -147,6 +148,36 @@ describe("UsageQuotaPanel", () => { expect(screen.queryByText("37.0% remaining")).toBeNull(); }); + it("renders weekly and monthly windows as separate meters", async () => { + const snapshot = makeSnapshot(); + snapshot.windows = [ + ...snapshot.windows, + { + provider: "codex", + windowType: "monthly", + percentUsed: 44, + resetsAt: "2099-06-01T07:00:00.000Z", + resetsInMs: 7 * 86_400_000, + }, + ]; + vi.mocked(window.ade.usage.getSnapshot).mockResolvedValue(snapshot); + vi.mocked(window.ade.usage.refresh).mockResolvedValue(snapshot); + + render(); + + expect((await screen.findAllByText("Weekly")).length).toBeGreaterThan(0); + expect(await screen.findByText("Monthly")).toBeTruthy(); + expect(await screen.findByText("44.0% used")).toBeTruthy(); + }); + + it("auto-refreshes once on mount so the drawer never shows stale data", async () => { + render(); + + await waitFor(() => { + expect(window.ade.usage.refresh).toHaveBeenCalledTimes(1); + }); + }); + it("keeps live provider polling available through the manual refresh button", async () => { render(); @@ -155,72 +186,50 @@ describe("UsageQuotaPanel", () => { expect(refreshButton.disabled).toBe(false); }); + const baseline = vi.mocked(window.ade.usage.refresh).mock.calls.length; fireEvent.click(screen.getByRole("button", { name: /refresh/i })); await waitFor(() => { - expect(window.ade.usage.refresh).toHaveBeenCalledTimes(1); + expect(window.ade.usage.refresh).toHaveBeenCalledTimes(baseline + 1); }); }); - it("labels Cursor Admin API-only auth as usage auth", async () => { - vi.mocked(window.ade.ai.getStatus).mockResolvedValue(makeAiStatus({ - cursor: makeProviderConnection("cursor", { - authAvailable: true, - runtimeDetected: true, - runtimeAvailable: false, - usageAvailable: true, + it("hides providers whose CLI is not detected on this machine", async () => { + vi.mocked(window.ade.ai.getStatus).mockResolvedValue( + makeAiStatus({ + claude: makeProviderConnection("claude", { runtimeDetected: false, authAvailable: false }), }), - })); + ); render(); - expect(await screen.findByText("usage auth only")).toBeTruthy(); - expect(screen.queryByText("sign-in required")).toBeNull(); + // Codex card stays visible. + expect((await screen.findAllByText("Codex")).length).toBeGreaterThan(0); + // Claude card is hidden when the CLI is not installed. + expect(screen.queryByText("Claude")).toBeNull(); }); - it("keeps the empty-window warning hidden when Cursor extra usage exists", async () => { - const snapshot: UsageSnapshot = { - ...makeSnapshot(), - windows: [], - extraUsage: [{ - provider: "cursor", - isEnabled: true, - usedCreditsUsd: 12.5, - monthlyLimitUsd: 0, - utilization: null, - currency: "usd", - }], - }; - vi.mocked(window.ade.usage.getSnapshot).mockResolvedValue(snapshot); - vi.mocked(window.ade.ai.getStatus).mockResolvedValue(makeAiStatus({ - cursor: makeProviderConnection("cursor", { - authAvailable: true, - runtimeDetected: true, - runtimeAvailable: false, - usageAvailable: true, + it("dims the provider card when the CLI is installed but not signed in", async () => { + vi.mocked(window.ade.ai.getStatus).mockResolvedValue( + makeAiStatus({ + claude: makeProviderConnection("claude", { runtimeDetected: true, authAvailable: false }), }), - })); + ); render(); - expect(await screen.findByText("Cursor monthly spend")).toBeTruthy(); - expect(screen.queryByText(/Restart ADE/)).toBeNull(); + expect(await screen.findByText("Not signed in")).toBeTruthy(); + // The weekly bar is not rendered for the unauthed provider. + expect(screen.queryByText("20.0% used")).toBeNull(); }); - it("keeps sign-in copy for non-Cursor auth failures", async () => { - vi.mocked(window.ade.ai.getStatus).mockResolvedValue(makeAiStatus({ - claude: makeProviderConnection("claude", { - authAvailable: true, - runtimeDetected: true, - runtimeAvailable: false, - usageAvailable: false, - blocker: "Claude runtime reported that login is still required.", - }), - })); - + it("never renders a Cursor section", async () => { render(); - expect(await screen.findByText("sign-in required")).toBeTruthy(); - expect(screen.queryByText("usage auth only")).toBeNull(); + await waitFor(() => { + expect(window.ade.usage.refresh).toHaveBeenCalled(); + }); + expect(screen.queryByText("Cursor")).toBeNull(); + expect(screen.queryByText(/Cursor not detected/i)).toBeNull(); }); }); diff --git a/apps/desktop/src/renderer/components/usage/UsageQuotaPanel.tsx b/apps/desktop/src/renderer/components/usage/UsageQuotaPanel.tsx index 91961b69c..aec90b167 100644 --- a/apps/desktop/src/renderer/components/usage/UsageQuotaPanel.tsx +++ b/apps/desktop/src/renderer/components/usage/UsageQuotaPanel.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { ArrowClockwise as RefreshCw, Gauge } from "@phosphor-icons/react"; import type { AiProviderConnectionStatus, @@ -11,7 +11,6 @@ import type { import { Button } from "../ui/Button"; import { cn } from "../ui/cn"; import { UsageMeter } from "../settings/UsageMeter"; -import { UsagePacingBadge } from "../settings/UsagePacingBadge"; const CARD_SHADOW_STYLE: React.CSSProperties = { background: "linear-gradient(180deg, rgba(20, 31, 45, 0.96) 0%, rgba(10, 18, 28, 0.94) 100%)", @@ -19,7 +18,7 @@ const CARD_SHADOW_STYLE: React.CSSProperties = { boxShadow: "0 18px 40px -24px rgba(0, 0, 0, 0.78), inset 0 1px 0 rgba(255,255,255,0.04)", }; -const PROVIDER_ORDER: UsageProvider[] = ["claude", "codex", "cursor"]; +const PROVIDER_ORDER: UsageProvider[] = ["claude", "codex"]; const PROVIDER_META: Record = { claude: { label: "Claude", color: "#D97757" }, @@ -36,21 +35,17 @@ function computeResetsInMs(resetsAt: string, nowMs: number): number { return Math.max(0, new Date(resetsAt).getTime() - nowMs); } -function formatResetTime(ms: number): string { +function formatResetSublabel(resetsAt: string, nowMs: number): string { + const ms = computeResetsInMs(resetsAt, nowMs); if (ms <= 0) return "resets now"; - const hours = Math.floor(ms / 3_600_000); + const days = Math.floor(ms / 86_400_000); + const hours = Math.floor((ms % 86_400_000) / 3_600_000); const mins = Math.floor((ms % 3_600_000) / 60_000); + if (days > 0) return `resets in ${days}d ${hours}h`; if (hours > 0) return `resets in ${hours}h ${mins}m`; return `resets in ${mins}m`; } -function formatPolledAt(iso: string | null): string { - if (!iso) return "--"; - const parsed = new Date(iso); - if (Number.isNaN(parsed.getTime())) return "--"; - return parsed.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" }); -} - function displayPercent(window: UsageWindow, nowMs: number): number { const resetsInMs = computeResetsInMs(window.resetsAt, nowMs); return resetsInMs <= 0 ? 0 : window.percentUsed; @@ -59,11 +54,11 @@ function displayPercent(window: UsageWindow, nowMs: number): number { function windowLabel(window: UsageWindow): string { switch (window.windowType) { case "five_hour": - return "5-hour window"; + return "5-hour"; case "weekly": - return "Weekly window"; + return "Weekly"; case "monthly": - return "Monthly window"; + return "Monthly"; case "weekly_oauth_apps": return "OAuth apps"; case "weekly_cowork": @@ -93,10 +88,19 @@ export function UsageQuotaPanel({ const [error, setError] = useState(null); const [nowMs, setNowMs] = useState(() => Date.now()); + // Keep the latest onSnapshotChange in a ref so applySnapshot/manualRefresh + // identities stay stable. Without this, a caller that passes an inline + // arrow as onSnapshotChange would re-derive manualRefresh every render and + // the auto-refresh-on-mount effect would re-fire in a loop. + const onSnapshotChangeRef = useRef(onSnapshotChange); + useEffect(() => { + onSnapshotChangeRef.current = onSnapshotChange; + }, [onSnapshotChange]); + const applySnapshot = useCallback((nextSnapshot: UsageSnapshot | null) => { setSnapshot(nextSnapshot); - onSnapshotChange?.(nextSnapshot); - }, [onSnapshotChange]); + onSnapshotChangeRef.current?.(nextSnapshot); + }, []); const load = useCallback(async () => { if (!window.ade?.usage) { @@ -134,6 +138,12 @@ export function UsageQuotaPanel({ return unsubscribe; }, [applySnapshot, load]); + // Auto-refresh on open so the drawer always shows fresh data instead of + // whatever the last background poll happened to leave behind. + useEffect(() => { + void manualRefresh(); + }, [manualRefresh]); + useEffect(() => { let cancelled = false; if (!window.ade?.ai?.getStatus) return; @@ -154,21 +164,24 @@ export function UsageQuotaPanel({ return () => window.clearInterval(timer); }, []); + const visibleProviders = useMemo(() => { + if (!providerConnections) return PROVIDER_ORDER; + return PROVIDER_ORDER.filter((provider) => { + const conn = providerConnection(providerConnections, provider); + // Show only providers whose CLI is detected on this machine. Auth is + // optional — a CLI without auth still renders so the user knows it's + // installed but hasn't been signed in. + return conn?.runtimeDetected !== false; + }); + }, [providerConnections]); + const windowsByProvider = useMemo(() => { const grouped: Partial> = {}; - for (const provider of PROVIDER_ORDER) { + for (const provider of visibleProviders) { grouped[provider] = snapshot?.windows.filter((window) => window.provider === provider) ?? []; } return grouped; - }, [snapshot?.windows]); - - const hasAnyWindow = PROVIDER_ORDER.some((provider) => (windowsByProvider[provider]?.length ?? 0) > 0); - const hasAnyExtraUsage = (snapshot?.extraUsage.length ?? 0) > 0; - const showEmptyQuotaWarning = - PROVIDER_ORDER.some((provider) => providerConnection(providerConnections, provider)?.authAvailable) && - !hasAnyWindow && - !hasAnyExtraUsage && - (snapshot?.errors.length ?? 0) === 0; + }, [snapshot?.windows, visibleProviders]); return (
@@ -178,9 +191,6 @@ export function UsageQuotaPanel({ Provider usage
-
- Last polled: {formatPolledAt(snapshot?.lastPolledAt ?? null)} -
); } - -function AuthChip({ - label, - entry, -}: { - label: string; - entry: AiProviderConnectionStatus | null; -}) { - let tone: { border: string; bg: string; text: string; copy: string }; - if (entry?.runtimeAvailable) { - tone = { border: "rgba(34,197,94,0.3)", bg: "rgba(34,197,94,0.12)", text: "#22C55E", copy: "runtime ready" }; - } else if (entry?.authAvailable) { - const copy = entry.runtimeDetected && !entry.runtimeAvailable - ? entry.usageAvailable ? "usage auth only" : "sign-in required" - : "auth found locally"; - tone = { border: "rgba(59,130,246,0.3)", bg: "rgba(59,130,246,0.12)", text: "#60A5FA", copy }; - } else { - tone = { border: "rgba(113,113,122,0.3)", bg: "rgba(113,113,122,0.12)", text: "#A1A1AA", copy: "not detected" }; - } - - return ( -
- {label} - {tone.copy} -
- ); -} diff --git a/apps/desktop/src/renderer/index.css b/apps/desktop/src/renderer/index.css index 01fc4afe6..8a61aae1f 100644 --- a/apps/desktop/src/renderer/index.css +++ b/apps/desktop/src/renderer/index.css @@ -2675,15 +2675,172 @@ button:active, [role="button"]:active { box-shadow: none; } -/* Active tab / pill in work chrome — neutral, matches shell controls */ +/* Active tab / pill in work chrome. + * When --lane-tab-tint is set on the element, the lane color drives the active + * state. Otherwise we fall back to a neutral fg tint. */ .ade-work-tab-active { - background: linear-gradient(180deg, + background: var(--lane-tab-tint, linear-gradient(180deg, color-mix(in srgb, var(--color-fg) 9%, transparent) 0%, - color-mix(in srgb, var(--color-fg) 5%, transparent) 100%); + color-mix(in srgb, var(--color-fg) 5%, transparent) 100%)); + color: var(--color-fg); + box-shadow: var(--lane-tab-active-shadow, + inset 0 1px 0 color-mix(in srgb, white 10%, transparent)), + 0 0 0 1px var(--lane-tab-active-ring, color-mix(in srgb, var(--color-fg) 12%, transparent)); +} + +/* Work tab — roomier flat/grouped tabs with full lane tint. */ +.ade-work-tab { + position: relative; + display: inline-flex; + align-items: center; + gap: 8px; + padding: 0 12px; + min-height: 40px; + height: 40px; + font-size: 12px; + font-weight: 400; + background: var(--lane-tab-tint, transparent); + color: var(--color-muted-fg); + border: none; + border-radius: 8px; + cursor: pointer; + transition: background 120ms ease, color 120ms ease, box-shadow 120ms ease, opacity 120ms ease; + opacity: 0.85; +} + +.ade-work-tab:hover { + opacity: 1; + color: var(--color-fg); +} + +.ade-work-tab--grouped { + min-height: 36px; + height: 36px; + padding: 0 10px; + font-size: 11px; + border-radius: 6px; +} + +.ade-work-tab--active { + opacity: 1; color: var(--color-fg); + font-weight: 500; + box-shadow: inset 0 0 0 1px var(--lane-tab-active-ring, + color-mix(in srgb, var(--color-fg) 24%, transparent)); +} + +.ade-work-tab--awaiting, +.ade-work-lane-band--awaiting, +.ade-work-lane-band-header--awaiting { + --awaiting-color: #F5A524; box-shadow: - inset 0 1px 0 color-mix(in srgb, white 10%, transparent), - 0 0 0 1px color-mix(in srgb, var(--color-fg) 12%, transparent); + inset 0 0 0 1px color-mix(in srgb, var(--awaiting-color) 70%, transparent), + 0 0 0 2px color-mix(in srgb, var(--awaiting-color) 35%, transparent), + 0 0 14px color-mix(in srgb, var(--awaiting-color) 22%, transparent); +} + +.ade-work-tab--drop-before::before, +.ade-work-tab--drop-after::after { + content: ""; + position: absolute; + top: 4px; + bottom: 4px; + width: 2px; + background: var(--lane-drop-indicator, color-mix(in srgb, var(--color-fg) 60%, transparent)); + border-radius: 1px; + pointer-events: none; +} + +.ade-work-tab--drop-before::before { left: -3px; } +.ade-work-tab--drop-after::after { right: -3px; } + +/* Swim-lane band per lane (grouped-by-lane mode) */ +.ade-work-lane-band { + position: relative; + display: flex; + flex-direction: column; + align-items: stretch; + min-width: 0; + flex-shrink: 0; + gap: 2px; +} + +.ade-work-lane-band-header { + display: flex; + align-items: center; + gap: 6px; + padding: 0 4px 2px; + font-size: 10px; + line-height: 1; + letter-spacing: 0.02em; + text-transform: uppercase; + color: var(--lane-band-color, var(--color-muted-fg)); + cursor: pointer; + user-select: none; + opacity: 0.75; + transition: opacity 120ms ease; +} + +.ade-work-lane-band-header:hover { + opacity: 1; +} + +.ade-work-lane-band-tabs { + display: flex; + flex-wrap: nowrap; + align-items: center; + gap: 6px; + padding: 0; +} + +.ade-work-lane-band--collapsed { + flex-direction: column; + align-items: center; + justify-content: center; + gap: 3px; + min-width: 96px; + max-width: 160px; + padding: 6px 10px; + border-radius: 8px; + border: none; + background: color-mix(in srgb, var(--lane-band-color, var(--color-fg)) 10%, transparent); + box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--lane-band-color, var(--color-fg)) 18%, transparent); + color: var(--lane-band-color, var(--color-muted-fg)); + cursor: pointer; + transition: background 120ms ease, box-shadow 120ms ease; +} + +.ade-work-lane-band--collapsed:hover { + background: color-mix(in srgb, var(--lane-band-color, var(--color-fg)) 16%, transparent); + box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--lane-band-color, var(--color-fg)) 28%, transparent); +} + +.ade-work-lane-band--collapsed.ade-work-lane-band--active { + box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--lane-band-color, var(--color-fg)) 40%, transparent); +} + +.ade-work-lane-band-collapsed-label { + display: -webkit-box; + -webkit-line-clamp: 1; + -webkit-box-orient: vertical; + overflow: hidden; + font-size: 11px; + line-height: 1.2; + text-align: center; + max-width: 100%; +} + +.ade-work-lane-band-collapsed-count { + font-size: 10px; + line-height: 1; + opacity: 0.55; +} + +.ade-work-tab-strip-roomy { + display: flex; + flex-wrap: nowrap; + align-items: center; + gap: 6px; } .ade-work-new-chat-btn { @@ -2760,6 +2917,14 @@ button:active, [role="button"]:active { -webkit-backdrop-filter: blur(26px) saturate(150%); } +.ade-chat-drawer-glass.absolute { + position: absolute; +} + +.ade-chat-drawer-glass.fixed { + position: fixed; +} + .ade-chat-drawer-glass::before { content: ""; position: absolute; diff --git a/apps/desktop/src/renderer/state/appStore.test.ts b/apps/desktop/src/renderer/state/appStore.test.ts index 9214a65ee..33da9402e 100644 --- a/apps/desktop/src/renderer/state/appStore.test.ts +++ b/apps/desktop/src/renderer/state/appStore.test.ts @@ -703,7 +703,6 @@ describe("appStore", () => { it("tracks project switching progress and clears it on success", async () => { const nextProject = { rootPath: "/tmp/next", displayName: "Next", baseRef: "main" } as any; (window.ade.project.switchToPath as any).mockResolvedValueOnce(nextProject); - (window.ade.project.listRecent as any).mockResolvedValueOnce([{ rootPath: "/tmp/next" }]); const pending = useAppStore.getState().switchProjectToPath("/tmp/next"); expect(useAppStore.getState().projectTransition).toEqual( @@ -735,32 +734,48 @@ describe("appStore", () => { ); }); - it("prunes banner-dismiss maps to the new project on switch", async () => { + it("prunes banner-dismiss maps to the new project after switch settles", async () => { + const originalWindowSetTimeout = window.setTimeout; + vi.useFakeTimers(); + window.setTimeout = globalThis.setTimeout as typeof window.setTimeout; // Seed dismissals for three projects, then switch to one of them with a // listRecent that only includes two. The third should be dropped. - useAppStore.setState({ - dismissedMissingAiBannerRoots: { "/p/a": true, "/p/b": true, "/p/c": true }, - dismissedGithubBannerRoots: { "/p/a": true, "/p/b": true }, - } as any); - - const nextProject = { rootPath: "/p/a", displayName: "A", baseRef: "main" } as any; - (window.ade.project.switchToPath as any).mockResolvedValueOnce(nextProject); - (window.ade.project.listRecent as any).mockResolvedValueOnce([ - { rootPath: "/p/a" }, - { rootPath: "/p/b" }, - ]); - - await useAppStore.getState().switchProjectToPath("/p/a"); - - // `/p/c` was neither active nor in recents → pruned from all banner maps. - expect(useAppStore.getState().dismissedMissingAiBannerRoots).toEqual({ - "/p/a": true, - "/p/b": true, - }); - expect(useAppStore.getState().dismissedGithubBannerRoots).toEqual({ - "/p/a": true, - "/p/b": true, - }); + try { + useAppStore.setState({ + dismissedMissingAiBannerRoots: { "/p/a": true, "/p/b": true, "/p/c": true }, + dismissedGithubBannerRoots: { "/p/a": true, "/p/b": true }, + } as any); + + const nextProject = { rootPath: "/p/a", displayName: "A", baseRef: "main" } as any; + (window.ade.project.switchToPath as any).mockResolvedValueOnce(nextProject); + (window.ade.project.listRecent as any).mockResolvedValueOnce([ + { rootPath: "/p/a" }, + { rootPath: "/p/b" }, + ]); + + await useAppStore.getState().switchProjectToPath("/p/a"); + + expect(useAppStore.getState().dismissedMissingAiBannerRoots).toEqual({ + "/p/a": true, + "/p/b": true, + "/p/c": true, + }); + + await vi.advanceTimersByTimeAsync(750); + + // `/p/c` was neither active nor in recents → pruned from all banner maps. + expect(useAppStore.getState().dismissedMissingAiBannerRoots).toEqual({ + "/p/a": true, + "/p/b": true, + }); + expect(useAppStore.getState().dismissedGithubBannerRoots).toEqual({ + "/p/a": true, + "/p/b": true, + }); + } finally { + window.setTimeout = originalWindowSetTimeout; + vi.useRealTimers(); + } }); it("clears all banner-dismiss maps when the project is closed", async () => { diff --git a/apps/desktop/src/renderer/state/appStore.ts b/apps/desktop/src/renderer/state/appStore.ts index 5d922beea..315fe6f37 100644 --- a/apps/desktop/src/renderer/state/appStore.ts +++ b/apps/desktop/src/renderer/state/appStore.ts @@ -143,6 +143,10 @@ export type WorkProjectViewState = { workSidebarOpen: boolean; workSidebarTab: WorkSidebarTab; workSidebarWidthPct: number; + /** Per-lane custom tab ordering for the grouped Work tab strip. */ + laneSessionOrder: Record; + /** Session ids pinned to the front of their lane's tab group. */ + pinnedSessionIds: string[]; }; export type TerminalAttentionSnapshot = { runningCount: number; @@ -187,6 +191,8 @@ function createDefaultWorkProjectViewState(): WorkProjectViewState { workSidebarOpen: false, workSidebarTab: "git", workSidebarWidthPct: 36, + laneSessionOrder: {}, + pinnedSessionIds: [], }; } @@ -247,9 +253,23 @@ function normalizeWorkProjectViewState(value: unknown): WorkProjectViewState { workSidebarOpen: candidate.workSidebarOpen === true, workSidebarTab: normalizeWorkSidebarTab(candidate.workSidebarTab), workSidebarWidthPct: normalizeWorkSidebarWidthPct(candidate.workSidebarWidthPct), + laneSessionOrder: normalizeLaneSessionOrder(candidate.laneSessionOrder), + pinnedSessionIds: normalizeStringArray(candidate.pinnedSessionIds), }; } +function normalizeLaneSessionOrder(value: unknown): Record { + if (!value || typeof value !== "object") return {}; + const out: Record = {}; + for (const [laneId, ids] of Object.entries(value as Record)) { + const trimmedLaneId = typeof laneId === "string" ? laneId.trim() : ""; + if (!trimmedLaneId) continue; + const list = normalizeStringArray(ids); + if (list.length > 0) out[trimmedLaneId] = list; + } + return out; +} + function readPersistedWorkViewState(): { workViewByProject: Record; laneWorkViewByScope: Record; @@ -1223,34 +1243,41 @@ export const useAppStore = create((set, get) => ({ ]); scheduleProjectHydration(get); - // Prune stale view state for projects no longer in recent list - const recentRoots = new Set( - (await window.ade.project.listRecent().catch(() => [])).map((r: { rootPath: string }) => r.rootPath) - ); - const activeRoot = get().project?.rootPath ?? null; - const retainedRoots = [activeRoot, ...recentRoots]; - set((prev) => { - const nextWorkViews: Record = {}; - const nextLaneWorkViews: Record = {}; - for (const [key, value] of Object.entries(prev.workViewByProject)) { - if (key === activeRoot || recentRoots.has(key)) nextWorkViews[key] = value; - } - for (const [scopeKey, value] of Object.entries(prev.laneWorkViewByScope)) { - const projectKey = scopeKey.split("::")[0]; - if (projectKey === activeRoot || recentRoots.has(projectKey)) nextLaneWorkViews[scopeKey] = value; - } - persistWorkViewState({ - workViewByProject: nextWorkViews, - laneWorkViewByScope: nextLaneWorkViews, - }); - return { - projectTransition: null, - workViewByProject: nextWorkViews, - laneWorkViewByScope: nextLaneWorkViews, - dismissedMissingAiBannerRoots: pickDismissMapForRoots(prev.dismissedMissingAiBannerRoots, retainedRoots), - dismissedGithubBannerRoots: pickDismissMapForRoots(prev.dismissedGithubBannerRoots, retainedRoots), - }; - }); + const hasProjectScopedStateToPrune = + Object.keys(get().workViewByProject).length > 1 || + Object.keys(get().laneWorkViewByScope).length > 0 || + Object.keys(get().dismissedMissingAiBannerRoots).length > 1 || + Object.keys(get().dismissedGithubBannerRoots).length > 1; + if (!hasProjectScopedStateToPrune) return; + + window.setTimeout(() => { + void window.ade.project.listRecent().then((recentRows) => { + const recentRoots = new Set(recentRows.map((r: { rootPath: string }) => r.rootPath)); + const activeRoot = get().project?.rootPath ?? null; + const retainedRoots = [activeRoot, ...recentRoots]; + set((prev) => { + const nextWorkViews: Record = {}; + const nextLaneWorkViews: Record = {}; + for (const [key, value] of Object.entries(prev.workViewByProject)) { + if (key === activeRoot || recentRoots.has(key)) nextWorkViews[key] = value; + } + for (const [scopeKey, value] of Object.entries(prev.laneWorkViewByScope)) { + const projectKey = scopeKey.split("::")[0]; + if (projectKey === activeRoot || recentRoots.has(projectKey)) nextLaneWorkViews[scopeKey] = value; + } + persistWorkViewState({ + workViewByProject: nextWorkViews, + laneWorkViewByScope: nextLaneWorkViews, + }); + return { + workViewByProject: nextWorkViews, + laneWorkViewByScope: nextLaneWorkViews, + dismissedMissingAiBannerRoots: pickDismissMapForRoots(prev.dismissedMissingAiBannerRoots, retainedRoots), + dismissedGithubBannerRoots: pickDismissMapForRoots(prev.dismissedGithubBannerRoots, retainedRoots), + }; + }); + }).catch(() => {}); + }, 750); } catch (error) { set({ projectTransition: null, diff --git a/apps/desktop/src/shared/cliLaunch.ts b/apps/desktop/src/shared/cliLaunch.ts index e57ddbce9..ce0dc066d 100644 --- a/apps/desktop/src/shared/cliLaunch.ts +++ b/apps/desktop/src/shared/cliLaunch.ts @@ -19,6 +19,14 @@ export type TrackedCliLaunchCommand = { export const LAUNCH_PROFILES = ["claude", "codex", "cursor", "droid", "opencode", "shell"] as const satisfies readonly LaunchProfile[]; export const TRACKED_CLI_PERMISSION_MODES = ["default", "plan", "edit", "full-auto", "config-toml"] as const satisfies readonly AgentChatPermissionMode[]; +export function sanitizeTrackedCliResumeTargetId(value: string | null | undefined): string | null { + const target = String(value ?? "").trim(); + if (!target) return null; + if (/[\x00-\x1F\x7F]/.test(target)) return null; + if (target.startsWith("-")) return null; + return target; +} + /** Maps a `launchPtySession` profile to the `TerminalToolType` recorded on the session. */ export const LAUNCH_PROFILE_TOOL_TYPE: Record = { claude: "claude", @@ -314,7 +322,7 @@ function buildOpenCodeCommandParts(args: { export function buildTrackedCliResumeCommand(metadata: TerminalResumeMetadata): string { validateLaunchProfilePermissionMode(metadata.provider, metadata.launch.permissionMode); - const targetId = metadata.targetId?.trim() ?? ""; + const targetId = sanitizeTrackedCliResumeTargetId(metadata.targetId) ?? ""; if (metadata.provider === "claude") { const parts = ["claude", ...permissionModeToClaudeFlag(metadata.launch.permissionMode)]; parts.push("--resume"); diff --git a/apps/desktop/src/shared/ipc.ts b/apps/desktop/src/shared/ipc.ts index d88304f8e..b7944135c 100644 --- a/apps/desktop/src/shared/ipc.ts +++ b/apps/desktop/src/shared/ipc.ts @@ -687,6 +687,7 @@ export const IPC = { usageGetBudgetConfig: "ade.usage.getBudgetConfig", usageSaveBudgetConfig: "ade.usage.saveBudgetConfig", usageEvent: "ade.usage.event", + usageThresholdEvent: "ade.usage.thresholdEvent", memoryAdd: "ade.memory.add", memoryPin: "ade.memory.pin", memoryUpdateCore: "ade.memory.updateCore", diff --git a/apps/desktop/src/shared/types/chat.ts b/apps/desktop/src/shared/types/chat.ts index a7024c2d3..b60d80fc8 100644 --- a/apps/desktop/src/shared/types/chat.ts +++ b/apps/desktop/src/shared/types/chat.ts @@ -1256,7 +1256,9 @@ export type AgentChatSlashCommand = { }; export type AgentChatSlashCommandsArgs = { - sessionId: string; + sessionId?: string; + laneId?: string | null; + provider?: AgentChatProvider | null; }; export type AgentChatClaudeOutputStylesArgs = { diff --git a/apps/desktop/src/shared/types/git.ts b/apps/desktop/src/shared/types/git.ts index edd04887f..d228f74ea 100644 --- a/apps/desktop/src/shared/types/git.ts +++ b/apps/desktop/src/shared/types/git.ts @@ -151,6 +151,12 @@ export type DiffChanges = { staged: FileChange[]; }; +export type DiffLineStats = { + additions: number; + deletions: number; + files: number; +}; + export type GetDiffChangesArgs = { laneId: string; }; diff --git a/apps/desktop/src/shared/types/prs.ts b/apps/desktop/src/shared/types/prs.ts index af7e94b08..d4a8949cb 100644 --- a/apps/desktop/src/shared/types/prs.ts +++ b/apps/desktop/src/shared/types/prs.ts @@ -43,6 +43,14 @@ export type PrSummary = { creationStrategy?: PrCreationStrategy | null; }; +export type PrLaneSummary = { + laneId: string; + number: number; + state: "open" | "merged" | "closed"; + checksPassed: number; + checksTotal: number; +}; + export type PrStatus = { prId: string; state: PrState; diff --git a/apps/desktop/src/shared/types/usage.ts b/apps/desktop/src/shared/types/usage.ts index d26775b26..97a9233bf 100644 --- a/apps/desktop/src/shared/types/usage.ts +++ b/apps/desktop/src/shared/types/usage.ts @@ -130,10 +130,20 @@ export type UsageSnapshot = { pacingByProvider?: UsagePacingByProvider; costs: CostSnapshot[]; extraUsage: ExtraUsage[]; + /** Per-provider daily token usage for the last 7 calendar days, oldest first. */ + dailyUsage7d?: Partial>; lastPolledAt: string; errors: string[]; }; +export type UsageThresholdEvent = { + provider: UsageProvider; + threshold: 25 | 50 | 75 | 100; + percent: number; + resetsAt: string; + firedAt: string; +}; + // --------------------------------------------------------------------------- // Budget cap types for shared automation usage enforcement // --------------------------------------------------------------------------- diff --git a/apps/ios/ADE/Services/Database.swift b/apps/ios/ADE/Services/Database.swift index 12d828354..f5ab72e60 100644 --- a/apps/ios/ADE/Services/Database.swift +++ b/apps/ios/ADE/Services/Database.swift @@ -2828,6 +2828,42 @@ final class DatabaseService { ) ?? false } + func upsertMobileProjectCache(_ project: MobileProjectSummary) throws { + guard db != nil else { return } + guard let rootPath = normalizedProjectCacheRoot(project.rootPath) else { + throw sqliteError(SyncHydrationMessaging.waitingForProjectData) + } + + let now = ISO8601DateFormatter().string(from: Date()) + let displayName = nonEmpty(project.displayName) + ?? rootPath.split(separator: "/").last.map(String.init) + ?? "Project" + let defaultBaseRef = nonEmpty(project.defaultBaseRef) ?? "main" + let lastOpenedAt = nonEmpty(project.lastOpenedAt) ?? now + + shouldCaptureLocalChanges = false + defer { shouldCaptureLocalChanges = true } + + _ = try execute(""" + insert into projects ( + id, root_path, display_name, default_base_ref, created_at, last_opened_at + ) values (?, ?, ?, ?, ?, ?) + on conflict(id) do update set + root_path = excluded.root_path, + display_name = excluded.display_name, + default_base_ref = excluded.default_base_ref, + last_opened_at = excluded.last_opened_at + """) { statement in + try bindText(project.id, to: statement, index: 1) + try bindText(rootPath, to: statement, index: 2) + try bindText(displayName, to: statement, index: 3) + try bindText(defaultBaseRef, to: statement, index: 4) + try bindText(now, to: statement, index: 5) + try bindText(lastOpenedAt, to: statement, index: 6) + } + notifyDidChange() + } + func listMobileProjects() -> [MobileProjectSummary] { guard hasTable(named: "projects") else { return [] } @@ -2877,6 +2913,21 @@ final class DatabaseService { Int(queryInt64("select count(*) from projects") ?? 0) } + private func nonEmpty(_ value: String?) -> String? { + guard let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines), + !trimmed.isEmpty + else { return nil } + return trimmed + } + + private func normalizedProjectCacheRoot(_ rootPath: String?) -> String? { + guard var root = nonEmpty(rootPath) else { return nil } + while root.count > 1, root.hasSuffix("/") { + root.removeLast() + } + return root + } + private func hasTable(named tableName: String) -> Bool { querySingle( "select 1 from sqlite_master where type = 'table' and name = ? limit 1", diff --git a/apps/ios/ADE/Services/SyncService.swift b/apps/ios/ADE/Services/SyncService.swift index f16bdf728..f57a9341b 100644 --- a/apps/ios/ADE/Services/SyncService.swift +++ b/apps/ios/ADE/Services/SyncService.swift @@ -217,7 +217,9 @@ enum InitialHydrationGate { enum SyncRequestTimeout { static let defaultTimeoutNanoseconds: UInt64 = 30_000_000_000 + static let chatSendTimeoutNanoseconds: UInt64 = 120_000_000_000 static let message = "The machine took too long to respond. Reconnecting now." + static let chatSendMessage = "The machine is still starting this chat turn. Live updates will keep syncing." static func error(message: String = Self.message, underlyingError: Error? = nil) -> NSError { var userInfo: [String: Any] = [NSLocalizedDescriptionKey: message] @@ -551,6 +553,30 @@ func syncShouldUseReducedNetworkLoad( return initialPreference } +struct SyncConnectionLoadSample: Equatable { + let isPoor: Bool + let isHealthy: Bool +} + +func syncConnectionLoadSample( + latencyMs: Int? = nil, + syncLag: Int? = nil, + roundTripSeconds: TimeInterval? = nil +) -> SyncConnectionLoadSample { + let latencyPoor = latencyMs.map { $0 >= 900 } ?? false + let latencyHealthy = latencyMs.map { $0 <= 250 } ?? false + let lagHealthy = syncLag.map { $0 <= 10 } ?? true + let roundTripPoor = roundTripSeconds.map { $0 >= 5.0 } ?? false + let roundTripHealthy = roundTripSeconds.map { $0 <= 0.9 } ?? false + + // A high server sync lag usually means the phone is catching up on a large + // CRDT backlog. That is sync work, not proof that the transport is weak. + return SyncConnectionLoadSample( + isPoor: latencyPoor || roundTripPoor, + isHealthy: (latencyHealthy || roundTripHealthy) && lagHealthy + ) +} + enum SyncUserFacingError { static func message(for error: Error) -> String { let nsError = error as NSError @@ -1014,8 +1040,13 @@ final class SyncService: ObservableObject { } func isActiveProject(_ project: MobileProjectSummary) -> Bool { - if let activeProjectId, project.id == activeProjectId { - return true + if let activeProjectId { + if project.id == activeProjectId { + return true + } + if projects.contains(where: { $0.id == activeProjectId }) { + return false + } } guard let activeProjectRootPath, let projectRoot = normalizedProjectRoot(project.rootPath) @@ -1121,16 +1152,22 @@ final class SyncService: ObservableObject { return normalizedProjectRoot(left) == normalizedProjectRoot(right) }) { var existing = match.value - mergedById.removeValue(forKey: match.key) - existing.id = cachedProject.id - existing.displayName = cachedProject.displayName + let keepRemoteIdentity = preferRemoteSelection + && activeProjectId != nil + && activeProjectRootPath != nil + && normalizedProjectRoot(existing.rootPath) == activeProjectRootPath + if !keepRemoteIdentity { + mergedById.removeValue(forKey: match.key) + existing.id = cachedProject.id + } + existing.displayName = keepRemoteIdentity ? existing.displayName : cachedProject.displayName existing.defaultBaseRef = cachedProject.defaultBaseRef ?? existing.defaultBaseRef existing.lastOpenedAt = cachedProject.lastOpenedAt ?? existing.lastOpenedAt existing.iconDataUrl = cachedProject.iconDataUrl ?? existing.iconDataUrl - existing.laneCount = cachedProject.laneCount - existing.isCached = true + existing.laneCount = keepRemoteIdentity ? existing.laneCount : cachedProject.laneCount + existing.isCached = keepRemoteIdentity ? (existing.isCached || database.hasProject(id: existing.id)) : true existing.isAvailable = existing.isAvailable || cachedProject.isAvailable - mergedById[cachedProject.id] = existing + mergedById[existing.id] = existing } else if !hasRemoteCatalog { mergedById[cachedProject.id] = cachedProject } @@ -1141,7 +1178,9 @@ final class SyncService: ObservableObject { let match = mergedById.first(where: { entry in normalizedProjectRoot(entry.value.rootPath) == activeProjectRootPath }) { - if match.value.isCached { + if preferRemoteSelection { + setActiveProjectId(match.value.id, rootPath: match.value.rootPath) + } else if match.value.isCached { setActiveProjectId(match.value.id, rootPath: match.value.rootPath) } else { var existing = match.value @@ -1150,14 +1189,16 @@ final class SyncService: ObservableObject { mergedById[activeProjectId] = existing } } - projects = mergedById.values.sorted { left, right in - if isActiveProject(left) { return true } - if isActiveProject(right) { return false } + let sortedProjects = mergedById.values.sorted { left, right in + let leftActive = activeProjectId != nil && left.id == activeProjectId + let rightActive = activeProjectId != nil && right.id == activeProjectId + if leftActive != rightActive { return leftActive } let leftOpen = left.isOpen ?? true let rightOpen = right.isOpen ?? true if leftOpen != rightOpen { return leftOpen } return (left.lastOpenedAt ?? "") > (right.lastOpenedAt ?? "") } + projects = preferRemoteSelection ? deduplicateProjectListByRoot(sortedProjects) : sortedProjects if preferRemoteSelection { preferActiveProjectFromRemoteCatalogIfNeeded() } @@ -1165,6 +1206,7 @@ final class SyncService: ObservableObject { } private func preferActiveProjectFromRemoteCatalogIfNeeded() { + guard activeProjectId != nil else { return } let remoteProjects = deduplicatedRemoteProjectCatalog() guard !remoteProjects.isEmpty else { return } if let activeProjectId, @@ -1176,13 +1218,6 @@ final class SyncService: ObservableObject { setActiveProjectId(matchingProject.id, rootPath: matchingProject.rootPath) return } - let preferred = remoteProjects.sorted { left, right in - if left.isAvailable != right.isAvailable { return left.isAvailable } - return (left.lastOpenedAt ?? "") > (right.lastOpenedAt ?? "") - }.first - if let preferred { - setActiveProjectId(preferred.id, rootPath: preferred.rootPath) - } } private func deduplicatedRemoteProjectCatalog() -> [MobileProjectSummary] { @@ -1224,9 +1259,42 @@ final class SyncService: ObservableObject { return (candidate.lastOpenedAt ?? "") > (existing.lastOpenedAt ?? "") } - private func applyRemoteProjectCatalog(_ catalog: MobileProjectCatalogPayload) { + private func deduplicateProjectListByRoot(_ candidates: [MobileProjectSummary]) -> [MobileProjectSummary] { + var seenRoots = Set() + return candidates.filter { project in + guard let root = normalizedProjectRoot(project.rootPath) else { return true } + return seenRoots.insert(root).inserted + } + } + + private func activeProjectCatalogEntryForHydration() -> MobileProjectSummary? { + guard let activeProjectId else { return nil } + let candidates = projects + remoteProjectCatalog + if let match = candidates.first(where: { $0.id == activeProjectId }) { + return match + } + guard let activeProjectRootPath else { return nil } + return candidates.first { normalizedProjectRoot($0.rootPath) == activeProjectRootPath } + } + + private func ensureActiveProjectCacheRowForHydration() throws { + guard let activeProjectId else { return } + if database.hasProject(id: activeProjectId) { + return + } + guard let project = activeProjectCatalogEntryForHydration() else { + return + } + try database.upsertMobileProjectCache(project) + refreshProjectCatalog(preferRemoteSelection: true) + } + + private func applyRemoteProjectCatalog( + _ catalog: MobileProjectCatalogPayload, + preferRemoteSelection: Bool = true + ) { remoteProjectCatalog = catalog.projects - refreshProjectCatalog() + refreshProjectCatalog(preferRemoteSelection: preferRemoteSelection) } private func applyRemoteProjectCatalogChunk( @@ -2080,21 +2148,16 @@ final class SyncService: ObservableObject { } private func recordConnectionLoadSample(latencyMs: Int? = nil, syncLag: Int? = nil, roundTripSeconds: TimeInterval? = nil) { - let latencyPoor = latencyMs.map { $0 >= 900 } ?? false - let latencyHealthy = latencyMs.map { $0 <= 250 } ?? false - let lagPoor = syncLag.map { $0 >= 100 } ?? false - let lagHealthy = syncLag.map { $0 <= 10 } ?? true - let roundTripPoor = roundTripSeconds.map { $0 >= 5.0 } ?? false - let roundTripHealthy = roundTripSeconds.map { $0 <= 0.9 } ?? false - - if latencyPoor || lagPoor || roundTripPoor { + let sample = syncConnectionLoadSample(latencyMs: latencyMs, syncLag: syncLag, roundTripSeconds: roundTripSeconds) + + if sample.isPoor { poorConnectionSampleCount = min(poorConnectionSampleCount + 1, 3) healthyConnectionSampleCount = 0 refreshReducedSyncLoad() return } - if (latencyHealthy || roundTripHealthy) && lagHealthy { + if sample.isHealthy { healthyConnectionSampleCount = min(healthyConnectionSampleCount + 1, 5) if healthyConnectionSampleCount >= 2 { poorConnectionSampleCount = 0 @@ -2231,6 +2294,7 @@ final class SyncService: ObservableObject { } if let hostName, !hostName.isEmpty { return discovered.hostName.localizedCaseInsensitiveCompare(hostName) == .orderedSame + || discovered.serviceName.localizedCaseInsensitiveCompare(hostName) == .orderedSame } return false } @@ -3686,7 +3750,13 @@ final class SyncService: ObservableObject { @discardableResult func sendChatMessage(sessionId: String, text: String) async throws -> SyncChatMessageDelivery { - let response = try await sendCommand(action: "chat.send", args: ["sessionId": sessionId, "text": text]) + let response = try await sendCommand( + action: "chat.send", + args: ["sessionId": sessionId, "text": text], + disconnectOnTimeout: false, + timeoutMessage: SyncRequestTimeout.chatSendMessage, + timeoutNanoseconds: SyncRequestTimeout.chatSendTimeoutNanoseconds + ) if let response = response as? [String: Any], response["queued"] as? Bool == true { return .queued } @@ -5496,6 +5566,10 @@ final class SyncService: ObservableObject { resetOutboundCursorStateForActiveProject() } + func ensureActiveProjectCacheRowForTesting() throws { + try ensureActiveProjectCacheRowForHydration() + } + func outboundLocalDbVersionForTesting() -> Int { outboundLocalDbVersion } @@ -5633,6 +5707,13 @@ final class SyncService: ObservableObject { } else { refreshProjectCatalog() } + if activeProjectId != nil, let incomingHostIdentity { + activeProjectHostIdentity = incomingHostIdentity + UserDefaults.standard.set(incomingHostIdentity, forKey: activeProjectHostIdentityKey) + } + if activeProject != nil { + projectHomePresented = false + } reconnectState.reset() allowAutoReconnect = true @@ -6483,7 +6564,8 @@ final class SyncService: ObservableObject { args: [String: Any], commandId: String? = nil, disconnectOnTimeout: Bool = true, - timeoutMessage: String = SyncRequestTimeout.message + timeoutMessage: String = SyncRequestTimeout.message, + timeoutNanoseconds: UInt64 = SyncRequestTimeout.defaultTimeoutNanoseconds ) async throws -> Any { guard canSendLiveRequests() else { throw NSError(domain: "ADE", code: 14, userInfo: [NSLocalizedDescriptionKey: "The machine is offline."]) @@ -6492,7 +6574,8 @@ final class SyncService: ObservableObject { let raw = try await awaitResponse( requestId: requestId, disconnectOnTimeout: disconnectOnTimeout, - timeoutMessage: timeoutMessage + timeoutMessage: timeoutMessage, + timeoutNanoseconds: timeoutNanoseconds ) { self.sendEnvelope( type: "command", @@ -6509,11 +6592,24 @@ final class SyncService: ObservableObject { return try unwrapSyncCommandResponse(raw) } - private func sendCommand(action: String, args: [String: Any]) async throws -> Any { + private func sendCommand( + action: String, + args: [String: Any], + disconnectOnTimeout: Bool = true, + timeoutMessage: String = SyncRequestTimeout.message, + timeoutNanoseconds: UInt64 = SyncRequestTimeout.defaultTimeoutNanoseconds + ) async throws -> Any { let commandId = makeRequestId() if canSendLiveRequests() { do { - return try await performCommandRequest(action: action, args: args, commandId: commandId) + return try await performCommandRequest( + action: action, + args: args, + commandId: commandId, + disconnectOnTimeout: disconnectOnTimeout, + timeoutMessage: timeoutMessage, + timeoutNanoseconds: timeoutNanoseconds + ) } catch { if !isRemoteCommandApplicationError(error), !canSendLiveRequests(), @@ -6711,6 +6807,7 @@ final class SyncService: ObservableObject { setDomainStatus(SyncDomain.allCases, phase: .syncingInitialData) do { + try ensureActiveProjectCacheRowForHydration() try await InitialHydrationGate.waitForProjectRow( currentProjectId: { guard let activeProjectId = self.activeProjectId else { @@ -7380,15 +7477,21 @@ func syncDiscoveredHostFromBonjour( ) -> DiscoveredSyncHost { let preferredHost = txtRecord["host"]? .trimmingCharacters(in: .whitespacesAndNewlines) + let fallbackServiceHost = serviceHostName + .flatMap(syncEndpointHost)? + .trimmingCharacters(in: CharacterSet(charactersIn: ".").union(.whitespacesAndNewlines)) let announcedAddresses = txtRecord["addresses"]? .split(separator: ",") .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } .filter { !$0.isEmpty } ?? [] - let addresses = ([preferredHost] + let announcedRouteAddresses = ([preferredHost] .compactMap { $0 } .filter { !$0.isEmpty }) + resolvedAddresses.filter { !$0.isEmpty } + announcedAddresses + let addresses = announcedRouteAddresses.isEmpty + ? ([fallbackServiceHost].compactMap { $0 }.filter { !$0.isEmpty }) + : announcedRouteAddresses let port = servicePort > 0 ? servicePort : Int(txtRecord["port"] ?? "") ?? 8787 let hostName = [txtRecord["deviceName"], serviceHostName, serviceName] .compactMap(syncNormalizedCommandScopeValue) @@ -7632,6 +7735,9 @@ private final class SyncBonjourBrowser: NSObject, NetServiceBrowserDelegate, Net let key = serviceKey(for: service) guard let host = makeHost(from: service) else { return } hosts[key] = host + if host.addresses.isEmpty && host.tailscaleAddress == nil { + scheduleResolveRetry(for: service) + } publish() } diff --git a/apps/ios/ADE/Views/Settings/ConnectionSettingsView.swift b/apps/ios/ADE/Views/Settings/ConnectionSettingsView.swift index 02c94513a..5d87dd600 100644 --- a/apps/ios/ADE/Views/Settings/ConnectionSettingsView.swift +++ b/apps/ios/ADE/Views/Settings/ConnectionSettingsView.swift @@ -102,7 +102,7 @@ private struct SettingsTailscaleHelpSection: View { Text("Away from home") .font(.subheadline.weight(.semibold)) .foregroundStyle(ADEColor.textPrimary) - Text("Install Tailscale on this iPhone and your ADE machine. Once both are on the same tailnet, the machine appears here like it does on local Wi-Fi.") + Text("Install Tailscale on this iPhone and your ADE machine. Pair once on local Wi-Fi or enter the machine's Tailscale address, then reconnect from the saved machine when you are away.") .font(.caption) .foregroundStyle(ADEColor.textSecondary) .fixedSize(horizontal: false, vertical: true) diff --git a/apps/ios/ADE/Views/Settings/SettingsPinSheet.swift b/apps/ios/ADE/Views/Settings/SettingsPinSheet.swift index 2de604fd0..5a393b3eb 100644 --- a/apps/ios/ADE/Views/Settings/SettingsPinSheet.swift +++ b/apps/ios/ADE/Views/Settings/SettingsPinSheet.swift @@ -35,9 +35,9 @@ struct SettingsPinSheet: View { .accessibilityLabel("Pairing PIN") .accessibilityValue(pin.isEmpty ? "No digits entered" : "\(pin.count) of 6 digits entered") - Text("Shown in ADE Sync settings or by `ade sync pin get`.") - .font(.footnote) - .foregroundStyle(ADEColor.textSecondary) + Text("Set or regenerate it in ADE Sync settings or with `ade sync pin generate`.") + .font(.footnote) + .foregroundStyle(ADEColor.textSecondary) PinKeypad( isDisabled: isSubmitting, @@ -126,14 +126,16 @@ struct SettingsPinSheet: View { Task { @MainActor in switch preset { case .discover(let host): + let selectedHost = latestDiscoveredHost(matching: host) + let routeCandidates = syncPinRouteCandidates(for: selectedHost) await syncService.pairAndConnect( - host: host.addresses.first ?? host.hostName, - port: host.port, + host: routeCandidates.first ?? selectedHost.hostName, + port: selectedHost.port, code: code, - hostIdentity: host.hostIdentity, - hostName: host.hostName, - candidateAddresses: host.addresses, - tailscaleAddress: host.tailscaleAddress + hostIdentity: selectedHost.hostIdentity, + hostName: selectedHost.hostName, + candidateAddresses: routeCandidates, + tailscaleAddress: selectedHost.tailscaleAddress ) case .manual(let host, let port): @@ -162,6 +164,39 @@ struct SettingsPinSheet: View { } } } + + private func latestDiscoveredHost(matching host: DiscoveredSyncHost) -> DiscoveredSyncHost { + syncService.discoveredHosts.first { candidate in + if let identity = syncPinTrimmedNonEmpty(host.hostIdentity), + let candidateIdentity = syncPinTrimmedNonEmpty(candidate.hostIdentity), + identity == candidateIdentity { + return true + } + if candidate.id == host.id || candidate.serviceName == host.serviceName { + return true + } + return candidate.hostName.localizedCaseInsensitiveCompare(host.hostName) == .orderedSame + || candidate.serviceName.localizedCaseInsensitiveCompare(host.hostName) == .orderedSame + } ?? host + } +} + +private func syncPinRouteCandidates(for host: DiscoveredSyncHost) -> [String] { + syncPinUniqueNonEmpty(host.addresses + (host.tailscaleAddress.map { [$0] } ?? [])) +} + +private func syncPinTrimmedNonEmpty(_ value: String?) -> String? { + guard let value = value?.trimmingCharacters(in: .whitespacesAndNewlines), !value.isEmpty else { + return nil + } + return value +} + +private func syncPinUniqueNonEmpty(_ values: [String]) -> [String] { + var seen = Set() + return values + .compactMap(syncPinTrimmedNonEmpty) + .filter { seen.insert($0).inserted } } private struct PinDigitBox: View { diff --git a/apps/ios/ADETests/ADETests.swift b/apps/ios/ADETests/ADETests.swift index 75b15aeb6..4601dd01e 100644 --- a/apps/ios/ADETests/ADETests.swift +++ b/apps/ios/ADETests/ADETests.swift @@ -259,7 +259,12 @@ final class ADETests: XCTestCase { func testSyncRequestTimeoutUsesThirtySecondFriendlyReconnectMessage() { XCTAssertEqual(SyncRequestTimeout.defaultTimeoutNanoseconds, 30_000_000_000) + XCTAssertEqual(SyncRequestTimeout.chatSendTimeoutNanoseconds, 120_000_000_000) XCTAssertEqual(SyncRequestTimeout.error().localizedDescription, "The machine took too long to respond. Reconnecting now.") + XCTAssertEqual( + SyncRequestTimeout.error(message: SyncRequestTimeout.chatSendMessage).localizedDescription, + "The machine is still starting this chat turn. Live updates will keep syncing." + ) } func testSyncRequestTimeoutOnlyReconnectsAfterSocketSilence() { @@ -444,6 +449,22 @@ final class ADETests: XCTestCase { XCTAssertTrue(host.addresses.isEmpty) } + func testBonjourHostUsesServiceHostnameWhenTxtAndResolvedAddressesLag() { + let host = syncDiscoveredHostFromBonjour( + serviceKey: "local|_ade-sync._tcp.|ADE Sync studio 8787", + serviceName: "ADE Sync studio 8787", + serviceHostName: "studio.local.", + servicePort: -1, + txtRecord: [:], + resolvedAddresses: [], + lastResolvedAt: "2026-05-10T10:00:00.000Z" + ) + + XCTAssertEqual(host.hostName, "studio.local.") + XCTAssertEqual(host.port, 8787) + XCTAssertEqual(host.addresses, ["studio.local"]) + } + func testSavedDiscoveredHostsDisplayLiveRuntimeMetadata() { let savedHost = DiscoveredSyncHost( id: "saved-device-1", @@ -674,6 +695,20 @@ final class ADETests: XCTestCase { ) } + func testSyncLoadSampleDoesNotTreatBacklogAsWeakConnection() { + let catchUp = syncConnectionLoadSample(latencyMs: 1, syncLag: 250_000) + XCTAssertFalse(catchUp.isPoor) + XCTAssertFalse(catchUp.isHealthy) + + let slowTransport = syncConnectionLoadSample(latencyMs: 950, syncLag: 0) + XCTAssertTrue(slowTransport.isPoor) + XCTAssertFalse(slowTransport.isHealthy) + + let caughtUp = syncConnectionLoadSample(latencyMs: 1, syncLag: 0) + XCTAssertFalse(caughtUp.isPoor) + XCTAssertTrue(caughtUp.isHealthy) + } + @MainActor func testSyncAutomaticReconnectPrefersSavedTailnetDuringRoam() { let service = SyncService(database: makeDatabase(baseURL: makeTemporaryDirectory())) @@ -1950,6 +1985,101 @@ final class ADETests: XCTestCase { database.close() } + @MainActor + func testSyncServiceAdoptsRuntimeProjectIdForCachedRootOnSameMachine() throws { + let activeProjectIdKey = "ade.sync.activeProjectId" + let activeProjectRootPathKey = "ade.sync.activeProjectRootPath" + let activeProjectHostIdentityKey = "ade.sync.activeProjectHostIdentity" + UserDefaults.standard.set("old-project", forKey: activeProjectIdKey) + UserDefaults.standard.set("/tmp/project-one", forKey: activeProjectRootPathKey) + UserDefaults.standard.set("host-1", forKey: activeProjectHostIdentityKey) + defer { + UserDefaults.standard.removeObject(forKey: activeProjectIdKey) + UserDefaults.standard.removeObject(forKey: activeProjectRootPathKey) + UserDefaults.standard.removeObject(forKey: activeProjectHostIdentityKey) + } + + let database = makeControllerHydrationDatabase(baseURL: makeTemporaryDirectory()) + try database.executeSqlForTesting(""" + insert into projects ( + id, root_path, display_name, default_base_ref, created_at, last_opened_at + ) values + ('old-project', '/tmp/project-one', 'Project One', 'main', '2026-04-22T00:00:00.000Z', '2026-04-22T01:00:00.000Z'); + """) + let service = SyncService(database: database) + XCTAssertEqual(service.activeProjectId, "old-project") + + try service.applyHelloPayloadForTesting([ + "brain": [ + "deviceId": "host-1", + "deviceName": "Mac Studio", + ], + "features": [ + "projectCatalog": true, + ], + "projects": [[ + "id": "runtime-project", + "displayName": "Project One", + "rootPath": "/tmp/project-one/", + "defaultBaseRef": "main", + "lastOpenedAt": "2026-04-22T02:00:00.000Z", + "laneCount": 2, + "isAvailable": true, + "isCached": false, + ]], + ]) + + XCTAssertEqual(service.activeProjectId, "runtime-project") + XCTAssertEqual(service.activeProjectRootPath, "/tmp/project-one") + XCTAssertEqual(database.currentProjectId(), "runtime-project") + XCTAssertEqual(service.projects.map(\.id), ["runtime-project"]) + XCTAssertFalse(service.shouldShowProjectHome) + + database.close() + } + + @MainActor + func testSyncServiceSeedsRuntimeProjectRowBeforeHydration() throws { + let activeProjectIdKey = "ade.sync.activeProjectId" + let activeProjectRootPathKey = "ade.sync.activeProjectRootPath" + UserDefaults.standard.removeObject(forKey: activeProjectIdKey) + UserDefaults.standard.removeObject(forKey: activeProjectRootPathKey) + defer { + UserDefaults.standard.removeObject(forKey: activeProjectIdKey) + UserDefaults.standard.removeObject(forKey: activeProjectRootPathKey) + } + + let database = makeControllerHydrationDatabase(baseURL: makeTemporaryDirectory()) + XCTAssertNil(database.initializationError) + let service = SyncService(database: database) + service.setActiveProjectForTesting(projectId: "runtime-project", rootPath: "/tmp/project-one/") + service.seedRemoteProjectCatalogForTesting([ + MobileProjectSummary( + id: "runtime-project", + displayName: "Project One", + rootPath: "/tmp/project-one/", + defaultBaseRef: "main", + lastOpenedAt: "2026-04-22T02:00:00.000Z", + laneCount: 2, + isAvailable: true, + isCached: false + ), + ]) + + XCTAssertFalse(database.hasProject(id: "runtime-project")) + XCTAssertEqual(database.currentDbVersion(), 0) + + try service.ensureActiveProjectCacheRowForTesting() + + XCTAssertTrue(database.hasProject(id: "runtime-project")) + XCTAssertEqual(database.currentDbVersion(), 0) + XCTAssertEqual(database.listMobileProjects().first?.rootPath, "/tmp/project-one") + XCTAssertEqual(service.projects.first?.id, "runtime-project") + XCTAssertTrue(service.projects.first?.isCached == true) + + database.close() + } + func testDatabasePersistsStableSiteIdAcrossReopen() throws { let baseURL = makeTemporaryDirectory() let database = makeDatabase(baseURL: baseURL) diff --git a/docs/features/ade-code/README.md b/docs/features/ade-code/README.md index 6d75b2928..ab1ee5bc0 100644 --- a/docs/features/ade-code/README.md +++ b/docs/features/ade-code/README.md @@ -13,13 +13,16 @@ It is a client. The runtime, lanes, chats, transcripts, PRs, processes, and proo | `apps/ade-cli/src/tuiClient/app.tsx` | Primary Ink/React surface: navigation, composer, drawers, right pane, session lifecycle, slash command dispatch. | | `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, and project slash-command discovery. | +| `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`), and the Claude steer family (`steerChatMessage`, `cancelSteerMessage`, `editSteerMessage`, `dispatchSteerMessage`). | | `apps/ade-cli/src/tuiClient/commands.ts` / `linearCommands.ts` | Slash command catalog and routing. | | `apps/ade-cli/src/tuiClient/format.ts` | Transcript rendering helpers for the TUI. | -| `apps/ade-cli/src/tuiClient/state.ts` | Persists terminal-client state such as the last selected chat per lane under the project `.ade/cache` layout. | -| `apps/ade-cli/src/tuiClient/theme.ts` | Shared Ink color and status tokens used by the header, model setup pane, transcript, and controls. | +| `apps/ade-cli/src/tuiClient/aggregate.ts` | Pure derivations on top of the chat event stream (e.g. `derivePendingSteers`) consumed by the composer and right-pane steer view. | +| `apps/ade-cli/src/tuiClient/laneTree.ts` | Stack-graph ordering for the lane drawer (`sortLanesForStackGraph`). | +| `apps/ade-cli/src/tuiClient/spinTick.tsx` | Shared monotonic spinner tick provider (`SpinTickProvider`) so every animated glyph in the TUI ticks in lockstep. | +| `apps/ade-cli/src/tuiClient/state.ts` | Persists terminal-client state under `~/.ade/`: the last selected chat per lane (`lastChatByLane`) plus the most recently active lane (`lastLaneId`), used to restore lane focus across launches. | +| `apps/ade-cli/src/tuiClient/theme.ts` | Shared Ink color and status tokens. Mirrors the Claude Design wireframe terminal palette 1:1: surfaces, text levels, brand violets, status (`running`/`attention`/`idle`/`failed`/`primary`), executor brand colors (Claude/Codex/Cursor/OpenCode/Droid + Shell + Copilot), plus helper exports `laneStatusColor`, `agentStatusColor`, `agentStatusGlyph`, and per-provider `glyph` + `wordmark`. | | `apps/ade-cli/src/tuiClient/types.ts` | `AdeCodeConnection`, `ProjectLaunchContext`, navigation DTOs aligned with `apps/desktop/src/shared/types`. | -| `apps/ade-cli/src/tuiClient/components/` | `AdeWordmark`, `Drawer`, `ChatView`, `Header`, `RightPane`, `SlashPalette`, `MentionPalette`, `ApprovalPrompt`, `ModelStatus`, `FooterControls`. | +| `apps/ade-cli/src/tuiClient/components/` | `AdeWordmark`, `Drawer` (with `DrawerPrSummary` rows), `ChatView` (exports `computeChatScrollMaxOffset` and `renderChatTranscriptPlainText` for `/copy`), `Header`, `RightPane`, `SlashPalette`, `MentionPalette`, `ApprovalPrompt`, `ModelStatus`, `FooterControls`. | | `apps/ade-cli/src/tuiClient/keybindings/index.ts` | Verbatim `~/.claude/keybindings.json` reader and TUI action dispatcher (chord support, vim namespace, clipboard-image paste hooks). Resolves `defaultKeybindingsPath()`, parses the Claude keybindings schema, and maps key sequences onto TUI actions. | | `apps/ade-cli/src/tuiClient/statusline/index.ts` | Claude-compatible status line config reader and runner. Reads the `~/.claude/statusline.json` contract, executes the configured status command, and exposes the rendered lines to `ModelStatus`. | | `apps/desktop/src/shared/types/chat.ts` | Canonical chat DTOs (`AgentChatEventEnvelope`, sessions, pending input, `AgentChatContextUsage`, `AgentChatClaudeMcpServerStatus`, `AgentChatClaudeOutputStyle`, `AgentChatClaudePlugin`, subagent kinds). Imported per-module so ade-cli typecheck stays scoped. | @@ -71,7 +74,8 @@ For the embedded runtime there is no `projects.add` step — the in-process runt - **Drawer** (toggled with the configured shortcut) — two sections: Lanes and Chats. Selecting a lane in the Lanes pane switches the active lane and filters the Chats pane to that lane's sessions. 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. - **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. +- **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. For an active lane, the default `lane-details` view shows live git stats (`DiffLineStats` via `diff.listLaneDiffStats`), the linked PR if any (`pr.listPrsByLane`), the most recent tool/command summary, elapsed time for the active turn, and a `worktreeAvailable` guard that surfaces a recoverable warning when a lane's worktree path is missing from disk. +- **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 (`lanes`, `setup`, plus an optional `agents` toggle when subagents/teammates exist) and pane-specific hints (drawer mode lanes/chats, details navigation, chat scroll position, `/steer` reminder when steers are queued). `footerControlsForAvailability(agentsAvailable)` decides which toggles are wired. Heartbeats are kept alive with `startTuiHeartbeat` so the runtime knows the chat client is still attached. @@ -86,15 +90,21 @@ Inline (acts on chat or shell): | `/commit [message]` | Commit lane changes through `git.commit`. | | `/push` | Push the active lane branch. | | `/clear` | Clear the local TUI transcript view. | +| `/copy` | Copy the visible chat transcript (rendered through `renderChatTranscriptPlainText`) to the system clipboard. | | `/end` | End the active chat runtime. | | `/open` | Hand the current ADE context off to desktop via `app/navigate`. | | `/quit` | Exit `ade code`. | | `/remember ` | Write a durable ADE memory entry. | +| `/steer cancel` | Remove the latest staged steer message from the local queue. | +| `/steer edit ` | Edit the latest staged steer message. | +| `/steer send` | Claude only: deliver the latest staged steer inline into the active turn (SDK `dispatchSteer mode: "inline"`). | +| `/steer interrupt` | Claude only: interrupt the active turn and run the latest staged steer next (`dispatchSteer mode: "interrupt"`). | Right pane (open the contextual drawer): | Command | Pane | | --- | --- | +| `/steer` | Show staged steer messages and their delivery state. | | `/new lane` | Lane creation form. | | `/new chat [title]` | New chat in the active lane. | | `/rename [title]` | Rename the active chat. | @@ -153,13 +163,12 @@ Several slash commands forward to a desktop route when issued from `ade code`: ## Project / lane resolution -`chooseInitialLane` (in `tuiClient/project.ts`) picks the active lane on launch: +Lane resolution at launch goes through two helpers in `tuiClient/project.ts`: -1. The lane the user passed via `--lane` (if any). -2. The most recently active lane reported by `lanes.list`. -3. The first lane in the project, falling back to "no lane" when the project has none yet. +1. `chooseInitialLane(lanes, context)` — context-only pick: `--lane` hint, then the lane whose worktree contains the current `workspaceRoot`, then the primary/first lane, falling back to "no lane". +2. `chooseTuiLaunchLane(lanes, context, lastLaneId)` — the actual TUI entry point. If the context lane is explicit (a `--lane` hint, or the user invoked `ade code` from inside a non-primary lane's worktree / attached root), that wins. Otherwise the persisted `AdeCodeState.lastLaneId` from `~/.ade/` wins so reopening the TUI returns to the previously focused lane. Falls back to the context choice when there is no persisted lane. -Lane selection updates the daemon's session state so the same lane is reflected in desktop and iOS clients attached to the same runtime. +Lane selection persists `lastLaneId` and updates the daemon's session state so the same lane is reflected in desktop and iOS clients attached to the same runtime. ## Launch @@ -183,7 +192,7 @@ After local changes, run `npm run build` inside `apps/ade-cli` so both `dist/cli - **Vim namespace.** When vim mode is active, the model-status row exposes the current `insert`/`normal` mode tag and the keybindings dispatcher routes `vim.*` actions. - **Clipboard image paste.** Cross-platform clipboard-image paste is wired into the composer (Linux via `xclip`/`wl-paste`, macOS via `pngpaste`/AppleScript, Windows via PowerShell), so pasting a screenshot uploads it as a Claude attachment alongside text. - **`auto` permission mode.** The Claude permission picker accepts `auto` (mapped onto the SDK `permissionMode: "auto"`) in addition to `default`, `plan`, `acceptEdits`, and `bypassPermissions`. -- **Subagent panel.** The right pane's subagent surface is re-keyed on `agentId` + `parentToolUseId` and split across three tabs — Subagents, Teammates, Background — so spawned Claude agents and background runs are distinguished. Snapshots are reconstructed live from `subagent_*` envelopes via `subagentSnapshotsFromEvents()`. +- **Subagent panel.** The right pane's subagent surface is re-keyed on `agentId` + `parentToolUseId` and split across two tabs — Subagents and Teammates. Background runs render inline within the Subagents tab via a per-row `background` flag rather than a separate tab. Snapshots are reconstructed live from `subagent_*` envelopes (and `teammate.idle` / `task.completed` for teammates) via `subagentSnapshotsFromEvents()`. Each snapshot carries `parentToolUseId`, `turnId`, `startedAt`, `endedAt`, and a derived `durationMs` so rows can show elapsed time even when the runtime did not report `usage.durationMs`. The footer exposes an explicit Agents pane toggle (via `pane:agents` keybinding) when at least one teammate/subagent row exists; absent that, the count surfaces as an inline chip. - **Context, MCP, output styles, plugins.** `/context`, `/mcp`, `/output-style`, and `/plugin` call `chat.getContextUsage`, `chat.getClaudeMcpStatus`, `chat.listClaudeOutputStyles` / `chat.setClaudeOutputStyle`, and `chat.listClaudePlugins` / `chat.reloadClaudePlugins` against the same Claude SDK runtime the desktop chat uses. ## Chat setup diff --git a/docs/features/cto/README.md b/docs/features/cto/README.md index aadea72a0..031081ee0 100644 --- a/docs/features/cto/README.md +++ b/docs/features/cto/README.md @@ -15,7 +15,7 @@ The runtime is organized around one contract: the CTO tab should be usable as a - `workerRevisionService.ts` — worker config revision history. - `workerTaskSessionService.ts` — task-scoped worker sessions. - `workerAdapterRuntimeService.ts` — adapter lifecycle for the three supported worker adapters: `claude-local`, `codex-local`, and `process`. -- `linearCredentialService.ts` — personal API key storage, token status. +- `linearCredentialService.ts` — personal API key + OAuth client + auth-mode storage and token status. Backed by the per-machine credential store (`~/.ade/secrets/`) when injected, with a one-time migration from the legacy project-scoped files. See [Linear integration](../linear-integration/README.md#source-file-map). - `linearOAuthService.ts` — PKCE loopback OAuth flow on port 19836. - `linearClient.ts` — Linear GraphQL client (shared by desktop and headless ADE CLI). - `linearIssueTracker.ts` / `issueTracker.ts` — Linear issue cache and change detection. diff --git a/docs/features/linear-integration/README.md b/docs/features/linear-integration/README.md index ef77831dc..9a517ae09 100644 --- a/docs/features/linear-integration/README.md +++ b/docs/features/linear-integration/README.md @@ -174,7 +174,7 @@ queued Core Linear services on desktop (`apps/desktop/src/main/services/cto/`): -- `linearCredentialService.ts` — token storage + health check +- `linearCredentialService.ts` — token + OAuth client + auth mode storage and health check. Reads/writes through the per-machine `SyncCredentialStore` (`~/.ade/secrets/`) when one is passed in, with a one-time migration from the legacy project-local `linear-token.v1.bin` / `linear-oauth-client.v1.bin` files; falls back to the legacy project-scoped path when the machine store is unavailable. Environment overrides (`ADE_LINEAR_API`, `LINEAR_API_KEY`, `ADE_LINEAR_TOKEN`, `LINEAR_TOKEN`) still take precedence. - `linearOAuthService.ts` — OAuth authorization flow - `linearClient.ts` — GraphQL client wrapper - `linearIssueTracker.ts` — normalization into `NormalizedLinearIssue` diff --git a/docs/features/onboarding-and-settings/README.md b/docs/features/onboarding-and-settings/README.md index fc041b5c3..2eff75d7d 100644 --- a/docs/features/onboarding-and-settings/README.md +++ b/docs/features/onboarding-and-settings/README.md @@ -208,11 +208,19 @@ Renderer — settings: forget paired phones. - `apps/desktop/src/renderer/components/usage/HeaderUsageControl.tsx` and `UsageQuotaPanel.tsx` — header usage popup. Live provider quotas - for Claude / Codex / Cursor and the automation budget guardrails are - now consolidated here; Settings no longer has a Usage tab. The popup - hydrates from `ade.usage.getSnapshot` and re-fetches via the explicit - Refresh control. Budget caps round-trip through - `ade.usage.getBudgetConfig` / `saveBudgetConfig`. + for Claude and Codex (tracked providers) and the automation budget + guardrails are now consolidated here; Settings no longer has a + Usage tab. The header shows weekly-window peaks per tracked + provider with green/amber/red thresholds at 75% / 100%; the panel + drills down into all reset windows, last-poll status, and per- + provider error chips. Cursor usage polling was removed (it required + a team-admin API key that desktop users almost never have); only + `claude` and `codex` are tracked in `TRACKED_PROVIDERS`. The popup + hydrates from `ade.usage.getSnapshot` and re-fetches via the + explicit Refresh control. Budget caps round-trip through + `ade.usage.getBudgetConfig` / `saveBudgetConfig`. Threshold + crossings (25 / 50 / 75 / 100 %) emit `UsageThresholdEvent`s the + notification bus turns into APNs alerts. - `apps/desktop/src/renderer/components/settings/ProxyAndPreviewSection.tsx` — proxy/preview configuration UI. - `apps/desktop/src/renderer/components/settings/DiagnosticsDashboardSection.tsx` diff --git a/docs/features/sync-and-multi-device/README.md b/docs/features/sync-and-multi-device/README.md index c97dac126..0c85208ef 100644 --- a/docs/features/sync-and-multi-device/README.md +++ b/docs/features/sync-and-multi-device/README.md @@ -383,7 +383,13 @@ is not supported. hydrates the full project catalog over the paired WebSocket. The host keeps a signature of `{ hostName, port, txt }` and re-publishes the announcement only when the signature changes, to avoid churn while IP - addresses fluctuate. + addresses fluctuate. On macOS the host also forks a `dns-sd -R + _ade-sync._tcp local ...` child + (`publishNativeLanDiscovery`) so the native mDNSResponder advertises + the service alongside the Node-side `bonjour-service` registration — + iOS Bonjour browsers see the host even when the userland advertiser + is throttled. The native child is killed on shutdown + (`stopNativeLanDiscovery`). - **Machine-scoped pairing state**: phone pairing files live under the machine ADE home (`~/.ade/secrets/`): `sync-device-id`, `sync-bootstrap-token`, `sync-pin.json`, and diff --git a/docs/features/sync-and-multi-device/ios-companion.md b/docs/features/sync-and-multi-device/ios-companion.md index 232bb173e..ecc301949 100644 --- a/docs/features/sync-and-multi-device/ios-companion.md +++ b/docs/features/sync-and-multi-device/ios-companion.md @@ -320,7 +320,13 @@ turns a raw response dict into either the `result` value or throws an `SyncRequestTimeout.defaultTimeoutNanoseconds = 30_000_000_000` (30s). Timed-out requests throw with the message *"The host took too long to -respond. Reconnecting now."* +respond. Reconnecting now."* Chat send commands (`chat.send` and the +mobile CLI launchers) use an extended budget +`SyncRequestTimeout.chatSendTimeoutNanoseconds = 120_000_000_000` (120s) +with the friendlier message *"The machine is still starting this chat +turn. Live updates will keep syncing."* because warmup-heavy turns +routinely outlast the 30 s default without indicating a transport +failure. A request timeout no longer unconditionally drops the socket. Inbound traffic on the WebSocket is timestamped via `lastInboundMessageAt` @@ -595,6 +601,19 @@ falling back to the brand glyph otherwise. The host pre-renders icons to a 64×64 PNG via Electron `nativeImage` before they reach the phone, so the iOS side can decode them with stock UIImage. +When a phone is connected, the remote catalog wins identity ties: +`SyncService.mergeCachedProjects` keeps the remote `projectId` on the +currently active project even when the local cache row carries a +different id (the older `mergedById.removeValue` path was demoting the +remote selection back to the cached id, which broke active-project +detection after a project switch). `Database.upsertMobileProjectCache` +persists each `MobileProjectSummary` into the phone's `projects` table +without capturing local CRR changes (`shouldCaptureLocalChanges = +false`) and normalises the `rootPath` (trim, drop trailing `/`) so +catalog rows from different OS reports of the same path don't +duplicate. Project list dedup runs as a final pass +(`deduplicateProjectListByRoot`) keyed on the normalised root path. + ### Shipped | Tab | Icon | Desktop equivalent | Capabilities |