From 933f01efc186bccb16f6a13adf3852de4ef84b54 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sat, 22 Aug 2026 17:28:08 -0700 Subject: [PATCH 1/2] Paint live agents in chrome above the prompt --- docs/TUI.md | 54 +++--- src/tui/chrome-state.test.ts | 295 +++++++++++++++++++++++-------- src/tui/chrome-state.ts | 291 ++++++++++++++++-------------- src/tui/geometry.test.ts | 7 +- src/tui/geometry/zones.ts | 7 +- src/tui/product-host.test.ts | 46 ++++- src/tui/product-host.ts | 33 ++-- src/tui/runner.ts | 1 + src/tui/runtime-channels.test.ts | 21 +-- src/tui/shell.ts | 28 +-- 10 files changed, 504 insertions(+), 279 deletions(-) diff --git a/docs/TUI.md b/docs/TUI.md index 80e03b853..52e81e106 100644 --- a/docs/TUI.md +++ b/docs/TUI.md @@ -163,15 +163,9 @@ removed line), not a decision marker, and no decision-marker shares that row. ## The live task list panel **Parked pending rebuild.** `formatChromeZones` (`src/tui/chrome-state.ts`) -always returns `{ task: null, agents: null }` — neither the checklist strip nor -the agents/fleet board auto-paints. Live work stays on transcript `● Task …` -rows (see below). `formatTasksPanel` / `formatAgentsPanel` remain for a future -rebuild; demos and shell tests may still feed preformatted rows via -`setChromeZones` directly, and Alt+T (`toggleTasksPanel`) still toggles the -shell's hidden flag for those manual paints. - -A task is a unit of work with a status; an agent is an executor with its own -context and transcript. They are never merged into one panel. When the +keeps the task checklist parked (`task: null`) while the agents strip paints +live. A task is a unit of work with a status; an agent is an executor with its +own context and transcript. They are never merged into one panel. When the checklist strip is rebuilt, each row will show a bracket status marker (`[ ]` todo, `[~]` doing, `[x]` done, `[-]` cancelled) ahead of the title, bounded to `TASKS_PANEL_MAX_VISIBLE` with a trailing `+N more` under overflow, and @@ -194,34 +188,40 @@ The panel stays **hidden by default** (CL-5847): a fresh shell does not paint the checklist. `toggleTasksPanel` (bound to Alt+T) opts in for the shell's lifetime — it flips a hidden flag held on the shell in memory only — so demos and tests that call `setChromeZones` with task rows can still show them. -Because `formatChromeZones` parks auto-paint, Alt+T alone does not surface a -live `manage_tasks` list today. +Because `formatChromeZones` parks task auto-paint, Alt+T alone does not +surface a live `manage_tasks` list today. The task tool writes state through `ChatDirectorImpl` (`src/agent/director.ts`), which calls `onTasksChange` on every `manage_tasks` tool call and on session -hydrate. `manage_tasks` calls paint no transcript rows; with chrome strips +hydrate. `manage_tasks` calls paint no transcript rows; with the checklist parked, that list has no standing chrome surface until rebuild. -## Live sub-agent rows (Task tool) +## Live agents chrome (strip above the prompt) -Live workers paint as pending `task` tool rows in the transcript — the -operator-preferred Amp/Codex-style lines: +Live workers paint as a **flat agents strip** above the prompt (label / +status / current tool) — Amp/Codex-style lanes without a FLEET header board: ``` -● Task Design Lab interview 1:07 · AskUserQuestion -● Task UI variations 0:59 · write_file +● explore map callers · 0:59 · grep +● general write tests · 1:07 · write_file ``` -`runtime-bridge` paints each `task` call as a stream row and rewrites it in -place via `syncAgentProgress` / `agentProgress` (elapsed clock, current tool, -stall marker). Ordinary in-flight tool rows get the same elapsed clock -(`syncToolElapsed`) without the current-tool suffix, so a slow MCP or -network call is distinguishable from a hung turn. There is no standing -FLEET board and no dual-rail agents chrome: -`formatChromeZones` always returns both zones null (`task` and `agents`), and -geometry is stack-only (`layoutMode: "stack"`, `railWidth: 0`). Checklist and -agents strips are parked pending rebuild; Alt+T / direct `setChromeZones` may -still paint for demos and tests. +`formatChromeZones` → `formatAgentsPanel` owns that paint. Geometry stays +stack-only (`layoutMode: "stack"`, `railWidth: 0`); the zone max is +`AGENTS_PANEL_MAX_VISIBLE + 1` (lanes plus a trailing `+N more`). Terminal +lanes (done / failed / cancelled) linger for `AGENTS_PANEL_LINGER_MS` (4s) +after `finishedAt`, then drop. Product-host sticky poll uses +`agentsChromeNeedsSticky` so clocks and linger stay fresh; while sticky is +needed it **does not** call `bridge.syncAgentProgress` — chrome owns the live +clocks. + +### Transcript Task rows (history anchors) + +`runtime-bridge` still paints each `task` call as a transcript stream row for +**spawn / final / fail anchors**. While the agents strip is sticky, sticky-poll +`syncAgentProgress` rewrites are gated off so the transcript is not a dual live +rail. Ordinary in-flight tool rows keep their own elapsed clock +(`syncToolElapsed`) without the current-tool suffix. ### Unprompted fleet reports diff --git a/src/tui/chrome-state.test.ts b/src/tui/chrome-state.test.ts index 755c2da95..9ff5b8a6b 100644 --- a/src/tui/chrome-state.test.ts +++ b/src/tui/chrome-state.test.ts @@ -1,5 +1,8 @@ import { describe, expect, test } from "bun:test" import { + AGENTS_PANEL_LINGER_MS, + agentIsLingering, + agentsChromeNeedsSticky, annotateAgentTools, chromeFromSession, clampBoardRows, @@ -30,7 +33,7 @@ describe("formatChromeZones", () => { }) }) - test("partial: task rows do not auto-paint (zones parked)", () => { + test("partial: task rows stay parked; agents absent stays null", () => { const out = formatChromeZones({ task: [{ title: "cutover readiness", status: "doing" }], }) @@ -38,7 +41,7 @@ describe("formatChromeZones", () => { expect(out.agents).toBeNull() }) - test("running agents: both zones stay null", () => { + test("running agents paint the agents strip; task stays null", () => { const state: ChromeLiveState = { task: [ { title: "chrome live helper", status: "doing" }, @@ -64,12 +67,14 @@ describe("formatChromeZones", () => { ], } const out = formatChromeZones(state, NOW) - // Both strips parked — live work stays on transcript ● Task rows. expect(out.task).toBeNull() - expect(out.agents).toBeNull() + expect(out.agents).not.toBeNull() + expect(out.agents?.[0]?.label).toContain("explore") + expect(out.agents?.[0]?.kind).toBe("lane") + expect(out.agents?.some((r) => r.kind === "header")).toBe(false) }) - test("idle with open checklist still returns null (zones parked)", () => { + test("idle terminal agents without linger hide; open checklist still parked", () => { const out = formatChromeZones( { task: [ @@ -91,7 +96,7 @@ describe("formatChromeZones", () => { expect(out.task).toBeNull() }) - test("observe does not force an agents panel via formatChromeZones", () => { + test("observe replaces the agents strip via formatChromeZones", () => { const out = formatChromeZones( { agents: [ @@ -109,9 +114,64 @@ describe("formatChromeZones", () => { }, NOW, ) - // Both zones always null from formatChromeZones (parked pending rebuild). - expect(out.agents).toBeNull() expect(out.task).toBeNull() + expect(out.agents).toEqual([ + { + label: "observe: explore — map callers of openListOverlay", + tail: "", + stalled: false, + kind: "lane", + status: "running", + }, + ]) + }) +}) + +describe("agentsChromeNeedsSticky / linger", () => { + test("running agents need sticky", () => { + expect( + agentsChromeNeedsSticky( + [ + { + agentId: "a", + description: "x", + status: "running", + currentToolStartedAt: null, + }, + ], + NOW, + ), + ).toBe(true) + }) + + test("terminal inside linger window needs sticky", () => { + const session = { + agentId: "a", + description: "x", + status: "done" as const, + currentToolStartedAt: null, + finishedAt: NOW - 1_000, + } + expect(agentIsLingering(session, NOW)).toBe(true) + expect(agentsChromeNeedsSticky([session], NOW)).toBe(true) + }) + + test("terminal past linger does not need sticky", () => { + const session = { + agentId: "a", + description: "x", + status: "failed" as const, + currentToolStartedAt: null, + finishedAt: NOW - AGENTS_PANEL_LINGER_MS, + } + expect(agentIsLingering(session, NOW)).toBe(false) + expect(agentsChromeNeedsSticky([session], NOW)).toBe(false) + }) + + test("empty / undefined agents do not need sticky", () => { + expect(agentsChromeNeedsSticky(null, NOW)).toBe(false) + expect(agentsChromeNeedsSticky(undefined, NOW)).toBe(false) + expect(agentsChromeNeedsSticky([], NOW)).toBe(false) }) }) @@ -165,7 +225,7 @@ describe("formatAgentsPanel", () => { expect(formatAgentsPanel([], undefined, NOW)).toBeNull() }) - test("a header row leads the board, then one row per running lane", () => { + test("flat list: one row per running lane, no FLEET header", () => { const rows = formatAgentsPanel( [ { agentId: "a", description: "one", status: "running", currentToolStartedAt: null, startedAt: NOW - 1_000, lastActivityAt: NOW }, @@ -175,13 +235,13 @@ describe("formatAgentsPanel", () => { NOW, ) expect(rows).toEqual([ - { label: "FLEET 2 lanes · 2 working", tail: "", stalled: false, kind: "header" }, - { label: "● b two", tail: " · 0:02", stalled: false, kind: "lane" }, - { label: "● a one", tail: " · 0:01", stalled: false, kind: "lane" }, + { label: "● b two", tail: " · 0:02", stalled: false, kind: "lane", status: "running" }, + { label: "● a one", tail: " · 0:01", stalled: false, kind: "lane", status: "running" }, ]) + expect(rows?.some((r) => r.kind === "header")).toBe(false) }) - test("terminal-only list renders zero rows", () => { + test("terminal-only list without finishedAt renders zero rows", () => { expect( formatAgentsPanel( [ @@ -194,6 +254,63 @@ describe("formatAgentsPanel", () => { ).toBeNull() }) + test("terminal rows linger for AGENTS_PANEL_LINGER_MS after finishedAt", () => { + const rows = formatAgentsPanel( + [ + { + agentId: "a", + description: "finished", + status: "done", + currentToolStartedAt: null, + finishedAt: NOW - 1_000, + }, + { + agentId: "b", + description: "failed", + status: "failed", + currentToolStartedAt: null, + finishedAt: NOW - 500, + }, + ], + undefined, + NOW, + ) + expect(rows).toEqual([ + { + label: "! b failed", + tail: " · failed", + stalled: true, + kind: "lane", + status: "failed", + }, + { + label: "● a finished", + tail: " · done", + stalled: false, + kind: "lane", + status: "done", + }, + ]) + }) + + test("linger expires — terminal rows drop after the window", () => { + expect( + formatAgentsPanel( + [ + { + agentId: "a", + description: "old", + status: "done", + currentToolStartedAt: null, + finishedAt: NOW - AGENTS_PANEL_LINGER_MS, + }, + ], + undefined, + NOW, + ), + ).toBeNull() + }) + test("a stalled lane uses ! marker and reports silence via the clock", () => { const rows = formatAgentsPanel( [ @@ -209,15 +326,15 @@ describe("formatAgentsPanel", () => { undefined, NOW, ) - expect(rows?.[1]).toEqual({ - label: "! a quiet worker", - tail: " · 3:00", - stalled: true, - kind: "lane", - }) - expect(rows?.[0]?.label).toContain("1 stalled") - expect(rows?.[0]?.kind).toBe("header") - expect(rows?.[0]?.stalled).toBe(true) + expect(rows).toEqual([ + { + label: "! a quiet worker", + tail: " · 3:00", + stalled: true, + kind: "lane", + status: "running", + }, + ]) }) test("trouble sorts above routine progress", () => { @@ -230,7 +347,7 @@ describe("formatAgentsPanel", () => { NOW, ) // Labels are `● id desc` / `! id desc` — second token is the agentId. - expect(rows?.slice(1).map((r) => r.label.split(/\s+/)[1])).toEqual(["quiet", "fine"]) + expect(rows?.map((r) => r.label.split(/\s+/)[1])).toEqual(["quiet", "fine"]) }) test("bounds fan-out and says how many lanes it is hiding", () => { @@ -243,16 +360,37 @@ describe("formatAgentsPanel", () => { lastActivityAt: NOW, })) const rows = formatAgentsPanel(running, undefined, NOW, 6) - expect(rows).toHaveLength(6) - expect(rows?.[5]).toEqual({ - label: "+4 more lanes", + // maxVisible lanes + trailing +N more + expect(rows).toHaveLength(7) + expect(rows?.[6]).toEqual({ + label: "+2 more", + tail: "", + stalled: false, + kind: "more", + }) + }) + + test("default max paints 10 lanes plus +N more under overflow", () => { + const running = Array.from({ length: 13 }, (_, i) => ({ + agentId: `agent-${i}`, + currentToolStartedAt: null, + description: "working", + status: "running" as const, + startedAt: NOW + i, + lastActivityAt: NOW, + })) + const rows = formatAgentsPanel(running, undefined, NOW) + expect(rows).toHaveLength(11) + expect(rows?.filter((r) => r.kind === "lane")).toHaveLength(10) + expect(rows?.[10]).toEqual({ + label: "+3 more", tail: "", stalled: false, kind: "more", }) }) - test("with too few rows for a disclosure line the header carries the count", () => { + test("overflow always reserves a +N more disclosure row", () => { const running = Array.from({ length: 8 }, (_, i) => ({ agentId: `agent-${i}`, currentToolStartedAt: null, @@ -261,11 +399,15 @@ describe("formatAgentsPanel", () => { startedAt: NOW + i, lastActivityAt: NOW, })) - // A whole row spent on "+N more" would cost more than the lane it displaces. const rows = formatAgentsPanel(running, undefined, NOW, 3) - expect(rows).toHaveLength(3) - expect(rows?.[0]?.tail).toBe(" · +6 hidden") - expect(rows?.some((r) => r.kind === "more")).toBe(false) + expect(rows).toHaveLength(4) + expect(rows?.[3]).toEqual({ + label: "+5 more", + tail: "", + stalled: false, + kind: "more", + }) + expect(rows?.some((r) => r.kind === "header")).toBe(false) }) test("observe empty id+desc hides", () => { @@ -289,7 +431,7 @@ describe("formatAgentsPanel", () => { const rowsAfter = formatAgentsPanel(frame2, undefined, NOW + 200) const ids = (rows: ReturnType) => - rows?.slice(1).map((r) => r.label.split(/\s+/)[1]) + rows?.map((r) => r.label.split(/\s+/)[1]) expect(ids(rowsBefore)).toEqual(ids(rowsAfter)) expect(ids(rowsBefore)).toEqual(["a", "b", "c"]) }) @@ -314,9 +456,11 @@ describe("formatAgentsPanel", () => { lastActivityAt: NOW - 310_000, } const rows = formatAgentsPanel([...newest, stalled], undefined, NOW, 4) - // header + 2 lanes + more (bodyBudget 3, one spent on more → 2 lanes shown) - expect(rows?.[1]?.label.split(/\s+/)[1]).toBe("quiet") - expect(rows?.[1]?.stalled).toBe(true) + // 4 lanes + more (maxVisible lanes kept; fold is an extra row) + expect(rows).toHaveLength(5) + expect(rows?.[0]?.label.split(/\s+/)[1]).toBe("quiet") + expect(rows?.[0]?.stalled).toBe(true) + expect(rows?.[4]?.kind).toBe("more") }) }) @@ -334,8 +478,7 @@ describe("chromeFromSession", () => { description: "map callers", status: "running", currentToolName: "grep", - // Clocks so fleetProgress can count the lane (without them the hybrid - // header would report 0 lanes while the board still paints the row). + // Clocks so the strip can paint elapsed / stall from agentProgress. startedAt: NOW - 5_000, lastActivityAt: NOW, }, @@ -359,9 +502,9 @@ describe("chromeFromSession", () => { ]) const zones = formatChromeZones(state, NOW) - // Both chrome strips parked pending rebuild. expect(zones.task).toBeNull() - expect(zones.agents).toBeNull() + expect(zones.agents).not.toBeNull() + expect(zones.agents?.[0]?.label).toContain("explore") }) test("falls back agent id; empty bags hide", () => { @@ -380,7 +523,7 @@ describe("chromeFromSession", () => { expect(state.agents?.[0]?.agentId).toBe("sess-1") }) - test("observe passes through on the session snapshot; chrome zones stay agents-null", () => { + test("observe passes through and paints the agents strip", () => { const state = chromeFromSession({ observe: { agentId: "explore", description: "watch" }, }) @@ -388,7 +531,15 @@ describe("chromeFromSession", () => { agentId: "explore", description: "watch", }) - expect(formatChromeZones(state, NOW).agents).toBeNull() + expect(formatChromeZones(state, NOW).agents).toEqual([ + { + label: "observe: explore — watch", + tail: "", + stalled: false, + kind: "lane", + status: "running", + }, + ]) }) }) @@ -440,14 +591,13 @@ describe("lane state survives the mapping hops", () => { undefined, NOW, ) - // Board: header first, then the lane. Marker is ● (live); tail carries tool clock. - expect(rows?.[0]?.kind).toBe("header") - expect(rows?.[0]?.label).toContain("in tool") - expect(rows?.[1]?.kind).toBe("lane") - expect(rows?.[1]?.stalled).toBe(false) - expect(rows?.[1]?.label.startsWith("● ")).toBe(true) - expect(rows?.[1]?.tail).toContain("run_shell 3:00") - expect(rows?.[1]?.tail).not.toContain("stalled") + // Flat strip: one lane row, no FLEET header. + expect(rows?.[0]?.kind).toBe("lane") + expect(rows?.[0]?.stalled).toBe(false) + expect(rows?.[0]?.label.startsWith("● ")).toBe(true) + expect(rows?.[0]?.tail).toContain("run_shell 3:00") + expect(rows?.[0]?.tail).not.toContain("stalled") + expect(rows?.some((r) => r.kind === "header")).toBe(false) expect(agentProgress(inTool, NOW)?.stat).toContain("run_shell 3:00") }) @@ -462,8 +612,8 @@ describe("lane state survives the mapping hops", () => { undefined, NOW, ) - expect(rows?.[1]?.tail).toContain("bun test ./src") - expect(rows?.[1]?.tail).not.toContain("run_shell") + expect(rows?.[0]?.tail).toContain("bun test ./src") + expect(rows?.[0]?.tail).not.toContain("run_shell") expect(agentProgress(withPreview, NOW)?.stat).toContain("bun test ./src") expect(agentProgress(withPreview, NOW)?.stat).not.toContain("run_shell") }) @@ -482,11 +632,10 @@ describe("lane state survives the mapping hops", () => { undefined, NOW, ) - expect(rows?.[0]?.kind).toBe("header") - expect(rows?.[0]?.label).toContain("1 stalled") - expect(rows?.[1]?.stalled).toBe(true) - expect(rows?.[1]?.kind).toBe("lane") - expect(rows?.[1]?.label.startsWith("! ")).toBe(true) + expect(rows?.[0]?.kind).toBe("lane") + expect(rows?.[0]?.stalled).toBe(true) + expect(rows?.[0]?.label.startsWith("! ")).toBe(true) + expect(rows?.some((r) => r.kind === "header")).toBe(false) }) // A progress ping renames the tool but carries no clock of its own and may @@ -514,8 +663,8 @@ describe("lane state survives the mapping hops", () => { }) test("a stalled lane with a null tool clock is marked ! with no tool name", () => { - // Inference-wait silence: header counts stalled; lane uses ! marker; - // agentProgress never gap-fills a tool subject. + // Inference-wait silence: flat strip uses ! marker; agentProgress never + // gap-fills a tool subject. const silent = { ...inTool, currentToolName: null, @@ -528,10 +677,9 @@ describe("lane state survives the mapping hops", () => { undefined, NOW, ) - expect(rows?.[0]?.label).toContain("1 stalled") - expect(rows?.[1]?.stalled).toBe(true) - expect(rows?.[1]?.label.startsWith("! ")).toBe(true) - expect(rows?.[1]?.tail).not.toContain("grep") + expect(rows?.[0]?.stalled).toBe(true) + expect(rows?.[0]?.label.startsWith("! ")).toBe(true) + expect(rows?.[0]?.tail).not.toContain("grep") expect(agentProgress(silent, NOW)?.stat).not.toContain("quiet") expect(agentProgress(silent, NOW)?.stat).not.toContain("grep") expect(agentProgress(silent, NOW)?.stat).not.toContain("read_file") @@ -541,38 +689,39 @@ describe("lane state survives the mapping hops", () => { describe("clampBoardRows", () => { test("carries a prior more-row count into a tighter re-clamp", () => { // Formatter already hid 4 of 8; collapse then grants only 4 rows total. - // Honest disclosure is 4 prior + 2 newly dropped = 6, not 2. + // Honest disclosure is 4 prior + 1 newly dropped = 5 (3 lanes + fold). const formatted = [ - { label: "FLEET 8 lanes · 8 working", tail: "", stalled: false, kind: "header" as const }, { label: "● a one", tail: " · 0:01", stalled: false, kind: "lane" as const }, { label: "● b two", tail: " · 0:01", stalled: false, kind: "lane" as const }, { label: "● c three", tail: " · 0:01", stalled: false, kind: "lane" as const }, { label: "● d four", tail: " · 0:01", stalled: false, kind: "lane" as const }, - { label: "+4 more lanes", tail: "", stalled: false, kind: "more" as const }, + { label: "+4 more", tail: "", stalled: false, kind: "more" as const }, ] const clamped = clampBoardRows(formatted, 4) expect(clamped).toHaveLength(4) - expect(clamped[0]?.kind).toBe("header") - expect(clamped[0]?.tail).toBe("") + expect(clamped[0]?.kind).toBe("lane") expect(clamped[3]).toEqual({ - label: "+6 more lanes", + label: "+5 more", tail: "", stalled: false, kind: "more", }) }) - test("under a tight height the header carries the total hidden count", () => { + test("under a tight height the fold still discloses total hidden", () => { const formatted = [ - { label: "FLEET 8 lanes · 8 working", tail: " · +4 hidden", stalled: false, kind: "header" as const }, { label: "● a one", tail: " · 0:01", stalled: false, kind: "lane" as const }, { label: "● b two", tail: " · 0:01", stalled: false, kind: "lane" as const }, + { label: "+4 more", tail: "", stalled: false, kind: "more" as const }, ] const clamped = clampBoardRows(formatted, 2) expect(clamped).toHaveLength(2) // 4 prior + 1 newly dropped lane = 5. - expect(clamped[0]?.tail).toBe(" · +5 hidden") - expect(clamped.some((r) => r.kind === "more")).toBe(false) + expect(clamped[1]).toEqual({ + label: "+5 more", + tail: "", + stalled: false, + kind: "more", + }) }) }) - diff --git a/src/tui/chrome-state.ts b/src/tui/chrome-state.ts index 74a66e7ee..c1af1425b 100644 --- a/src/tui/chrome-state.ts +++ b/src/tui/chrome-state.ts @@ -4,13 +4,14 @@ * Pure: structured session state → task / agents zone rows. * Heights stay with geometry; this module never invents row budgets. * - * ## Parked auto-paint + * ## Agents strip (live) / task checklist (parked) * - * `formatChromeZones` currently always returns `{ task: null, agents: null }` - * — both chrome strips are parked pending rebuild. Live work stays on - * transcript `● Task …` rows. `formatTasksPanel` / `formatAgentsPanel` remain - * for demos, tests, and a future rebuild; manual `setChromeZones` can still - * feed preformatted rows. + * `formatChromeZones` paints the agents zone from `formatAgentsPanel` and keeps + * the task checklist parked (`task: null`). Live fleet status is a flat strip + * above the prompt (label / status / current tool) — same shape as transcript + * `● Task …` anchors, without a FLEET header board. Transcript Task rows remain + * as spawn/final/fail anchors; live progress clocks belong to chrome only + * (product-host gates `syncAgentProgress` while this strip needs a tick). * * ## Product host push contract * @@ -26,22 +27,28 @@ * * Always pass the full snapshot so absent zones clear (`null` hides the zone). * Partial object fields mean “no data” → that zone line is null, not left - * stale. Observe mode can override the agents line via `state.observe` when - * agents chrome is rebuilt. + * stale. Observe mode can override the agents line via `state.observe`. + * Sticky poll continues while any agent is running or still inside the + * post-terminal linger window (`finishedAt` + `AGENTS_PANEL_LINGER_MS`). */ import { agentProgress, - fleetProgress, laneState, DEFAULT_STALL_MS, type AgentProgressSession, - type FleetProgress, type LaneState, } from "./agent-progress.js" import { AGENTS_PANEL_MAX_VISIBLE, TASKS_PANEL_MAX_VISIBLE } from "./geometry/zones.js" import type { ChromeZoneContent } from "./shell.js" +/** + * How long a terminal agent row (done / failed / cancelled) stays on the strip + * after `finishedAt` before dropping. Mid of the 3–5s hold window so success + * and failure share the same glanceable linger. + */ +export const AGENTS_PANEL_LINGER_MS = 4_000 + /** Subagent row shape for the agents chrome panel (store-agnostic). */ export type ChromeAgentSession = { readonly agentId: string @@ -60,6 +67,11 @@ export type ChromeAgentSession = { readonly lastActivityAt?: number /** Clock the oldest outstanding tool call began; separates a long tool from silence. */ readonly currentToolStartedAt: number | null + /** + * When the worker reached a terminal status. Drives the post-finish linger + * window on the strip (`AGENTS_PANEL_LINGER_MS`); absent → no linger paint. + */ + readonly finishedAt?: number } /** Lightweight task row: title + status, as written by the task tool. */ @@ -113,10 +125,14 @@ export type AgentPanelRow = { readonly stalled: boolean /** * What the row is, so the renderer can colour and align it without parsing - * `label`. Absent means a lane row (the default, and every row before the - * board grew a header). + * `label`. Absent means a lane row (the default). */ readonly kind?: "header" | "lane" | "more" + /** + * Lane lifecycle for paint tone. Live running uses primary `UI.text`; + * terminal linger uses done/error/dim. Absent ⇒ treat as live running. + */ + readonly status?: "running" | "done" | "failed" | "cancelled" } /** @@ -137,20 +153,18 @@ export type FormattedChromeZones = { /** * Format structured live state into chrome zone rows for setChromeZones. * - * Both chrome strips (task checklist + agents/fleet board) are parked pending - * rebuild: this always returns `{ task: null, agents: null }` so nothing - * auto-paints in those zones. Live work stays on transcript `● Task …` rows - * (runtime-bridge). `formatTasksPanel` / `formatAgentsPanel` stay intact for - * demos, tests, and a future rebuild; manual `setChromeZones` / Alt+T can still - * feed preformatted rows into the shell. + * Agents strip is live (`formatAgentsPanel`); the task checklist stays parked + * (`task: null`) until a later rebuild. Manual `setChromeZones` / Alt+T can + * still feed preformatted task rows into the shell. */ export function formatChromeZones( state: ChromeLiveState, nowMs: number = Date.now(), ): FormattedChromeZones { - void state - void nowMs - return { task: null, agents: null } + return { + task: null, + agents: formatAgentsPanel(state.agents, state.observe, nowMs), + } } /** @@ -162,6 +176,36 @@ export function chromeZonesContent(state: ChromeLiveState): ChromeZoneContent { return formatChromeZones(state) } +/** + * True while the agents strip still needs wall-clock ticks: any running worker, + * or any terminal row still inside the post-finish linger window. Product-host + * sticky poll uses this both to keep clocks/linger fresh and to freeze + * transcript `syncAgentProgress` rewrites while chrome owns live status. + */ +export function agentsChromeNeedsSticky( + agents: readonly ChromeAgentSession[] | null | undefined, + nowMs: number, + lingerMs: number = AGENTS_PANEL_LINGER_MS, +): boolean { + if (agents === null || agents === undefined) return false + for (const session of agents) { + if (session.status === "running") return true + if (agentIsLingering(session, nowMs, lingerMs)) return true + } + return false +} + +/** Terminal session still inside the glanceable linger window. */ +export function agentIsLingering( + session: ChromeAgentSession, + nowMs: number, + lingerMs: number = AGENTS_PANEL_LINGER_MS, +): boolean { + if (session.status === "running") return false + if (session.finishedAt === undefined) return false + return nowMs - session.finishedAt < lingerMs +} + /** * Format the live task-list panel: one row per task, bounded to `maxVisible` * with a trailing "+N more" row, mirroring `formatAgentsPanel`'s shape but @@ -198,12 +242,14 @@ export function formatTasksPanel( } /** - * Format the live agents panel: one row per running agent, bounded to - * `maxVisible` with a trailing "+N more" row, sourced from the same - * `agentProgress` / `laneState` clock/tool/stall computation the transcript - * trailer uses. Terminal-only sessions (done/failed/cancelled) render no - * rows — the panel shows live work, not a history; Ctrl+E / agents-nav covers - * inspection. + * Format the live agents strip: a flat growing list (label / status / tool), + * bounded to `maxVisible` with a trailing "+N more" row. + * + * No FLEET header — a roll-up board fought the Amp/Codex-style lane list the + * strip is meant to be. Running lanes sort trouble-first via `laneState`; + * terminal sessions linger for `AGENTS_PANEL_LINGER_MS` after `finishedAt` + * (success / fail / cancel share the same window) then drop. Observe mode + * still replaces the whole strip with a single observe row. */ export function formatAgentsPanel( agents: readonly ChromeAgentSession[] | null | undefined, @@ -211,6 +257,7 @@ export function formatAgentsPanel( nowMs: number, maxVisible: number = AGENTS_PANEL_MAX_VISIBLE, stallMs: number = DEFAULT_STALL_MS, + lingerMs: number = AGENTS_PANEL_LINGER_MS, ): readonly AgentPanelRow[] | null { const observeRow = formatObserveRow(observe) if (observeRow !== undefined) return observeRow === null ? null : [observeRow] @@ -218,14 +265,13 @@ export function formatAgentsPanel( if (agents === null || agents === undefined || agents.length === 0) return null const running = agents.filter((s) => s.status === "running") - if (running.length === 0) return null - - // One sort, not two. Both jobs the old pair of sorts did — which lanes - // survive a fan-out, and what order the survivors paint in — want trouble - // first, and neither key here churns: a lane's state changes only when - // something real happens to it, and startedAt never changes at all. Sorting - // by staleness would have reshuffled the board on every tool event. - const ranked = [...running] + const lingering = agents.filter((s) => agentIsLingering(s, nowMs, lingerMs)) + if (running.length === 0 && lingering.length === 0) return null + + // One sort for live lanes. Trouble first; startedAt never churns so the board + // does not reshuffle on every tool event. Lingering terminals trail, newest + // finish first, so a just-completed lane stays glanceable at the bottom edge. + const rankedRunning = [...running] .map((session) => ({ session, state: boardLaneState(session, nowMs, stallMs), @@ -237,39 +283,35 @@ export function formatAgentsPanel( a.session.agentId.localeCompare(b.session.agentId), ) - // The header always costs a row, so it is part of the budget it summarises. - const bodyBudget = Math.max(1, maxVisible - 1) - const hidden = Math.max(0, ranked.length - bodyBudget) - // Below a few body rows, a whole row spent on the hidden count carries less - // than the lane it displaces; the header states it instead. - const countInHeader = hidden > 0 && bodyBudget < 4 - const shown = ranked.slice(0, countInHeader ? bodyBudget : bodyBudget - (hidden > 0 ? 1 : 0)) - const stillHidden = ranked.length - shown.length - - // Fleet roll-up from the same `laneState` path the rows use — never a second - // stall opinion grown in this file. - const fleet = fleetProgress( - running.flatMap((s) => { - const progress = toProgressSession(s) - return progress === null ? [] : [progress] - }), - nowMs, - stallMs, + const rankedLingering = [...lingering].sort( + (a, b) => + (b.finishedAt ?? 0) - (a.finishedAt ?? 0) || + a.agentId.localeCompare(b.agentId), ) - const rows: AgentPanelRow[] = [fleetHeaderRow(fleet, countInHeader ? stillHidden : 0)] - for (const { session, state } of shown) { - rows.push(formatAgentRow(session, state, nowMs, stallMs)) - } - if (stillHidden > 0 && !countInHeader) { - rows.push({ - label: `+${stillHidden} more lanes`, - tail: "", - stalled: false, - kind: "more", - }) + const ranked: AgentPanelRow[] = [ + ...rankedRunning.map(({ session, state }) => + formatAgentRow(session, state, nowMs, stallMs), + ), + ...rankedLingering.map((session) => formatTerminalRow(session)), + ] + + const shown = ranked.slice(0, maxVisible) + const hidden = ranked.length - shown.length + if (hidden > 0) { + // maxVisible lanes + trailing fold → AGENTS_PANEL_MAX_VISIBLE + 1 + // (geometry agents.max). Mirror formatTasksPanel: do not steal a lane slot. + return [ + ...shown, + { + label: `+${hidden} more`, + tail: "", + stalled: false, + kind: "more", + }, + ] } - return rows + return shown } /** @@ -304,18 +346,12 @@ function boardLaneState( } /** - * Fit the board into the rows geometry actually granted it. - * - * The formatter sizes the board to its content, but collapse can grant fewer - * rows than that under pressure. Painting the full set anyway overflows the - * zone's box — rows land on top of each other and on whatever is below. So the - * granted height is the last word, and the lanes it costs are disclosed rather - * than dropped in silence. + * Fit the strip into the rows geometry actually granted it. * - * When the formatter already folded a fan-out (`+N more lanes` or header - * `+N hidden`), that prior count is carried into the re-clamp total so the - * operator still sees every running lane accounted for — not only the ones - * still present as row objects after the first fold. + * The formatter sizes to content, but collapse can grant fewer rows under + * pressure. Painting the full set anyway overflows the zone's box. The granted + * height is the last word; lanes it costs are disclosed rather than dropped + * silently. Prior `+N more` counts are carried into the re-clamp total. */ export function clampBoardRows( rows: readonly AgentPanelRow[], @@ -324,27 +360,18 @@ export function clampBoardRows( if (height <= 0) return [] if (rows.length <= height) return rows - const header = rows[0] - if (header === undefined) return [] - const lanes = rows.filter((r) => r.kind === "lane") + const lanes = rows.filter((r) => r.kind !== "more" && r.kind !== "header") const priorHidden = priorHiddenCount(rows) - // Drop any prior disclosure on the header; we restate the total below. - const cleanHeader = stripHiddenTail(header) - - // Below a few rows the disclosure line costs more than the lane it displaces, - // so the header carries the count instead — the same trade the formatter makes. - if (height < 4) { - const shown = lanes.slice(0, Math.max(0, height - 1)) - const hidden = priorHidden + (lanes.length - shown.length) - return [withHiddenCount(cleanHeader, hidden), ...shown] + + if (height < 2) { + return lanes.slice(0, height) } - const shown = lanes.slice(0, Math.max(0, height - 2)) + const shown = lanes.slice(0, Math.max(0, height - 1)) const hidden = priorHidden + (lanes.length - shown.length) return [ - cleanHeader, ...shown, - { label: `+${hidden} more lanes`, tail: "", stalled: false, kind: "more" }, + { label: `+${hidden} more`, tail: "", stalled: false, kind: "more" }, ] } @@ -353,46 +380,13 @@ function priorHiddenCount(rows: readonly AgentPanelRow[]): number { let hidden = 0 for (const row of rows) { if (row.kind === "more") { - const match = /^\+(\d+) more lanes$/.exec(row.label) - if (match?.[1] !== undefined) hidden += Number(match[1]) - continue - } - if (row.kind === "header") { - const match = / · \+(\d+) hidden$/.exec(row.tail) + const match = /^\+(\d+) more(?: lanes)?$/.exec(row.label) if (match?.[1] !== undefined) hidden += Number(match[1]) } } return hidden } -function stripHiddenTail(header: AgentPanelRow): AgentPanelRow { - const tail = header.tail.replace(/ · \+\d+ hidden$/, "") - return tail === header.tail ? header : { ...header, tail } -} - -function withHiddenCount(header: AgentPanelRow, hidden: number): AgentPanelRow { - return hidden > 0 ? { ...header, tail: ` · +${hidden} hidden` } : header -} - -/** - * The one-line answer to "is everything fine". Counts run worst-first so that - * a narrow terminal ellipsizes away the routine tail rather than the trouble. - * Counts come from main's `fleetProgress`; the FLEET chrome layout is the board. - */ -function fleetHeaderRow(fleet: FleetProgress, hidden: number): AgentPanelRow { - const parts = [`${fleet.running} ${fleet.running === 1 ? "lane" : "lanes"}`] - // Trouble first (matches BOARD_LANE_ORDER); skip zero counts; working last. - if (fleet.stalled > 0) parts.push(`${fleet.stalled} stalled`) - if (fleet.inTool > 0) parts.push(`${fleet.inTool} in tool`) - if (fleet.working > 0) parts.push(`${fleet.working} working`) - return { - label: `FLEET ${parts.join(" · ")}`, - tail: hidden > 0 ? ` · +${hidden} hidden` : "", - stalled: fleet.stalled > 0, - kind: "header", - } -} - function formatObserveRow(observe: ChromeLiveState["observe"]): AgentPanelRow | null | undefined { if (observe === null || observe === undefined) return undefined const id = observe.agentId.trim() @@ -400,7 +394,7 @@ function formatObserveRow(observe: ChromeLiveState["observe"]): AgentPanelRow | if (id.length === 0 && desc.length === 0) return null const label = id.length > 0 && desc.length > 0 ? `${id} — ${desc}` : id.length > 0 ? id : desc - return { label: `observe: ${label}`, tail: "", stalled: false } + return { label: `observe: ${label}`, tail: "", stalled: false, kind: "lane", status: "running" } } function formatAgentRow( @@ -411,7 +405,7 @@ function formatAgentRow( ): AgentPanelRow { const stalled = state === "stalled" // Rail grammar: ● for live work, ! when quiet. The marker names the state so - // the tail stays clock/tool only (variant A single-line lanes). + // the tail stays clock/tool only. const marker = stalled ? "!" : "●" const label = `${marker} ${session.agentId} ${session.description}`.trim() // Prefer the argument subject (command / path) over the bare tool name so a @@ -427,14 +421,15 @@ function formatAgentRow( const progressSession = toProgressSession(session) if (progressSession === null) { - // No clock to report (the host omitted startedAt) — still surface what the - // lane is doing rather than dropping detail the row already has. - return { label, tail: doing !== null ? ` · ${doing}` : "", stalled, kind: "lane" } + return { + label, + tail: doing !== null ? ` · ${doing}` : "", + stalled, + kind: "lane", + status: "running", + } } - // Prefer agentProgress for tool-clock / in_tool / silence clocks so the - // board never invents a second stall path. Tail is clock · tool only — the - // ●/! marker already names the state. const progress = agentProgress(progressSession, nowMs, stallMs) if (progress !== null) { return { @@ -442,10 +437,36 @@ function formatAgentRow( tail: ` · ${progress.stat}`, stalled, kind: "lane", + status: "running", } } - return { label, tail: doing !== null ? ` · ${doing}` : "", stalled, kind: "lane" } + return { + label, + tail: doing !== null ? ` · ${doing}` : "", + stalled, + kind: "lane", + status: "running", + } +} + +function formatTerminalRow(session: ChromeAgentSession): AgentPanelRow { + const failed = session.status === "failed" + const marker = failed ? "!" : "●" + const label = `${marker} ${session.agentId} ${session.description}`.trim() + const word = + session.status === "done" + ? "done" + : session.status === "failed" + ? "failed" + : "cancelled" + return { + label, + tail: ` · ${word}`, + stalled: failed, + kind: "lane", + status: session.status, + } } /** @@ -493,6 +514,7 @@ export type ChromeSessionAgent = { readonly currentToolStartedAt: number | null readonly startedAt?: number readonly lastActivityAt?: number + readonly finishedAt?: number } /** @@ -559,6 +581,7 @@ function mapSessionAgents( ...(a.lastActivityAt !== undefined ? { lastActivityAt: a.lastActivityAt } : {}), + ...(a.finishedAt !== undefined ? { finishedAt: a.finishedAt } : {}), } }) } diff --git a/src/tui/geometry.test.ts b/src/tui/geometry.test.ts index 4c0e8ec1b..9dfe6ac48 100644 --- a/src/tui/geometry.test.ts +++ b/src/tui/geometry.test.ts @@ -107,7 +107,7 @@ describe("resolveGeometry — 80×24 idle floor", () => { describe("resolveGeometry — agents panel", () => { test("agents zone max allows more than one row again", () => { - expect(ZONE_REGISTRY.agents.max).toBe(AGENTS_PANEL_MAX_VISIBLE + 2); + expect(ZONE_REGISTRY.agents.max).toBe(AGENTS_PANEL_MAX_VISIBLE + 1); for (let n = 0; n <= AGENTS_PANEL_MAX_VISIBLE + 3; n++) { const layout = idle80x24({ visibility: { agents: n } }); const fracCap = Math.max(1, Math.floor(24 * FLEET_BOARD_CAP_FRACTION)); @@ -141,14 +141,15 @@ describe("resolveGeometry — agents panel", () => { }); test("a taller terminal honours the agents row request (stack)", () => { + const requested = AGENTS_PANEL_MAX_VISIBLE + 1; const tall = resolveGeometry({ terminal: { columns: 120, rows: 40 }, - visibility: { agents: 14 }, + visibility: { agents: requested }, transcriptFloor: FLEET_TRANSCRIPT_FLOOR, }); expect(tall.layoutMode).toBe("stack"); expect(tall.railWidth).toBe(0); - expect(tall.heights.agents).toBe(14); + expect(tall.heights.agents).toBe(requested); expect(tall.regions.agents?.width).toBe(tall.contentWidth); // Stack: agents sit below transcript and consume vertical chrome. expect(tall.regions.agents!.y).toBeGreaterThan(tall.regions.transcript!.y); diff --git a/src/tui/geometry/zones.ts b/src/tui/geometry/zones.ts index af58b765b..8c0c681ee 100644 --- a/src/tui/geometry/zones.ts +++ b/src/tui/geometry/zones.ts @@ -39,7 +39,7 @@ export type ZoneDeclaration = { * degrades to a trailing "+N more" row instead of growing the zone (and * therefore the chrome budget) without limit. */ -export const AGENTS_PANEL_MAX_VISIBLE = 13; +export const AGENTS_PANEL_MAX_VISIBLE = 10; /** * Share of the terminal the fleet board may take before it starts hiding @@ -106,12 +106,11 @@ export const ZONE_REGISTRY: { readonly [K in ZoneId]: ZoneDeclaration } = { alwaysOn: false, }, // Live agents strip under the transcript when present (max = visible lanes + - // trailing "+N more" + header slack). Live chrome keeps this zone empty — - // fleet status paints as ● Task transcript rows instead. + // trailing "+N more"). Auto-paint comes from formatChromeZones → formatAgentsPanel. agents: { id: "agents", min: 0, - max: AGENTS_PANEL_MAX_VISIBLE + 2, + max: AGENTS_PANEL_MAX_VISIBLE + 1, idleDefault: 0, alwaysOn: false, }, diff --git a/src/tui/product-host.test.ts b/src/tui/product-host.test.ts index e0aeaf7f1..43c057b3d 100644 --- a/src/tui/product-host.test.ts +++ b/src/tui/product-host.test.ts @@ -6,6 +6,7 @@ import { EventEmitter } from "node:events" import { describe, expect, test } from "bun:test" import type { KeyEvent } from "@opentui/core" import type { PermissionRequest } from "../permission/types.js" +import { AGENTS_PANEL_LINGER_MS } from "./chrome-state.js" import { createHarness } from "./harness.js" import { acceptOverlaySelection, closeInsetOverlay, moveOverlaySelection, runOverlayAction } from "./shell.js" import { @@ -302,7 +303,7 @@ describe("mountProductHost", () => { expect(host.shell.streamLog).toEqual([]) }) - test("setChrome with running agents does not paint an agents panel clock", async () => { + test("setChrome with running agents paints an agents panel clock", async () => { const now = Date.now() const { host, renderOnce, captureCharFrame } = await mountHeadless({ chrome: { @@ -320,21 +321,54 @@ describe("mountProductHost", () => { }) try { await renderOnce() - // Fleet board chrome is off — sticky poll must not resurrect an agents - // panel clock from injected chrome state. - expect(captureCharFrame()).not.toContain("0:59") - expect(captureCharFrame()).not.toContain("map callers") + // Live agents strip above the prompt — sticky poll keeps the clock fresh. + expect(captureCharFrame()).toContain("0:59") + expect(captureCharFrame()).toContain("map callers") await new Promise((r) => setTimeout(r, 1_100)) await renderOnce() - expect(captureCharFrame()).not.toMatch(/1:0\d/) + expect(captureCharFrame()).toMatch(/1:0\d/) + expect(captureCharFrame()).toContain("map callers") + } finally { + host.dispose() + } + }) + + test("sticky ticks clear the agents zone after linger without setChrome", async () => { + const now = Date.now() + const { host, renderOnce, captureCharFrame, destroyHarness } = await mountHeadless({ + chrome: { + agents: [ + { + agentId: "explore", + currentToolStartedAt: null, + description: "map callers", + status: "done", + startedAt: now - 10_000, + lastActivityAt: now, + finishedAt: now, + }, + ], + }, + }) + try { + await renderOnce() + expect(host.shell.layout.heights.agents).toBeGreaterThan(0) + expect(captureCharFrame()).toContain("map callers") + + // Only sticky poll may clear — no setChrome. Wait past linger + one tick. + await new Promise((r) => setTimeout(r, AGENTS_PANEL_LINGER_MS + 500)) + await renderOnce() + expect(host.shell.layout.heights.agents).toBe(0) expect(captureCharFrame()).not.toContain("map callers") } finally { host.dispose() + destroyHarness() } }) }) + describe("flat type-to-filter model picker", () => { // Several providers, one (codex) with three accounts, plus a favorite so the // top of the flat list has a reachable pick without typing. diff --git a/src/tui/product-host.ts b/src/tui/product-host.ts index 2d2a14f7f..7de585a6b 100644 --- a/src/tui/product-host.ts +++ b/src/tui/product-host.ts @@ -20,6 +20,7 @@ import { openAddProviderOverlay, openModelPickerOverlay } from "./overlays.js" import { wireGates } from "./gate-wire.js" import { createSystemClipboard } from "./system-clipboard.js" import { + agentsChromeNeedsSticky, formatChromeZones, type ChromeLiveState, } from "./chrome-state.js" @@ -363,31 +364,41 @@ export async function mountProductHost( // The poll outlives the renderer whenever a caller tears the renderer down // without disposing the host. Painting into freed buffers throws, and a host // that can no longer paint has nothing left to keep fresh, so it stands down. + // Track sticky so a true→false falling edge still paints once — otherwise the + // strip never clears when linger expires without a store notify. + let stickyWasNeeded = + chromeState !== null && + agentsChromeNeedsSticky(chromeState.agents, Date.now()) const stickyPoll = setInterval(() => { if (disposed) return try { paintChrome(shell) - if (config.subAgentSessions !== undefined) { + const stickyNeeded = + chromeState !== null && + agentsChromeNeedsSticky(chromeState.agents, Date.now()) + // While the agents strip owns live clocks / linger, skip transcript + // syncAgentProgress rewrites — spawn/final/fail anchors still arrive via + // event paths; only the sticky clock tick is frozen here. + if (config.subAgentSessions !== undefined && !stickyNeeded) { bridge.syncAgentProgress(config.subAgentSessions()) } - // The agents panel's elapsed clock and stalled flag are a function of - // wall time, not just of the last event — repaint on the same tick as - // the transcript trailer so a worker that goes quiet still flips to - // "stalled" without waiting on an unrelated chrome push. Gated on a - // running agent existing: paintChromeZones() re-enters setChromeZones, - // which already calls paintChrome(shell) on its own unchanged-zone - // path, so calling it unconditionally would repaint chrome twice a - // tick for the common case (task only, no agents) that has nothing - // time-based to refresh. - if (chromeState !== null && (chromeState.agents ?? []).some((a) => a.status === "running")) { + // Elapsed clock, stall flip, and post-finish linger are wall-time — repaint + // the strip on this tick while sticky is needed. paintChromeZones re-enters + // setChromeZones (which may paintChrome again on an unchanged-zone path), + // so gate on sticky rather than calling it every tick for idle chrome. + // Falling edge (stickyWasNeeded && !stickyNeeded) clears the zone when + // formatAgentsPanel returns null after linger without a setChrome push. + if (stickyNeeded || stickyWasNeeded) { paintChromeZones() } + stickyWasNeeded = stickyNeeded } catch { clearInterval(stickyPoll) } }, 200) if (typeof stickyPoll.unref === "function") stickyPoll.unref() + function dispose(): void { if (disposed) return disposed = true diff --git a/src/tui/runner.ts b/src/tui/runner.ts index f994d39da..6e4347990 100644 --- a/src/tui/runner.ts +++ b/src/tui/runner.ts @@ -2276,6 +2276,7 @@ export async function runTUI(initialConfig: Config): Promise { currentToolStartedAt: s.currentToolStartedAt, startedAt: s.startedAt, lastActivityAt: s.lastActivityAt, + ...(s.finishedAt !== undefined ? { finishedAt: s.finishedAt } : {}), })), }), subscribeChrome: (notify) => { diff --git a/src/tui/runtime-channels.test.ts b/src/tui/runtime-channels.test.ts index c4754b6ee..e0573e78a 100644 --- a/src/tui/runtime-channels.test.ts +++ b/src/tui/runtime-channels.test.ts @@ -197,8 +197,8 @@ describe("permission.grant channel", () => { }) }) -describe("agents chrome (zone off — transcript Task rows own live lanes)", () => { - test("setChrome with running agents does not paint an agents zone", async () => { +describe("agents chrome (live strip above the prompt)", () => { + test("setChrome with running agents paints the agents zone", async () => { const { host, frame, cleanup } = await mountHeadless({ chrome: { agents: [ @@ -208,24 +208,23 @@ describe("agents chrome (zone off — transcript Task rows own live lanes)", () status: "running", currentToolName: "grep", currentToolStartedAt: null, + startedAt: Date.now() - 5_000, + lastActivityAt: Date.now(), }, ], }, }) try { - // Fleet board chrome is off: live lane status rides transcript Task rows, - // not a dedicated agents zone. Injecting agents into chrome must not paint - // them into the frame or the transcript. const painted = await frame() - expect(painted).not.toContain("map callers") - expect(painted).not.toContain("grep") + expect(painted).toContain("map callers") + expect(painted).toContain("explore") expect(host.shell.streamLog).toEqual([]) } finally { cleanup() } }) - test("a later chrome push still leaves the agents zone empty", async () => { + test("a later chrome push paints the agents zone", async () => { const { host, frame, cleanup } = await mountHeadless() try { host.setChrome({ @@ -236,12 +235,14 @@ describe("agents chrome (zone off — transcript Task rows own live lanes)", () status: "running", currentToolName: "grep", currentToolStartedAt: null, + startedAt: Date.now() - 5_000, + lastActivityAt: Date.now(), }, ], }) const painted = await frame() - expect(painted).not.toContain("map callers") - expect(painted).not.toContain("grep") + expect(painted).toContain("map callers") + expect(painted).toContain("explore") } finally { cleanup() } diff --git a/src/tui/shell.ts b/src/tui/shell.ts index 5d736789d..361f85ec4 100644 --- a/src/tui/shell.ts +++ b/src/tui/shell.ts @@ -4578,6 +4578,15 @@ function renderTasksRows( } } +/** Paint tone for one agents-strip row (cream live / orange trouble / green done). */ +function agentRowFg(row: AgentPanelRow): string { + if (row.kind === "more" || row.kind === "header") return UI.textDim + if (row.stalled || row.status === "failed") return UI.action + if (row.status === "done") return UI.done + if (row.status === "cancelled") return UI.textDim + return UI.text +} + /** Rebuild agentsBox's row children to match the requested rows exactly. */ function renderAgentsRows( shell: AppShell, @@ -4589,18 +4598,12 @@ function renderAgentsRows( destroySubtree(child) } for (const row of rows) { - // Bronze for a live working lane (inFlight), red for a stalled one — the - // ●/! marker already names the state, the hue only carries the urgency. - // The "+N more" fold-away row is chrome about the strip, not a lane in it, - // so it sits back in dim and leaves the colour to the work. + // Live lanes use primary cream (`UI.text`) — the Amp/Codex strip is body + // text, not bronze in-flight chrome. Stalled / failed keep the decision + // orange; done linger is green; cancelled / "+N more" sit back in dim. const text = new TextRenderable(shell.renderer as CliRenderer, { content: fitAgentRow(row, maxWidth), - fg: - row.kind === "more" - ? UI.textDim - : row.stalled - ? UI.action - : UI.inFlight, + fg: agentRowFg(row), }) shell.agentsBox.add(text) } @@ -4645,9 +4648,12 @@ export function setChromeZones( prev === undefined || row.label !== prev.label || row.tail !== prev.tail || - row.stalled !== prev.stalled + row.stalled !== prev.stalled || + row.status !== prev.status || + row.kind !== prev.kind ) }) + bag.chrome.agents = next } From 6331c3f418f0a9d65a466229bd6dde1a6342c0b4 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sat, 22 Aug 2026 20:49:15 -0700 Subject: [PATCH 2/2] Prefer interfaces for chrome snapshot object shapes --- src/tui/chrome-state.ts | 36 ++++++++++++++++++------------------ 1 file changed, 18 insertions(+), 18 deletions(-) diff --git a/src/tui/chrome-state.ts b/src/tui/chrome-state.ts index 89e3eb10f..01181f3b9 100644 --- a/src/tui/chrome-state.ts +++ b/src/tui/chrome-state.ts @@ -50,7 +50,7 @@ import type { ChromeZoneContent } from "./shell.js"; export const AGENTS_PANEL_LINGER_MS = 4_000; /** Subagent row shape for the agents chrome panel (store-agnostic). */ -export type ChromeAgentSession = { +export interface ChromeAgentSession { readonly agentId: string; readonly description: string; readonly status: "running" | "done" | "failed" | "cancelled"; @@ -72,29 +72,29 @@ export type ChromeAgentSession = { * window on the strip (`AGENTS_PANEL_LINGER_MS`); absent → no linger paint. */ readonly finishedAt?: number; -}; +} /** Lightweight task row: title + status, as written by the task tool. */ -export type ChromeTaskRow = { +export interface ChromeTaskRow { readonly title: string; readonly status: "todo" | "doing" | "done" | "cancelled"; -}; +} /** * One rendered task-panel row. `status` is null for a non-task row (the * "+N more" trailer, or a bare-string task input with no structured status) * so the renderer knows not to paint a status marker for it. */ -export type TaskPanelRow = { +export interface TaskPanelRow { readonly label: string; readonly status: "todo" | "doing" | "done" | "cancelled" | null; -}; +} /** * Full live chrome snapshot. Missing / null fields hide that zone. * Prefer pushing a complete snapshot on every update. */ -export type ChromeLiveState = { +export interface ChromeLiveState { /** * Task list: the structured rows the task tool writes. Distinct from * `agents` — a task is a unit of work with a status, not an executor. @@ -110,7 +110,7 @@ export type ChromeLiveState = { readonly agentId: string; readonly description: string; } | null; -}; +} /** * One rendered agents-panel row. `stalled` is a fact the formatter already @@ -119,7 +119,7 @@ export type ChromeLiveState = { * marker, agentId + description) is the part the renderer may ellipsize under * width pressure; `tail` (clock/tool) must never be trimmed away. */ -export type AgentPanelRow = { +export interface AgentPanelRow { readonly label: string; readonly tail: string; readonly stalled: boolean; @@ -133,7 +133,7 @@ export type AgentPanelRow = { * terminal linger uses done/error/dim. Absent ⇒ treat as live running. */ readonly status?: "running" | "done" | "failed" | "cancelled"; -}; +} /** * Board paint order for main's `LaneState` vocabulary — trouble first so an @@ -143,12 +143,12 @@ export type AgentPanelRow = { const BOARD_LANE_ORDER: readonly LaneState[] = ["stalled", "in_tool", "working"]; /** Always-populated result for setChromeZones (null = hide zone). */ -export type FormattedChromeZones = { +export interface FormattedChromeZones { /** One row per rendered task-panel line (null = hide zone, zero rows). */ readonly task: readonly TaskPanelRow[] | null; /** One row per rendered agents-panel line (null = hide zone, zero rows). */ readonly agents: readonly AgentPanelRow[] | null; -}; +} /** * Format structured live state into chrome zone rows for setChromeZones. @@ -479,16 +479,16 @@ export function annotateAgentTools( // --------------------------------------------------------------------------- /** manage_tasks / Task-shaped row (title + status). */ -export type ChromeSessionTask = { +export interface ChromeSessionTask { readonly title: string; readonly status: "todo" | "doing" | "done" | "cancelled"; -}; +} /** * SubAgentSession-shaped strip row. `agentId` preferred; falls back to `id` * when the store only exposes a session id. */ -export type ChromeSessionAgent = { +export interface ChromeSessionAgent { readonly agentId?: string; readonly id?: string; readonly description: string; @@ -499,16 +499,16 @@ export type ChromeSessionAgent = { readonly startedAt?: number; readonly lastActivityAt?: number; readonly finishedAt?: number; -}; +} /** * Live session bags the product host already holds. Missing fields omit zones. */ -export type ChromeSessionInput = { +export interface ChromeSessionInput { readonly tasks?: readonly ChromeSessionTask[] | null; readonly agents?: readonly ChromeSessionAgent[] | null; readonly observe?: ChromeLiveState["observe"]; -}; +} /** * Map real session shapes (tasks / subagent store) into ChromeLiveState for