From a95b9558a1d1fb072a0abcb88a73cc2b90efce8e Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sat, 8 Aug 2026 18:16:35 -0700 Subject: [PATCH 1/5] Keep task state out of the transcript Task state is live state owned by the task panel, and it was also being written into scrollback three separate ways: every manage_tasks call painted a tool row, resume unshifted an aggregated tasks block, and the Alt+T toggle appended a row saying which panels were showing. That rendered the same list twice on one screen - once in a panel that updates in place, and again as history that never does. With a dozen tasks it was a third of the noise on the screen by itself. The live path now drops manage_tasks calls and their results, tracking the suppressed call ids so a result never lands unpaired. Resume feeds restoreTasks and nothing else. The toggle says what it did in a flash, which is the right lifetime for a claim about the current screen. Nothing produces a tasks content block any more, so the block type and its hydration path go with it rather than staying as a shim. --- src/tui-opentui/history-hydrate.test.ts | 29 +++----- src/tui-opentui/history-hydrate.ts | 28 +------ src/tui-opentui/runtime-bridge.test.ts | 98 +++++++++++++++++++++++++ src/tui-opentui/runtime-bridge.ts | 21 ++++++ src/tui-opentui/shell.ts | 11 ++- src/tui-opentui/wave6.test.ts | 28 +++++++ src/tui/runner.ts | 8 +- src/tui/turns-to-blocks.test.ts | 33 ++++----- src/tui/turns-to-blocks.ts | 1 - 9 files changed, 183 insertions(+), 74 deletions(-) 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/shell.ts b/src/tui-opentui/shell.ts index 18f261503..357b74074 100644 --- a/src/tui-opentui/shell.ts +++ b/src/tui-opentui/shell.ts @@ -4313,6 +4313,9 @@ export function setChromeZones( }) } +/** 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 +4329,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..2b969c4ff 100644 --- a/src/tui-opentui/wave6.test.ts +++ b/src/tui-opentui/wave6.test.ts @@ -567,6 +567,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 }; From 258baac9098809dd982d46c4a83431a8059c8140 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sat, 8 Aug 2026 18:33:19 -0700 Subject: [PATCH 2/5] Make the agents strip a fleet board Aggregate header, trouble sorted above routine progress, a state word and the clock that justifies it per lane, and an honest hidden-lane count. The board is sized to its content and bounded by a fraction of the terminal; with two or more lanes live the transcript yields its idle floor so the fleet can be seen at once. Known defect, fixed in the commit that follows: the board can render more rows than geometry granted it. --- src/tui-opentui/chrome-state.test.ts | 136 +++++++++++----- src/tui-opentui/chrome-state.ts | 199 ++++++++++++++++------- src/tui-opentui/demo.ts | 52 ++++-- src/tui-opentui/geometry.test.ts | 47 +++++- src/tui-opentui/geometry/index.ts | 3 + src/tui-opentui/geometry/resolve.ts | 13 +- src/tui-opentui/geometry/zones.ts | 23 ++- src/tui-opentui/runtime-channels.test.ts | 10 +- src/tui-opentui/shell.ts | 42 ++++- 9 files changed, 389 insertions(+), 136 deletions(-) diff --git a/src/tui-opentui/chrome-state.test.ts b/src/tui-opentui/chrome-state.test.ts index 6959cc2c3..436490ca5 100644 --- a/src/tui-opentui/chrome-state.test.ts +++ b/src/tui-opentui/chrome-state.test.ts @@ -68,11 +68,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 +162,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 +172,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 +191,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 +206,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, + 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 }) + 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 + 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) }) test("observe empty id+desc hides", () => { @@ -230,10 +272,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 +282,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 +310,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 +333,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 +352,8 @@ describe("chromeFromSession", () => { description: "map callers", status: "running", currentToolName: "grep", + startedAt: NOW - 5_000, + lastActivityAt: NOW, }, ]) @@ -322,7 +363,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 +447,14 @@ 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. Expect main's in_tool vocabulary. + 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).toContain("run_shell 1:30") + expect(rows?.[1]?.tail).not.toContain("stalled") expect(agentProgress(inTool, NOW)?.stat).toContain("run_shell 1:30") }) @@ -421,7 +468,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 diff --git a/src/tui-opentui/chrome-state.ts b/src/tui-opentui/chrome-state.ts index 710e7e7dc..39e99afc2 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,107 @@ 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) +} + +/** + * 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 +306,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 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: ` · ${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 +463,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/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 357b74074..e614c7b19 100644 --- a/src/tui-opentui/shell.ts +++ b/src/tui-opentui/shell.ts @@ -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) } From 2ea467a088031f003373eceebc1e30f2016c9010 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sat, 8 Aug 2026 18:45:07 -0700 Subject: [PATCH 3/5] Never paint the fleet board past the rows it was granted The formatter sizes the board to its content, but collapse can grant it fewer rows than that. The rows were rendered before the resolver ran and were never clamped to its answer, so a dozen lanes on an 80x24 terminal added thirteen children to a seven-row box: rows painted on top of each other and on the transcript beneath, and the churn tore down text buffers the next repaint then wrote to. Rendering now happens after the resolver has spoken and only ever paints what it granted. Lanes that no longer fit are disclosed the same way the formatter discloses them, so the count stays honest all the way down. --- src/tui-opentui/chrome-state.ts | 40 +++++++++++++++++++++++++++ src/tui-opentui/shell.ts | 48 ++++++++++++++++++--------------- 2 files changed, 66 insertions(+), 22 deletions(-) diff --git a/src/tui-opentui/chrome-state.ts b/src/tui-opentui/chrome-state.ts index 39e99afc2..89041d097 100644 --- a/src/tui-opentui/chrome-state.ts +++ b/src/tui-opentui/chrome-state.ts @@ -277,6 +277,46 @@ function boardLaneState( 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. + */ +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") + + // 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, height - 1) + return [withHiddenCount(header, lanes.length - shown.length), ...shown] + } + + const shown = lanes.slice(0, height - 2) + const hidden = lanes.length - shown.length + return [ + header, + ...shown, + { label: `+${hidden} more lanes`, tail: "", stalled: false, kind: "more" }, + ] +} + +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. diff --git a/src/tui-opentui/shell.ts b/src/tui-opentui/shell.ts index e614c7b19..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, @@ -4319,32 +4319,36 @@ 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. */ From 10a3d6113f56ebf5b5308ae5642a83367642d61c Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sun, 9 Aug 2026 00:12:21 -0700 Subject: [PATCH 4/5] Assert agents row identity without deep-equal on renderables OpenTUI trees cycle parent/child links, so toEqual never finishes. Seed a stable row budget first, then check rebuild skips with reference identity when only a task retitle or identical agents push lands. --- src/tui-opentui/wave6.test.ts | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/src/tui-opentui/wave6.test.ts b/src/tui-opentui/wave6.test.ts index 2b969c4ff..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() From f51358318593bc4afc7332f779a3265f188b1acd Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sun, 9 Aug 2026 00:19:06 -0700 Subject: [PATCH 5/5] Keep fleet board disclosures honest under re-clamp When geometry grants fewer rows than the formatter already folded, carry the prior +N more / header hidden count into the new total so operators still see every running lane accounted for. Paint lane state as operator copy (in tool) rather than the machine token. --- src/tui-opentui/chrome-state.test.ts | 46 ++++++++++++++++++++- src/tui-opentui/chrome-state.ts | 60 ++++++++++++++++++++++++---- 2 files changed, 97 insertions(+), 9 deletions(-) diff --git a/src/tui-opentui/chrome-state.test.ts b/src/tui-opentui/chrome-state.test.ts index 436490ca5..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, @@ -447,12 +448,14 @@ describe("lane state survives the mapping hops", () => { undefined, NOW, ) - // Board: header first, then the lane. Expect main's in_tool vocabulary. + // 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).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") @@ -495,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 89041d097..46952d6cb 100644 --- a/src/tui-opentui/chrome-state.ts +++ b/src/tui-opentui/chrome-state.ts @@ -285,6 +285,11 @@ function boardLaneState( * 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[], @@ -296,27 +301,68 @@ export function clampBoardRows( 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, height - 1) - return [withHiddenCount(header, lanes.length - shown.length), ...shown] + 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, height - 2) - const hidden = lanes.length - shown.length + const shown = lanes.slice(0, Math.max(0, height - 2)) + const hidden = priorHidden + (lanes.length - shown.length) return [ - header, + 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. @@ -366,12 +412,12 @@ function formatAgentRow( // 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 state word (and kind: lane) the way the fleet board reads. + // the operator-facing state word (and kind: lane) the way the fleet board reads. const progress = agentProgress(progressSession, nowMs, stallMs) if (progress !== null) { return { label, - tail: ` · ${state} · ${progress.stat}`, + tail: ` · ${laneStateWord(state)} · ${progress.stat}`, stalled, kind: "lane", }