diff --git a/src/tui-opentui/chrome-state.test.ts b/src/tui-opentui/chrome-state.test.ts index 6959cc2c3..6bf95d80d 100644 --- a/src/tui-opentui/chrome-state.test.ts +++ b/src/tui-opentui/chrome-state.test.ts @@ -2,6 +2,7 @@ import { describe, expect, test } from "bun:test" import { annotateAgentTools, chromeFromSession, + clampBoardRows, formatAgentsPanel, formatChromeZones, formatTasksPanel, @@ -68,11 +69,15 @@ describe("formatChromeZones", () => { { label: "wire chrome zone", status: "todo" }, { label: "wire agents zone", status: "todo" }, ]) + // Hybrid: board FLEET header + kind; tail is `state · agentProgress.stat` + // (elapsed · tool), not the branch's tool-first wording. expect(out.agents).toEqual([ + { label: "FLEET 1 lane · 1 working", tail: "", stalled: false, kind: "header" }, { label: "explore: map setChromeZones callers", - tail: " · 0:05 · grep", + tail: " · working · 0:05 · grep", stalled: false, + kind: "lane", }, ]) }) @@ -158,7 +163,7 @@ describe("formatAgentsPanel", () => { expect(formatAgentsPanel([], undefined, NOW)).toBeNull() }) - test("one row per running agent, oldest-started first", () => { + test("a header row leads the board, then one row per running lane", () => { const rows = formatAgentsPanel( [ { agentId: "a", description: "one", status: "running", currentToolStartedAt: null, startedAt: NOW - 1_000, lastActivityAt: NOW }, @@ -168,9 +173,9 @@ describe("formatAgentsPanel", () => { NOW, ) expect(rows).toEqual([ - { label: "2 agents", tail: "", stalled: false }, - { label: "b: two", tail: " · 0:02", stalled: false }, - { label: "a: one", tail: " · 0:01", stalled: false }, + { label: "FLEET 2 lanes · 2 working", tail: "", stalled: false, kind: "header" }, + { label: "b: two", tail: " · working · 0:02", stalled: false, kind: "lane" }, + { label: "a: one", tail: " · working · 0:01", stalled: false, kind: "lane" }, ]) }) @@ -187,7 +192,7 @@ describe("formatAgentsPanel", () => { ).toBeNull() }) - test("stalled agent is visually distinct in its label", () => { + test("a stalled lane names its state and reports how long it has been silent", () => { const rows = formatAgentsPanel( [ { @@ -202,25 +207,63 @@ describe("formatAgentsPanel", () => { undefined, NOW, ) - expect(rows).toEqual([ - { label: "a: quiet worker", tail: " · 1:00 · quiet 0:40 · stalled", stalled: true }, - ]) + // Hybrid uses main's agentProgress wording (`quiet`, with lifetime in the + // stat) under the board's `stalled · …` prefix — not branch `silent`. + expect(rows?.[1]).toEqual({ + label: "a: quiet worker", + tail: " · stalled · 1:00 · quiet 0:40", + stalled: true, + kind: "lane", + }) + expect(rows?.[0]?.label).toContain("1 stalled") + expect(rows?.[0]?.kind).toBe("header") }) - test("bounds fan-out to maxVisible plus a +N more row", () => { + test("trouble sorts above routine progress", () => { + const rows = formatAgentsPanel( + [ + { agentId: "fine", description: "busy", status: "running", currentToolStartedAt: null, startedAt: NOW - 1_000, lastActivityAt: NOW }, + { agentId: "quiet", description: "silent", status: "running", currentToolStartedAt: null, startedAt: NOW - 90_000, lastActivityAt: NOW - 60_000 }, + ], + undefined, + NOW, + ) + expect(rows?.slice(1).map((r) => r.label.split(":")[0])).toEqual(["quiet", "fine"]) + }) + + test("bounds fan-out and says how many lanes it is hiding", () => { + const running = Array.from({ length: 8 }, (_, i) => ({ + agentId: `agent-${i}`, + currentToolStartedAt: null, + description: "working", + status: "running" as const, + startedAt: NOW + i, + lastActivityAt: NOW, + })) + const rows = formatAgentsPanel(running, undefined, NOW, 6) + expect(rows).toHaveLength(6) + expect(rows?.[5]).toEqual({ + label: "+4 more lanes", + tail: "", + stalled: false, + kind: "more", + }) + }) + + test("with too few rows for a disclosure line the header carries the count", () => { const running = Array.from({ length: 8 }, (_, i) => ({ agentId: `agent-${i}`, currentToolStartedAt: null, description: "working", status: "running" as const, - startedAt: NOW, + startedAt: NOW + i, lastActivityAt: NOW, })) - const rows = formatAgentsPanel(running, undefined, NOW, 5) - // Fleet summary, five lanes, then the fold-away row. - expect(rows?.[0]).toEqual({ label: "8 agents", tail: "", stalled: false }) - expect(rows).toHaveLength(7) - expect(rows?.[6]).toEqual({ label: "+3 more", tail: "", stalled: false }) + // 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) }) test("observe empty id+desc hides", () => { @@ -230,10 +273,9 @@ describe("formatAgentsPanel", () => { }) test("row order is stable across an activity update between frames", () => { - // Selection may key on staleness (lastActivityAt), but presentation must - // not: lastActivityAt is the field a tool event updates most often, so - // keying the visible row order on it would reshuffle the panel every - // time any agent made progress — unreadable at a busy 200ms repaint. + // Neither sort key churns: a lane's state changes only when something real + // happens to it, and startedAt never changes at all. Keying on + // lastActivityAt would reshuffle the board on every tool event. const frame1 = [ { agentId: "b", description: "second", status: "running" as const, currentToolStartedAt: null, startedAt: NOW - 1_000, lastActivityAt: NOW - 1_000 }, { agentId: "a", description: "first", status: "running" as const, currentToolStartedAt: null, startedAt: NOW - 2_000, lastActivityAt: NOW - 2_000 }, @@ -241,30 +283,24 @@ describe("formatAgentsPanel", () => { ] const rowsBefore = formatAgentsPanel(frame1, undefined, NOW) - // Same agents, one tick later: "b" reported activity (its lastActivityAt - // moved), the others did not. startedAt — what row order actually keys - // on — is unchanged for all three. const frame2 = frame1.map((a) => (a.agentId === "b" ? { ...a, lastActivityAt: NOW + 200 } : a)) const rowsAfter = formatAgentsPanel(frame2, undefined, NOW + 200) - const lanes = (rows: readonly { label: string }[] | null) => - rows?.slice(1).map((r) => r.label.split(":")[0]) - expect(lanes(rowsBefore)).toEqual(lanes(rowsAfter)) - // Sanity: presentation order is oldest-started first (a, b, c), matching - // the tiebreak-free startedAt sort. - expect(lanes(rowsBefore)).toEqual(["a", "b", "c"]) + expect(rowsBefore?.map((r) => r.label.split(":")[0])).toEqual( + rowsAfter?.map((r) => r.label.split(":")[0]), + ) + expect(rowsBefore?.slice(1).map((r) => r.label.split(":")[0])).toEqual(["a", "b", "c"]) }) - test("a stalled agent stays visible over newer agents when the fan-out is truncated", () => { - // The real feed (listForStrip) sorts running agents newest-first; the - // panel must not blindly take that order, or the one worker most likely - // to need attention is exactly the one that gets folded into "+N more". + test("a stalled lane survives a truncated fan-out", () => { + // The real feed sorts newest-first; the board must not take that order, or + // the one lane most likely to need attention is exactly the one hidden. const newest = Array.from({ length: 5 }, (_, i) => ({ agentId: `fresh-${i}`, currentToolStartedAt: null, description: "just started", status: "running" as const, - startedAt: NOW, + startedAt: NOW + i, lastActivityAt: NOW, })) const stalled = { @@ -275,12 +311,12 @@ describe("formatAgentsPanel", () => { startedAt: NOW - 300_000, lastActivityAt: NOW - 250_000, } - const rows = formatAgentsPanel([...newest, stalled], undefined, NOW, 5) + const rows = formatAgentsPanel([...newest, stalled], undefined, NOW, 4) expect(rows?.some((r) => r.label.includes("quiet"))).toBe(true) expect(rows?.some((r) => r.stalled)).toBe(true) - expect(rows).toHaveLength(7) - expect(rows?.[0]).toEqual({ label: "6 agents · 1 stalled", tail: "", stalled: true }) - expect(rows?.[6]).toEqual({ label: "+1 more", tail: "", stalled: false }) + // And the ones it could not show are still accounted for. + expect(rows?.[0]?.label).toContain("6 lanes") + expect(rows?.[0]?.label).toContain("1 stalled") }) }) @@ -298,6 +334,10 @@ 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). + startedAt: NOW - 5_000, + lastActivityAt: NOW, }, ], }) @@ -313,6 +353,8 @@ describe("chromeFromSession", () => { description: "map callers", status: "running", currentToolName: "grep", + startedAt: NOW - 5_000, + lastActivityAt: NOW, }, ]) @@ -322,7 +364,8 @@ describe("chromeFromSession", () => { { label: "export index", status: "todo" }, ]) expect(zones.agents).toEqual([ - { label: "explore: map callers", tail: " · grep", stalled: false }, + { label: "FLEET 1 lane · 1 working", tail: "", stalled: false, kind: "header" }, + { label: "explore: map callers", tail: " · working · 0:05 · grep", stalled: false, kind: "lane" }, ]) }) @@ -405,9 +448,16 @@ describe("lane state survives the mapping hops", () => { undefined, NOW, ) - expect(rows?.[0]?.stalled).toBe(false) - expect(rows?.[0]?.tail).toContain("run_shell 1:30") - expect(rows?.[0]?.tail).not.toContain("stalled") + // Board: header first, then the lane. Operator copy uses "in tool", not + // the machine LaneState token. + 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]?.tail).toContain("in tool") + expect(rows?.[1]?.tail).not.toContain("in_tool") + expect(rows?.[1]?.tail).toContain("run_shell 1:30") + expect(rows?.[1]?.tail).not.toContain("stalled") expect(agentProgress(inTool, NOW)?.stat).toContain("run_shell 1:30") }) @@ -421,7 +471,10 @@ describe("lane state survives the mapping hops", () => { undefined, NOW, ) - expect(rows?.[0]?.stalled).toBe(true) + 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") }) // A progress ping renames the tool but carries no clock of its own, so it @@ -445,3 +498,42 @@ describe("lane state survives the mapping hops", () => { expect(annotated.agents?.[0]?.currentToolStartedAt).toBeNull() }) }) + +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. + const formatted = [ + { label: "FLEET 8 lanes · 8 working", tail: "", stalled: false, kind: "header" as const }, + { label: "a: one", tail: " · working · 0:01", stalled: false, kind: "lane" as const }, + { label: "b: two", tail: " · working · 0:01", stalled: false, kind: "lane" as const }, + { label: "c: three", tail: " · working · 0:01", stalled: false, kind: "lane" as const }, + { label: "d: four", tail: " · working · 0:01", stalled: false, kind: "lane" as const }, + { label: "+4 more lanes", 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[3]).toEqual({ + label: "+6 more lanes", + tail: "", + stalled: false, + kind: "more", + }) + }) + + test("under a tight height the header carries the total hidden count", () => { + const formatted = [ + { label: "FLEET 8 lanes · 8 working", tail: " · +4 hidden", stalled: false, kind: "header" as const }, + { label: "a: one", tail: " · working · 0:01", stalled: false, kind: "lane" as const }, + { label: "b: two", tail: " · working · 0:01", stalled: false, kind: "lane" 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) + }) +}) + diff --git a/src/tui-opentui/chrome-state.ts b/src/tui-opentui/chrome-state.ts index 710e7e7dc..46952d6cb 100644 --- a/src/tui-opentui/chrome-state.ts +++ b/src/tui-opentui/chrome-state.ts @@ -24,9 +24,12 @@ import { agentProgress, - fleetLabel, 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" @@ -95,8 +98,21 @@ export type AgentPanelRow = { readonly label: string readonly tail: string 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). + */ + readonly kind?: "header" | "lane" | "more" } +/** + * Board paint order for main's `LaneState` vocabulary — trouble first so an + * operator answers "is everything fine" without reading a word. This is a + * display order only; stall/`in_tool` semantics live in `agent-progress`. + */ +const BOARD_LANE_ORDER: readonly LaneState[] = ["stalled", "in_tool", "working"] + /** Always-populated result for setChromeZones (null = hide zone). */ export type FormattedChromeZones = { /** One row per rendered task-panel line (null = hide zone, zero rows). */ @@ -159,9 +175,10 @@ 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` 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. + * `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. */ export function formatAgentsPanel( agents: readonly ChromeAgentSession[] | null | undefined, @@ -178,51 +195,193 @@ export function formatAgentsPanel( const running = agents.filter((s) => s.status === "running") if (running.length === 0) return null - // Two different sorts for two different jobs. Selection (which N survive - // a fan-out past maxVisible) must key on staleness, or the stalest — - // most likely stalled — agent is exactly the one that gets folded into - // "+N more". Presentation must NOT key on staleness: lastActivityAt is - // the most rapidly-changing field in the record, so sorting rows by it - // reshuffles the panel on every tool event. startedAt never changes for - // a live agent, so it gives stable row order; agentId breaks ties since - // a simultaneous fan-out can share a startedAt and the input feed's own - // order (newest-first, itself not stable under updates) must not leak - // through as a tiebreak. - const selected = [...running] + // 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] + .map((session) => ({ + session, + state: boardLaneState(session, nowMs, stallMs), + })) .sort( - (a, b) => (a.lastActivityAt ?? a.startedAt ?? 0) - (b.lastActivityAt ?? b.startedAt ?? 0), + (a, b) => + BOARD_LANE_ORDER.indexOf(a.state) - BOARD_LANE_ORDER.indexOf(b.state) || + (a.session.startedAt ?? 0) - (b.session.startedAt ?? 0) || + a.session.agentId.localeCompare(b.session.agentId), ) - .slice(0, maxVisible) - const hidden = running.length - selected.length - - const presented = [...selected].sort( - (a, b) => (a.startedAt ?? 0) - (b.startedAt ?? 0) || a.agentId.localeCompare(b.agentId), - ) - const rows = presented.map((s) => formatAgentRow(s, nowMs, stallMs)) - if (hidden > 0) rows.push({ label: `+${hidden} more`, tail: "", stalled: false }) - // Fleet roll-up first: past a couple of lanes an operator reads the summary, - // not six individual rows, and any row folded into "+N more" is otherwise - // invisible. Counted from the same lane states the rows below are rendered - // from, so the header can never disagree with them. + // 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.map((s) => ({ - status: "running" as const, - currentToolName: s.currentToolName ?? null, - currentToolStartedAt: s.currentToolStartedAt ?? null, - startedAt: s.startedAt ?? 0, - lastActivityAt: s.lastActivityAt ?? s.startedAt ?? 0, - })), + running.flatMap((s) => { + const progress = toProgressSession(s) + return progress === null ? [] : [progress] + }), nowMs, stallMs, ) - const summary = fleetLabel(fleet) - if (summary !== null && running.length > 1) { - rows.unshift({ label: summary, tail: "", stalled: fleet.stalled > 0 }) + + 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", + }) } return rows } +/** + * Map a chrome session into the shape `laneState` / `agentProgress` require. + * Missing clocks mean we cannot ask those helpers — callers fall back to a + * safe display default rather than inventing timestamps. + */ +function toProgressSession(session: ChromeAgentSession): AgentProgressSession | null { + if (session.startedAt === undefined) return null + return { + status: session.status, + currentToolName: session.currentToolName ?? null, + currentToolStartedAt: session.currentToolStartedAt, + startedAt: session.startedAt, + lastActivityAt: session.lastActivityAt ?? session.startedAt, + } +} + +/** + * Board-facing lane word: main's `laneState` when clocks exist, else `working` + * (no second stall path — without clocks we simply cannot claim stalled). + */ +function boardLaneState( + session: ChromeAgentSession, + nowMs: number, + stallMs: number, +): LaneState { + const progress = toProgressSession(session) + if (progress === null) return "working" + return laneState(progress, nowMs, stallMs) +} + +/** + * 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. + * + * 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. + */ +export function clampBoardRows( + rows: readonly AgentPanelRow[], + height: number, +): readonly AgentPanelRow[] { + 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 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] + } + + const shown = lanes.slice(0, Math.max(0, height - 2)) + const hidden = priorHidden + (lanes.length - shown.length) + return [ + cleanHeader, + ...shown, + { label: `+${hidden} more lanes`, tail: "", stalled: false, kind: "more" }, + ] +} + +/** Lanes already disclosed by a prior format/clamp fold on these rows. */ +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) + 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 +} + +/** + * Operator-facing state word. Machine `LaneState` stays snake_case for code; + * the board never paints that vocabulary into the terminal. + */ +function laneStateWord(state: LaneState): string { + switch (state) { + case "in_tool": + return "in tool" + case "stalled": + return "stalled" + case "working": + return "working" + } +} + +/** + * 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() @@ -233,33 +392,38 @@ function formatObserveRow(observe: ChromeLiveState["observe"]): AgentPanelRow | return { label: `observe: ${label}`, tail: "", stalled: false } } -function formatAgentRow(session: ChromeAgentSession, nowMs: number, stallMs: number): AgentPanelRow { +function formatAgentRow( + session: ChromeAgentSession, + state: LaneState, + nowMs: number, + stallMs: number, +): AgentPanelRow { const label = `${session.agentId}: ${session.description}`.trim() - const progress = - session.startedAt !== undefined - ? agentProgress( - { - status: "running", - currentToolName: session.currentToolName ?? null, - currentToolStartedAt: session.currentToolStartedAt, - startedAt: session.startedAt, - lastActivityAt: session.lastActivityAt ?? session.startedAt, - }, - nowMs, - stallMs, - ) - : null + const stalled = state === "stalled" + const tool = session.currentToolName + const doing = tool !== undefined && tool !== null && tool.length > 0 ? tool : null + + 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" } + } + // Prefer main's agentProgress for tool-clock / in_tool / quiet clocks so the + // board never invents a second stall path. Board presentation still prefixes + // the operator-facing state word (and kind: lane) the way the fleet board reads. + const progress = agentProgress(progressSession, nowMs, stallMs) if (progress !== null) { - const tail = progress.stalled ? ` · ${progress.stat} · stalled` : ` · ${progress.stat}` - return { label, tail, stalled: progress.stalled } + return { + label, + tail: ` · ${laneStateWord(state)} · ${progress.stat}`, + stalled, + kind: "lane", + } } - // No startedAt to compute a clock from (host omitted it) — still surface - // the tool name so the row is not silently missing detail it has. - const tool = session.currentToolName - const tail = tool !== undefined && tool !== null && tool.length > 0 ? ` · ${tool}` : "" - return { label, tail, stalled: false } + return { label, tail: doing !== null ? ` · ${doing}` : "", stalled, kind: "lane" } } /** @@ -385,4 +549,3 @@ function mapSessionAgents( } }) } - diff --git a/src/tui-opentui/demo.ts b/src/tui-opentui/demo.ts index 7ef566be8..d2bd0bc4c 100644 --- a/src/tui-opentui/demo.ts +++ b/src/tui-opentui/demo.ts @@ -86,6 +86,42 @@ if (!process.stdout.isTTY) { process.exit(1) } +/** + * A fleet big enough to exercise the board, including the states that matter: + * a lane gone silent, one waiting on an approval, and enough lanes to push the + * board past what a short terminal can show. A single-lane fixture cannot + * demonstrate sorting, the aggregate header, or the hidden-lane disclosure — + * which is to say it cannot demonstrate anything the board exists to do. + */ +const DEMO_FLEET = [ + ["a5", "provider catalog groundwork", 554, 390, "bash npm test"], + ["a2", "geometry resolver split", 252, 3, "edit zones.ts"], + ["a3", "stall watchdog thresholds", 118, 1, "edit watchdog.ts"], + ["a4", "transcript dedupe", 12, 12, null], + ["a6", "chrome-state formatters", 44, 2, "grep formatAgents"], + ["a8", "keybinding audit", 161, 6, "edit keybindings"], + ["a9", "release note sweep", 67, 67, "approve rm -rf"], + ["a10", "telemetry catalog", 8, 8, null], + ["a11", "docs/TUI.md rewrite", 199, 4, "write TUI.md"], + ["a13", "pricing metadata refresh", 302, 40, "read pricing.ts"], + ["a14", "eval harness rewrite", 123, 200, "bash bun test"], + ["a15", "mcp view polish", 90, 5, "edit mcp-view.ts"], +].map(([agentId, description, ranSec, idleSec, tool]) => { + const lastActivityAt = Date.now() - (idleSec as number) * 1000 + return { + agentId: agentId as string, + description: description as string, + status: "running" as const, + currentToolName: tool as string | null, + // Hybrid chrome requires the tool clock; without it a long-running tool + // would be reclassified as stalled. Align with last activity when a tool + // is named so demo lanes still exercise working / in_tool / stalled. + currentToolStartedAt: tool === null ? null : lastActivityAt, + startedAt: Date.now() - (ranSec as number) * 1000, + lastActivityAt, + } +}) + const renderer = await createCliRenderer({ exitOnCtrlC: false, targetFps: 30, @@ -292,21 +328,7 @@ renderer.keyInput.on("keypress", (key: KeyEvent) => { ) { const on = shell.layout.heights.agents > 0 setChromeZones(shell, { - agents: on - ? null - : formatChromeZones({ - agents: [ - { - agentId: "explore", - currentToolStartedAt: null, - description: "map callers", - status: "running", - currentToolName: "grep", - startedAt: Date.now() - 42_000, - lastActivityAt: Date.now(), - }, - ], - }).agents, + agents: on ? null : formatChromeZones({ agents: DEMO_FLEET }).agents, }) return } diff --git a/src/tui-opentui/geometry.test.ts b/src/tui-opentui/geometry.test.ts index a58bc38db..0c6e0f8d8 100644 --- a/src/tui-opentui/geometry.test.ts +++ b/src/tui-opentui/geometry.test.ts @@ -2,6 +2,8 @@ import { describe, expect, test } from "bun:test"; import { AGENTS_PANEL_MAX_VISIBLE, COLLAPSE_ORDER, + FLEET_BOARD_CAP_FRACTION, + FLEET_TRANSCRIPT_FLOOR, IDLE_TRANSCRIPT_FLOOR, OVERLAY_TRANSCRIPT_FLOOR, PROMPT_BASE_ROWS, @@ -99,11 +101,12 @@ describe("resolveGeometry — 80×24 idle floor", () => { }); describe("resolveGeometry — agents panel", () => { - test("N running agents request N rows, bounded by the zone max", () => { - for (let n = 0; n <= AGENTS_PANEL_MAX_VISIBLE + 4; n++) { - const requested = Math.min(n, ZONE_REGISTRY.agents.max); + test("the board is sized to its content, one row per lane", () => { + // Small boards get exactly what they ask for; only once the transcript + // floor is at risk does the board start giving rows back. + for (let n = 0; n <= 6; n++) { const layout = idle80x24({ visibility: { agents: n } }); - expect(layout.heights.agents).toBe(requested); + expect(layout.heights.agents).toBe(n); } }); @@ -113,15 +116,41 @@ describe("resolveGeometry — agents panel", () => { expect(layout.regions.agents).toBeUndefined(); }); - test("a large fan-out never grows the zone past its bounded max", () => { + test("a large fan-out never grows the board without bound", () => { const layout = idle80x24({ visibility: { agents: 50 } }); - expect(layout.heights.agents).toBe(ZONE_REGISTRY.agents.max); - // Fleet summary + the visible lanes + the "+N more" trailer. - expect(layout.heights.agents).toBe(AGENTS_PANEL_MAX_VISIBLE + 2); + // Two independent bounds, and the tighter one wins: the fraction of the + // terminal the board may take, and whatever the transcript floor leaves. + expect(layout.heights.agents).toBeLessThanOrEqual( + Math.floor(24 * FLEET_BOARD_CAP_FRACTION), + ); + expect(layout.heights.agents).toBeLessThanOrEqual(ZONE_REGISTRY.agents.max); + expect(layout.transcriptHeight).toBeGreaterThanOrEqual(layout.transcriptFloor); + }); + + test("a taller terminal gives the board room for a bigger fleet", () => { + const tall = resolveGeometry({ + terminal: { columns: 120, rows: 40 }, + visibility: { agents: 14 }, + transcriptFloor: FLEET_TRANSCRIPT_FLOOR, + }); + // A dozen lanes plus a header fit on a 40-row terminal without hiding any. + expect(tall.heights.agents).toBe(14); + }); + + test("with a fleet running the transcript yields its idle floor to the board", () => { + const fleet = resolveGeometry({ + terminal: { columns: 80, rows: 24 }, + visibility: { agents: 13 }, + transcriptFloor: FLEET_TRANSCRIPT_FLOOR, + }); + expect(fleet.heights.agents).toBe(13); + expect(fleet.transcriptHeight).toBeGreaterThanOrEqual(FLEET_TRANSCRIPT_FLOOR); + // The prompt box never leaves the screen, whatever the fleet is doing. + expect(fleet.heights.prompt).toBeGreaterThanOrEqual(PROMPT_BASE_ROWS); }); test("a bounded agents panel never eats the transcript floor", () => { - const layout = idle80x24({ visibility: { agents: ZONE_REGISTRY.agents.max } }); + const layout = idle80x24({ visibility: { agents: AGENTS_PANEL_MAX_VISIBLE + 1 } }); expect(layout.transcriptHeight).toBeGreaterThanOrEqual(layout.transcriptFloor); }); diff --git a/src/tui-opentui/geometry/index.ts b/src/tui-opentui/geometry/index.ts index 3c264dfe7..b93038911 100644 --- a/src/tui-opentui/geometry/index.ts +++ b/src/tui-opentui/geometry/index.ts @@ -1,6 +1,9 @@ export { AGENTS_PANEL_MAX_VISIBLE, COLLAPSE_ORDER, + FLEET_BOARD_CAP_FRACTION, + FLEET_FLOOR_MIN_LANES, + FLEET_TRANSCRIPT_FLOOR, IDLE_TRANSCRIPT_FLOOR, OVERLAY_MAX_FRACTION, OVERLAY_MIN_ROWS, diff --git a/src/tui-opentui/geometry/resolve.ts b/src/tui-opentui/geometry/resolve.ts index ca37a92a9..03b72a0c7 100644 --- a/src/tui-opentui/geometry/resolve.ts +++ b/src/tui-opentui/geometry/resolve.ts @@ -4,6 +4,7 @@ import { resolveContentWidth, resolveSideMargin } from "./margins.js"; import { COLLAPSE_ORDER, + FLEET_BOARD_CAP_FRACTION, IDLE_TRANSCRIPT_FLOOR, OVERLAY_MAX_FRACTION, OVERLAY_MIN_ROWS, @@ -141,7 +142,17 @@ export function desiredHeights(input: GeometryInput): MutableHeights { notice: vis.notice === true ? 1 : ZONE_REGISTRY.notice.idleDefault, prompt: promptRows, task: clamp(boolOrRows(vis.task, 1), 0, ZONE_REGISTRY.task.max), - agents: clamp(boolOrRows(vis.agents, 1), 0, ZONE_REGISTRY.agents.max), + // The board asks for exactly the rows it will paint; the fraction is what + // stops a large fan-out from taking the screen, and it has to be computed + // here because the registry max cannot know the terminal's height. + agents: clamp( + boolOrRows(vis.agents, 1), + 0, + Math.min( + ZONE_REGISTRY.agents.max, + Math.max(1, Math.floor(rows * FLEET_BOARD_CAP_FRACTION)), + ), + ), plugin_banner: vis.pluginBanner ? 1 : 0, command_banner: clamp( boolOrRows(vis.commandBanner, 1), diff --git a/src/tui-opentui/geometry/zones.ts b/src/tui-opentui/geometry/zones.ts index 49906b5ee..030de6f23 100644 --- a/src/tui-opentui/geometry/zones.ts +++ b/src/tui-opentui/geometry/zones.ts @@ -39,7 +39,28 @@ 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 = 5; +export const AGENTS_PANEL_MAX_VISIBLE = 13; + +/** + * Share of the terminal the fleet board may take before it starts hiding + * lanes. The board is sized to its content, so a single lane costs two rows + * and a dozen costs thirteen; this only bounds the large fan-out, and the + * transcript keeps everything the board does not ask for. + */ +export const FLEET_BOARD_CAP_FRACTION = 0.62; + +/** + * Transcript floor while a fleet is running. + * + * With two or more lanes live the operator's job is watching the fleet, not + * reading a conversation, so the transcript stops being entitled to half the + * screen. It never disappears — this is still enough to read the last thing + * the orchestrator said, which is how it keeps reporting and asking. + */ +export const FLEET_TRANSCRIPT_FLOOR = 4; + +/** Lanes live before the fleet floor replaces the idle one. */ +export const FLEET_FLOOR_MIN_LANES = 2; /** * Bound on rendered task rows in the live task-list panel. Mirrors diff --git a/src/tui-opentui/history-hydrate.test.ts b/src/tui-opentui/history-hydrate.test.ts index cdea2cce7..35dd4dc60 100644 --- a/src/tui-opentui/history-hydrate.test.ts +++ b/src/tui-opentui/history-hydrate.test.ts @@ -1,12 +1,12 @@ import { describe, expect, test } from "bun:test" import { EMPTY_PLAN_DETAIL, - EMPTY_TASKS_DETAIL, EMPTY_VIEW_DETAIL, hydrateHistoryRows, MISSING_ERROR_DETAIL, rowFromHistoryBlock, rowsFromHistoryBlocks, + type HistoryBlock, } from "./history-hydrate.js" describe("rowFromHistoryBlock", () => { @@ -155,25 +155,16 @@ describe("rowFromHistoryBlock", () => { }) }) - test("tasks hydrates as a system row listing task titles", () => { + test("a tasks block no longer hydrates a row at all", () => { + // Task state is live panel state, not conversation history. Nothing writes + // this block any more, and an old session carrying one must not paint a + // second copy of a list the panel already shows. expect( rowFromHistoryBlock({ type: "tasks", - tasks: [ - { id: "1", title: "Fix hydrate", status: "done" }, - { id: "2", title: "Ship it", status: "todo" }, - ], - }), - ).toEqual({ - role: "system", - text: "- Fix hydrate (done)\n- Ship it (todo)", - meta: "tasks", - }) - expect(rowFromHistoryBlock({ type: "tasks" })).toEqual({ - role: "system", - text: EMPTY_TASKS_DETAIL, - meta: "tasks", - }) + tasks: [{ id: "1", title: "Fix hydrate", status: "done" }], + } as unknown as HistoryBlock), + ).toBeNull() }) }) @@ -198,11 +189,11 @@ describe("hydrateHistoryRows", () => { }, { type: "error", message: "fail" }, ]) - // The call and its result hydrate as the one row a live turn would paint. + // The call and its result hydrate as the one row a live turn would paint; + // the tasks block drops out entirely, since the panel owns that state. expect(rows).toMatchObject([ { role: "user", text: "parent user" }, { role: "assistant", text: "assistant line" }, - { role: "system", text: EMPTY_TASKS_DETAIL, meta: "tasks" }, { role: "tool", text: "body", meta: "read_file", summary: "x", verb: "Read" }, { role: "system", text: "fail", meta: "error" }, ]) diff --git a/src/tui-opentui/history-hydrate.ts b/src/tui-opentui/history-hydrate.ts index c2457c944..066b79594 100644 --- a/src/tui-opentui/history-hydrate.ts +++ b/src/tui-opentui/history-hydrate.ts @@ -35,8 +35,6 @@ export type HistoryBlock = { readonly node?: unknown /** plan block payload. */ readonly steps?: unknown - /** tasks block payload. */ - readonly tasks?: unknown } /** Body for a resumed error the transcript recorded without its message. */ @@ -45,7 +43,6 @@ export const MISSING_ERROR_DETAIL = "this step failed and the details were not s /** Bodies for blocks that survived to hydration carrying nothing paintable. */ export const EMPTY_VIEW_DETAIL = "this reply was a view with no text" export const EMPTY_PLAN_DETAIL = "plan with no steps" -export const EMPTY_TASKS_DETAIL = "task list with no tasks" function asHistoryBlock(raw: unknown): HistoryBlock | null { if (raw === null || typeof raw !== "object") return null @@ -61,7 +58,6 @@ function asHistoryBlock(raw: unknown): HistoryBlock | null { callId?: string node?: unknown steps?: unknown - tasks?: unknown } = { type: o.type } if (typeof o.content === "string") out.content = o.content if (typeof o.name === "string") out.name = o.name @@ -71,7 +67,6 @@ function asHistoryBlock(raw: unknown): HistoryBlock | null { if (typeof o.callId === "string") out.callId = o.callId if (o.node !== undefined) out.node = o.node if (o.steps !== undefined) out.steps = o.steps - if (o.tasks !== undefined) out.tasks = o.tasks return out as HistoryBlock } @@ -105,25 +100,12 @@ function planText(steps: unknown): string { return lines.join("\n") } -function tasksText(tasks: unknown): string { - if (!Array.isArray(tasks)) return "" - const lines: string[] = [] - for (const raw of tasks) { - if (raw === null || typeof raw !== "object") continue - const task = raw as Record - const title = typeof task.title === "string" ? task.title : "" - if (title.length === 0) continue - const status = typeof task.status === "string" ? task.status : "" - lines.push(status.length > 0 ? `- ${title} (${status})` : `- ${title}`) - } - return lines.join("\n") -} /** * Map one content block to a transcript row, or null when the block type is * unknown. * - * view / plan / tasks get a degraded text row rather than being dropped: a + * view / plan get a degraded text row rather than being dropped: a * resumed session that answered through a view would otherwise paint the * question and nothing else, with no marker that anything was lost. * @@ -170,14 +152,6 @@ export function rowFromHistoryBlock(block: HistoryBlock): StreamRow | null { meta: "plan", } } - case "tasks": { - const text = tasksText(block.tasks) - return { - role: "system", - text: text.length > 0 ? text : EMPTY_TASKS_DETAIL, - meta: "tasks", - } - } case "error": return { role: "system", diff --git a/src/tui-opentui/runtime-bridge.test.ts b/src/tui-opentui/runtime-bridge.test.ts index ca832d4cd..f72674686 100644 --- a/src/tui-opentui/runtime-bridge.test.ts +++ b/src/tui-opentui/runtime-bridge.test.ts @@ -763,3 +763,101 @@ describe("syncAgentProgress", () => { ) }) }) + +describe("task checklist calls stay out of the transcript", () => { + test("a manage_tasks call and its result paint no rows", async () => { + await withTestRenderer( + async (h) => { + const shell = createAppShell(h.renderer, { + terminal: { columns: 80, rows: 24 }, + wireKeys: false, + run: "busy", + }) + const bridge = attachSessionBridge(shell, createRecordingPort()) + try { + appendStreamRow(shell, { role: "assistant", text: "planning the sweep" }) + const before = streamRowCount(shell) + + bridge.handle({ + type: "inference.tool_call.end", + data: { + name: "manage_tasks", + callId: "mt-1", + arguments: { action: "create", tasks: [{ title: "audit", status: "todo" }] }, + }, + }) + bridge.handle({ + type: "tool.done", + data: { + result: { callId: "mt-1", name: "manage_tasks", content: "ok", isError: false }, + }, + }) + + // The list lives in the task panel; scrollback must not carry a + // second copy of it. + expect(streamRowCount(shell)).toBe(before) + } finally { + bridge.dispose() + shell.dispose() + } + }, + { width: 80, height: 24 }, + ) + }) + + test("an errored manage_tasks result is dropped rather than left unpaired", async () => { + await withTestRenderer( + async (h) => { + const shell = createAppShell(h.renderer, { + terminal: { columns: 80, rows: 24 }, + wireKeys: false, + run: "busy", + }) + const bridge = attachSessionBridge(shell, createRecordingPort()) + try { + const before = streamRowCount(shell) + bridge.handle({ + type: "inference.tool_call.end", + data: { name: "manage_tasks", callId: "mt-2", arguments: { action: "update" } }, + }) + bridge.handle({ + type: "tool.done", + data: { + result: { callId: "mt-2", name: "manage_tasks", content: "boom", isError: true }, + }, + }) + expect(streamRowCount(shell)).toBe(before) + } finally { + bridge.dispose() + shell.dispose() + } + }, + { width: 80, height: 24 }, + ) + }) + + test("other tools still paint normally", async () => { + await withTestRenderer( + async (h) => { + const shell = createAppShell(h.renderer, { + terminal: { columns: 80, rows: 24 }, + wireKeys: false, + run: "busy", + }) + const bridge = attachSessionBridge(shell, createRecordingPort()) + try { + const before = streamRowCount(shell) + bridge.handle({ + type: "inference.tool_call.end", + data: { name: "grep", callId: "g-1", arguments: { pattern: "zones" } }, + }) + expect(streamRowCount(shell)).toBeGreaterThan(before) + } finally { + bridge.dispose() + shell.dispose() + } + }, + { width: 80, height: 24 }, + ) + }) +}) diff --git a/src/tui-opentui/runtime-bridge.ts b/src/tui-opentui/runtime-bridge.ts index 1e25c7bf1..cbd573f59 100644 --- a/src/tui-opentui/runtime-bridge.ts +++ b/src/tui-opentui/runtime-bridge.ts @@ -80,6 +80,14 @@ import { /** Tool name a sub-agent dispatch call carries — its row gets live progress. */ const TASK_TOOL_NAME = "task" +/** + * Tool name the task checklist is written through. Its calls paint no + * transcript row: the list they write is live state owned by the task panel, + * and a row per call renders the same work twice on one screen — once as a + * panel that updates in place, and again as scrollback that never does. + */ +const MANAGE_TASKS_TOOL_NAME = "manage_tasks" + /** A sub-agent session as `syncAgentProgress` needs it: identified, and live-readable. */ export type TaskProgressSession = AgentProgressSession & { readonly id: string } import { @@ -347,6 +355,11 @@ type BridgeBag = { * on the animation tick, not only when a worker happens to emit an event. */ agentSessions: readonly TaskProgressSession[] + /** + * callIds whose call painted no row because the work belongs to a panel. + * Tracked so the matching result is dropped rather than landing unpaired. + */ + panelOnlyCallIds: Set /** * Row index where the inference attempt in progress began, or null when no * boundary is armed. The mapper decides when to mark, clear and roll back; @@ -513,6 +526,12 @@ function applyToolCall( bag: BridgeBag, event: Extract, ): void { + if (event.name === MANAGE_TASKS_TOOL_NAME) { + // Remembered so the matching result is dropped too — suppressing only the + // call would leave its result to land as an unpaired row. + if (event.callId !== undefined) bag.panelOnlyCallIds.add(event.callId) + return + } const row = toolCallRow({ name: event.name, ...(event.detail !== undefined ? { arguments: event.detail } : {}), @@ -542,6 +561,7 @@ function applyToolResult( bag: BridgeBag, event: Extract, ): void { + if (event.callId !== undefined && bag.panelOnlyCallIds.delete(event.callId)) return const result = toolResultRow({ name: event.name, content: event.detail ?? (event.isError ? "error" : "ok"), @@ -741,6 +761,7 @@ export function attachSessionBridge( lastToolRow: -1, taskCallIds: new Set(), agentSessions: [], + panelOnlyCallIds: new Set(), attemptRow: null, turnThinking: null, } diff --git a/src/tui-opentui/runtime-channels.test.ts b/src/tui-opentui/runtime-channels.test.ts index a554fbe99..a3908e661 100644 --- a/src/tui-opentui/runtime-channels.test.ts +++ b/src/tui-opentui/runtime-channels.test.ts @@ -189,7 +189,11 @@ describe("subagent.progress channel", () => { description: "map callers", toolName: "grep", }) - expect(await frame()).toContain("map callers · grep") + // The board right-aligns each lane's tail into a column, so the tool + // name is on the row but no longer adjacent to the description. + const painted = await frame() + expect(painted).toContain("map callers") + expect(painted).toContain("grep") // Progress is chrome, never a transcript row: one line per worker tool // call would bury the turn it is a detail of. expect(host.shell.streamLog).toEqual([]) @@ -210,7 +214,9 @@ describe("subagent.progress channel", () => { { agentId: "explore", description: "map callers", status: "running", currentToolStartedAt: null }, ], }) - expect(await frame()).toContain("map callers · grep") + const painted = await frame() + expect(painted).toContain("map callers") + expect(painted).toContain("grep") } finally { cleanup() } diff --git a/src/tui-opentui/shell.ts b/src/tui-opentui/shell.ts index 18f261503..a601fa379 100644 --- a/src/tui-opentui/shell.ts +++ b/src/tui-opentui/shell.ts @@ -6,7 +6,7 @@ */ import { homedir } from "node:os" -import type { AgentPanelRow, TaskPanelRow } from "./chrome-state.js" +import { clampBoardRows, type AgentPanelRow, type TaskPanelRow } from "./chrome-state.js" import { BoxRenderable, @@ -96,6 +96,8 @@ import { type FocusState, } from "./focus/index.js" import { + FLEET_FLOOR_MIN_LANES, + FLEET_TRANSCRIPT_FLOOR, PROMPT_IDLE_ROWS, resolveBottomMarginRows, resolveGeometry, @@ -2053,12 +2055,27 @@ export function relayout(shell: AppShell, opts?: RelayoutOpts): GeometryLayout { // a long list would claim the whole screen instead of scrolling. ...(isLanding(shell) && overlayMode === "closed" ? { transcriptFloor: 0 } - : {}), + : fleetTranscriptFloor(shell)), }) applyLayout(shell, layout) return layout } +/** + * Rows the transcript holds back once a fleet is running. + * + * With several lanes live the operator is managing a fleet rather than reading + * a conversation, so the transcript gives up its idle floor to the board. It + * keeps enough to stay a live tail — the orchestrator reporting back and asking + * questions is still the main way the operator learns anything. + */ +function fleetTranscriptFloor(shell: AppShell): { transcriptFloor?: number } { + const bag = internals.get(shell) + if (!bag) return {} + const lanes = bag.chrome.agents.filter((row) => row.kind === "lane").length + return lanes >= FLEET_FLOOR_MIN_LANES ? { transcriptFloor: FLEET_TRANSCRIPT_FLOOR } : {} +} + /** * Append a raw line to the sticky transcript ScrollBox. * stickyScroll + stickyStart "bottom" auto-follow until the operator scrolls up. @@ -4161,7 +4178,17 @@ function taskStatusMarker(status: TaskPanelRow["status"]): string { */ function fitAgentRow(row: AgentPanelRow, maxWidth: number): string { const full = ` ${row.label}${row.tail}` - if (stringWidth(full) <= maxWidth) return full + if (stringWidth(full) <= maxWidth) { + // Push every lane's tail to the right edge so the clocks line up as a + // column. A lane that has been silent far longer than its neighbours then + // stands out of that column by its shape, before any of it is read — which + // is the one thing the board has to get right at a glance. + if (row.kind === "lane") { + const pad = maxWidth - stringWidth(full) + return ` ${row.label}${" ".repeat(Math.max(0, pad))}${row.tail}` + } + return full + } const leadingSpace = 1 const ellipsis = 1 @@ -4223,10 +4250,17 @@ function renderAgentsRows( } for (const row of rows) { // Green for working, not the task zone's bronze immediately above it — - // adjacent zones sharing a hue read as one undifferentiated block. + // adjacent zones sharing a hue read as one undifferentiated block. The + // header and the hidden-count row are chrome about the board rather than + // lanes in it, so they sit back in dim and leave the colour to the work. const text = new TextRenderable(shell.renderer as CliRenderer, { content: fitAgentRow(row, maxWidth), - fg: row.stalled ? UI.textDim : UI.done, + fg: + row.kind === "header" || row.kind === "more" + ? UI.textDim + : row.stalled + ? UI.action + : UI.done, }) shell.agentsBox.add(text) } @@ -4285,34 +4319,41 @@ export function setChromeZones( if (taskChanged) { renderTasksRows(shell, bag.chrome.task, shell.layout.contentWidth) } - if (agentsChanged) { - renderAgentsRows(shell, bag.chrome.agents, shell.layout.contentWidth) - } - // Only a zone appearing/disappearing or its row count changing alters the // row budget; retitling a zone whose row count is unchanged must not // re-resolve and re-apply the whole layout. - if ( - taskRowCount === bag.visibility.task && - agentsRowCount === bag.visibility.agents - ) { - paintChrome(shell) - return + const budgetUnchanged = + taskRowCount === bag.visibility.task && agentsRowCount === bag.visibility.agents + if (!budgetUnchanged) { + relayout(shell, { + visibility: { + ...bag.visibility, + task: taskRowCount, + agents: agentsRowCount, + }, + overlayMode: bag.overlayMode, + ...(bag.overlayBodyRows !== undefined + ? { overlayBodyRows: bag.overlayBodyRows } + : {}), + }) } - relayout(shell, { - visibility: { - ...bag.visibility, - task: taskRowCount, - agents: agentsRowCount, - }, - overlayMode: bag.overlayMode, - ...(bag.overlayBodyRows !== undefined - ? { overlayBodyRows: bag.overlayBodyRows } - : {}), - }) + // Painted after the resolver has spoken, and only ever as many rows as it + // granted: a board that paints past its box lands on top of the transcript + // and tears down the renderables underneath it. + if (agentsChanged || !budgetUnchanged) { + renderAgentsRows( + shell, + clampBoardRows(bag.chrome.agents, shell.layout.heights.agents), + shell.layout.contentWidth, + ) + } + if (budgetUnchanged) paintChrome(shell) } +/** How long a panel-visibility flash holds the notice row. */ +const PANEL_TOGGLE_FLASH_MS = 3000 + /** * Toggle the task-list panel visible/hidden without touching the live task * data underneath it — un-hiding shows whatever the task tool last wrote, @@ -4326,10 +4367,10 @@ export function toggleTasksPanel(shell: AppShell): void { bag.tasksPanelHidden = !bag.tasksPanelHidden const hiding = bag.tasksPanelHidden setChromeZones(shell, { task: bag.chrome.tasksRaw }) - appendStreamRow(shell, { - role: "system", - text: hiding ? "task list hidden" : "task list shown", - meta: "task", + // A flash, not a transcript row: which panels are showing is a property of + // the current screen, not something that happened in the conversation. + setStatusFlash(shell, hiding ? "task list hidden · alt+t to show" : "task list shown", { + ttlMs: PANEL_TOGGLE_FLASH_MS, }) } diff --git a/src/tui-opentui/wave6.test.ts b/src/tui-opentui/wave6.test.ts index 950e28361..afc2d0858 100644 --- a/src/tui-opentui/wave6.test.ts +++ b/src/tui-opentui/wave6.test.ts @@ -329,27 +329,33 @@ describe("Wave 6: chrome zones", () => { wireKeys: false, }) try { + // Seed both zones so later retitles keep the row budget stable. + // A budget change re-clamps the board and must repaint; this test is + // about content-identity rebuilds, not clamp-on-resize. setChromeZones(shell, { + task: [{ label: "seed", status: "todo" }], agents: [{ label: "explore: map callers", tail: "", stalled: false }], }) - const rowsBefore = [...shell.agentsBox.getChildren()] - expect(rowsBefore).toHaveLength(1) + const firstBefore = shell.agentsBox.getChildren()[0] + expect(shell.agentsBox.getChildren()).toHaveLength(1) + expect(firstBefore).toBeDefined() - // An unrelated task push must not touch the agents rows. + // Task retitle only (same row count) must not rebuild agents rows. + // Use reference identity — deep-equal on OpenTUI trees hangs on cycles. setChromeZones(shell, { task: [{ label: "unrelated", status: "todo" }] }) - expect([...shell.agentsBox.getChildren()]).toEqual(rowsBefore) + expect(shell.agentsBox.getChildren()[0]).toBe(firstBefore) - // Pushing the exact same agent lines again must not rebuild either. + // Exact same agent lines again must not rebuild either. setChromeZones(shell, { agents: [{ label: "explore: map callers", tail: "", stalled: false }], }) - expect([...shell.agentsBox.getChildren()]).toEqual(rowsBefore) + expect(shell.agentsBox.getChildren()[0]).toBe(firstBefore) // Changed lines must rebuild. setChromeZones(shell, { agents: [{ label: "explore: map callers", tail: " · 0:01", stalled: false }], }) - expect([...shell.agentsBox.getChildren()]).not.toEqual(rowsBefore) + expect(shell.agentsBox.getChildren()[0]).not.toBe(firstBefore) expect(shell.agentsBox.getChildren()).toHaveLength(1) } finally { shell.dispose() @@ -567,6 +573,34 @@ describe("CL-5731: task list panel", () => { ) }) + test("toggling the panel says so in a flash, not in the transcript", async () => { + await withTestRenderer( + async (h) => { + const shell = createAppShell(h.renderer, { + terminal: { columns: 80, rows: 24 }, + wireKeys: false, + }) + try { + setChromeZones(shell, { task: [{ label: "wire toggle", status: "doing" }] }) + const before = streamRowCount(shell) + + toggleTasksPanel(shell) + // Which panels are showing is a property of the current screen, not + // an event in the conversation, so it costs no scrollback. + expect(streamRowCount(shell)).toBe(before) + expect(shell.statusFlash).toContain("hidden") + + toggleTasksPanel(shell) + expect(streamRowCount(shell)).toBe(before) + expect(shell.statusFlash).toContain("shown") + } finally { + shell.dispose() + } + }, + { width: 80, height: 24 }, + ) + }) + test("the toggle persists across further chrome pushes for the life of the shell", async () => { await withTestRenderer( async (h) => { diff --git a/src/tui/runner.ts b/src/tui/runner.ts index 675890912..c9a25ac33 100644 --- a/src/tui/runner.ts +++ b/src/tui/runner.ts @@ -2298,10 +2298,10 @@ export async function runTUI(initialConfig: Config): Promise { .then((turns) => { const blocks = turnsToContentBlocks(turns, { maxBlocks: RESUME_TRANSCRIPT_BLOCK_LIMIT }); const tasks = hydrateTasksFromTurns(turns); - if (tasks.length > 0) { - blocks.unshift({ type: "tasks", tasks }); - directorHolder.instance?.restoreTasks(tasks); - } + // Restored tasks go to the panel only. They are live state, not something + // that happened in the conversation, so putting them in scrollback as well + // renders the same list twice on one screen. + if (tasks.length > 0) directorHolder.instance?.restoreTasks(tasks); if (blocks.length > 0) emitter.emit("history.hydrate", blocks); }) .catch((err: unknown) => { diff --git a/src/tui/turns-to-blocks.test.ts b/src/tui/turns-to-blocks.test.ts index 28a6a0409..e48c59f28 100644 --- a/src/tui/turns-to-blocks.test.ts +++ b/src/tui/turns-to-blocks.test.ts @@ -29,14 +29,9 @@ function toolResultTurn(callId: string, isError: boolean): ConversationTurn { } describe("turnsToContentBlocks no longer derives tasks", () => { - test("a transcript with manage_tasks calls produces no tasks block on its own", () => { - const turns = [manageTasksTurn("m1", "doing"), toolResultTurn("m1", false)]; - const blocks = turnsToContentBlocks(turns); - expect(blocks.some((b) => b.type === "tasks")).toBe(false); - }); - - // The aggregated task block is unshifted separately on resume, so leaving - // the raw rows in would show every manage_tasks call twice over. + // Task state reaches the panel through hydrateTasksFromTurns and never the + // transcript, so the raw rows are stripped and no aggregated block replaces + // them. test("manage_tasks call and result rows are stripped from the resumed transcript", () => { const turns = [ manageTasksTurn("m1", "todo"), @@ -89,28 +84,28 @@ describe("hydrateTasksFromTurns", () => { }); describe("resume rendering, end to end (mirrors runner.ts's hydrate composition)", () => { - test("a manage_tasks call whose result errored shows the task exactly once", () => { + test("a manage_tasks call whose result errored leaves the transcript empty of it", () => { const turns = [manageTasksTurn("m1", "doing"), toolResultTurn("m1", true)]; const blocks = turnsToContentBlocks(turns); - const tasks = hydrateTasksFromTurns(turns); - if (tasks.length > 0) blocks.unshift({ type: "tasks", tasks }); - const taskBlocks = blocks.filter((b) => b.type === "tasks"); - expect(taskBlocks).toHaveLength(1); - expect(taskBlocks[0]).toEqual({ type: "tasks", tasks: [{ id: "t1", title: "work", status: "doing" }] }); + // The restored list goes to the task panel and nowhere else: the transcript + // carries neither the raw call rows nor an aggregated copy of the list. + expect(hydrateTasksFromTurns(turns)).toEqual([ + { id: "t1", title: "work", status: "doing" }, + ]); expect(blocks.some((b) => b.type === "tool_call" && b.name === "manage_tasks")).toBe(false); + expect(blocks.some((b) => b.type === "tool_result")).toBe(false); }); - test("a manage_tasks call with no result at all shows the task exactly once", () => { + test("a manage_tasks call with no result at all leaves the transcript empty of it", () => { const turns = [manageTasksTurn("m1", "doing")]; const blocks = turnsToContentBlocks(turns); - const tasks = hydrateTasksFromTurns(turns); - if (tasks.length > 0) blocks.unshift({ type: "tasks", tasks }); - const taskBlocks = blocks.filter((b) => b.type === "tasks"); - expect(taskBlocks).toHaveLength(1); + expect(hydrateTasksFromTurns(turns)).toEqual([ + { id: "t1", title: "work", status: "doing" }, + ]); expect(blocks.some((b) => b.type === "tool_call" && b.name === "manage_tasks")).toBe(false); }); }); diff --git a/src/tui/turns-to-blocks.ts b/src/tui/turns-to-blocks.ts index 1f4b16a9c..da941b3f0 100644 --- a/src/tui/turns-to-blocks.ts +++ b/src/tui/turns-to-blocks.ts @@ -12,7 +12,6 @@ export type ContentBlockData = | { type: "tool_call"; callId?: string; name: string; arguments: string; startedAt?: number } | { type: "tool_result"; callId: string; name: string; content: string; isError: boolean; finishedAt?: number } | { type: "reply"; content: string } - | { type: "tasks"; tasks: Task[] } | { type: "plan"; steps: PlanBlockStep[] } | { type: "view"; node: ViewNode } | { type: "error"; message: string };