diff --git a/docs/TUI.md b/docs/TUI.md index 20b2d8ea4..a57c17f03 100644 --- a/docs/TUI.md +++ b/docs/TUI.md @@ -173,15 +173,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 @@ -204,34 +198,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 ce2d8b826..7e92f953d 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( [ { @@ -189,13 +249,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( [ @@ -208,6 +268,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( [ @@ -223,15 +340,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", () => { @@ -258,7 +375,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", () => { @@ -271,16 +388,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("with too few rows for a disclosure line the header carries the count", () => { + 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("overflow always reserves a +N more disclosure row", () => { const running = Array.from({ length: 8 }, (_, i) => ({ agentId: `agent-${i}`, currentToolStartedAt: null, @@ -289,11 +427,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", () => { @@ -336,7 +478,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"]); }); @@ -361,9 +503,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"); }); }); @@ -381,8 +525,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, }, @@ -406,9 +549,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", () => { @@ -427,7 +570,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" }, }); @@ -435,7 +578,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", + }, + ]); }); }); @@ -486,14 +637,13 @@ describe("lane state survives the mapping hops", () => { expect(laneState(inTool, NOW)).toBe("in_tool"); const rows = formatAgentsPanel(chromeFromSession({ agents: [inTool] }).agents, 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"); }); @@ -508,8 +658,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"); }); @@ -524,11 +674,10 @@ describe("lane state survives the mapping hops", () => { expect(laneState(silent, NOW)).toBe("stalled"); const rows = formatAgentsPanel(chromeFromSession({ agents: [silent] }).agents, 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 @@ -550,8 +699,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, @@ -560,10 +709,9 @@ describe("lane state survives the mapping hops", () => { lastActivityAt: NOW - 310_000, }; const rows = formatAgentsPanel(chromeFromSession({ agents: [silent] }).agents, 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"); @@ -573,42 +721,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 fda657782..01181f3b9 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 interface ChromeAgentSession { readonly agentId: string; @@ -60,6 +67,11 @@ export interface 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 interface 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 interface 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,31 @@ 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; } /** @@ -300,18 +338,12 @@ function boardLaneState(session: ChromeAgentSession, nowMs: number, stallMs: num } /** - * 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[], @@ -320,28 +352,16 @@ 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" }, - ]; + return [...shown, { label: `+${hidden} more`, tail: "", stalled: false, kind: "more" }]; } /** Lanes already disclosed by a prior format/clamp fold on these rows. */ @@ -349,53 +369,20 @@ 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(); const desc = observe.description.trim(); 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( @@ -406,7 +393,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 @@ -422,14 +409,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 { @@ -437,10 +425,32 @@ 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, + }; } /** @@ -488,6 +498,7 @@ export interface ChromeSessionAgent { readonly currentToolStartedAt: number | null; readonly startedAt?: number; readonly lastActivityAt?: number; + readonly finishedAt?: number; } /** @@ -548,6 +559,7 @@ function mapSessionAgents( currentToolStartedAt: a.currentToolStartedAt, ...(a.startedAt !== undefined ? { startedAt: a.startedAt } : {}), ...(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 05dec0fbd..17ea2a524 100644 --- a/src/tui/geometry.test.ts +++ b/src/tui/geometry.test.ts @@ -105,7 +105,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)); @@ -137,14 +137,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 c86428bf6..04a692db7 100644 --- a/src/tui/geometry/zones.ts +++ b/src/tui/geometry/zones.ts @@ -39,7 +39,7 @@ export interface 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> = { 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 35c4a5be9..a7cce5e6b 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, @@ -296,7 +297,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: { @@ -314,17 +315,49 @@ 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(); } }); }); diff --git a/src/tui/product-host.ts b/src/tui/product-host.ts index 984bc92f6..1008ddf7c 100644 --- a/src/tui/product-host.ts +++ b/src/tui/product-host.ts @@ -19,7 +19,11 @@ import { import { openAddProviderOverlay, openModelPickerOverlay } from "./overlays.js"; import { wireGates } from "./gate-wire.js"; import { createSystemClipboard } from "./system-clipboard.js"; -import { formatChromeZones, type ChromeLiveState } from "./chrome-state.js"; +import { + agentsChromeNeedsSticky, + formatChromeZones, + type ChromeLiveState, +} from "./chrome-state.js"; import { grantApproval, grantNotice, @@ -348,25 +352,32 @@ export async function mountProductHost(config: ProductHostConfig): Promise { 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); } diff --git a/src/tui/runner.ts b/src/tui/runner.ts index 65a94c78e..b50857ab2 100644 --- a/src/tui/runner.ts +++ b/src/tui/runner.ts @@ -2334,6 +2334,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 34135cbf1..b09784cd4 100644 --- a/src/tui/runtime-channels.test.ts +++ b/src/tui/runtime-channels.test.ts @@ -193,8 +193,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: [ @@ -204,24 +204,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({ @@ -232,12 +231,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 daaa7ce65..0d24625ab 100644 --- a/src/tui/shell.ts +++ b/src/tui/shell.ts @@ -4389,6 +4389,15 @@ function renderTasksRows(shell: AppShell, rows: readonly TaskPanelRow[], maxWidt } } +/** 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, rows: readonly AgentPanelRow[], maxWidth: number): void { for (const child of [...shell.agentsBox.getChildren()]) { @@ -4396,13 +4405,12 @@ function renderAgentsRows(shell: AppShell, rows: readonly AgentPanelRow[], maxWi 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); } @@ -4444,7 +4452,9 @@ export function setChromeZones(shell: AppShell, content: ChromeZoneContent): voi 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;