From b4d94f55e244f996b819770744ae5425ac51bdad Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sat, 8 Aug 2026 09:26:58 -0700 Subject: [PATCH 1/5] Restore the live agents panel above the transcript MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The OpenTUI cutover collapsed live sub-agents to a single chrome line of counts ("agents: 5 live · 4 cancelled"), losing the one-row-per-agent panel the pre-cutover Ink shell rendered. Per-agent elapsed/tool/stall detail already existed in agent-progress.ts but only reached a transcript row trailer that scrolled away. The agents zone now renders one row per running agent (elapsed, current tool, stalled marker), bounded to AGENTS_PANEL_MAX_VISIBLE with a trailing "+N more" row, and sized through the geometry resolver's zone max rather than a fixed guess. Zero running agents costs zero rows. --- docs/TUI.md | 22 +++++ src/tui-opentui/chrome-state.test.ts | 122 +++++++++++++++--------- src/tui-opentui/chrome-state.ts | 133 ++++++++++++++++----------- src/tui-opentui/demo.ts | 14 ++- src/tui-opentui/geometry.test.ts | 28 ++++++ src/tui-opentui/geometry/index.ts | 1 + src/tui-opentui/geometry/resolve.ts | 5 +- src/tui-opentui/geometry/zones.ts | 17 +++- src/tui-opentui/shell.ts | 58 ++++++++---- src/tui-opentui/wave6.test.ts | 4 +- src/tui/runner.ts | 2 + 11 files changed, 280 insertions(+), 126 deletions(-) diff --git a/docs/TUI.md b/docs/TUI.md index ed1068407..6f5ff71d6 100644 --- a/docs/TUI.md +++ b/docs/TUI.md @@ -53,6 +53,28 @@ uses the bronze/sand/ember chrome ramp and green (`UI.done`) for completion. The one deliberate exception is diff removals, where orange is content (the removed line), not a decision marker, and no decision-marker shares that row. +## The live agents panel + +The `agents` chrome zone renders a standing panel above the transcript, one +row per currently-running sub-agent — not a count. Each row reads +`agentId: description · elapsed · tool`, sourced from the same +`agentProgress()` clock/tool/stall computation used to trail a task row in the +transcript (`src/tui-opentui/agent-progress.ts`); the panel does not compute +progress a second way. A worker silent past the stall window (`DEFAULT_STALL_MS`) +gets a `· stalled` suffix so it reads distinct from one still working, without +relying on color alone. + +The panel is bounded to `AGENTS_PANEL_MAX_VISIBLE` rows +(`src/tui-opentui/geometry/zones.ts`); a larger fan-out degrades to a trailing +`+N more` row rather than growing the zone — and therefore the chrome +budget — without limit. Its height is requested from the geometry resolver +like every other zone, never guessed: the caller passes the exact row count it +is about to render (`ZoneVisibility.agents: boolean | number`), and the +resolver clamps it to the zone's registered max. Agents that have reached a +terminal state (done/failed/cancelled) do not occupy a row; zero running +agents is zero rows and zero chrome. `observe` mode overrides the panel with a +single `observe: ` line instead of per-agent rows. + ## How pop-ups should feel A blocking surface (permissions, an operator question, the model/provider diff --git a/src/tui-opentui/chrome-state.test.ts b/src/tui-opentui/chrome-state.test.ts index 2f703ba5b..d77bd7353 100644 --- a/src/tui-opentui/chrome-state.test.ts +++ b/src/tui-opentui/chrome-state.test.ts @@ -2,13 +2,15 @@ import { describe, expect, test } from "bun:test" import { annotateAgentTools, chromeFromSession, - formatAgentsLine, + formatAgentsPanel, formatChromeZones, formatGoalLine, formatTaskLine, type ChromeLiveState, } from "./chrome-state" +const NOW = 1_000_000 + describe("formatChromeZones", () => { test("empty state hides all zones", () => { expect(formatChromeZones({})).toEqual({ @@ -70,6 +72,8 @@ describe("formatChromeZones", () => { description: "map setChromeZones callers", status: "running", currentToolName: "grep", + startedAt: NOW - 5_000, + lastActivityAt: NOW, }, { agentId: "general", @@ -78,32 +82,32 @@ describe("formatChromeZones", () => { }, ], } - const out = formatChromeZones(state) + const out = formatChromeZones(state, NOW) expect(out.goal).toBe("goal: review · 2/4 · 1:1 OpenTUI cutover") expect(out.task).toBe("task: chrome live helper (+2)") - expect(out.agents).toContain("1 live") - expect(out.agents).toContain("explore:") - expect(out.agents).toContain("grep") - expect(out.agents).toContain("1 done") + expect(out.agents).toEqual(["explore: map setChromeZones callers · 0:05 · grep"]) }) - test("observe overrides agents line", () => { - const out = formatChromeZones({ - agents: [ - { + test("observe overrides the agents panel", () => { + const out = formatChromeZones( + { + agents: [ + { + agentId: "explore", + description: "map callers", + status: "running", + }, + ], + observe: { agentId: "explore", - description: "map callers", - status: "running", + description: "map callers of openListOverlay", }, - ], - observe: { - agentId: "explore", - description: "map callers of openListOverlay", }, - }) - expect(out.agents).toBe( - "observe: explore — map callers of openListOverlay", + NOW, ) + expect(out.agents).toEqual([ + "observe: explore — map callers of openListOverlay", + ]) }) }) @@ -195,41 +199,71 @@ describe("formatTaskLine", () => { }) }) -describe("formatAgentsLine", () => { +describe("formatAgentsPanel", () => { test("empty hides", () => { - expect(formatAgentsLine(null)).toBeNull() - expect(formatAgentsLine([])).toBeNull() + expect(formatAgentsPanel(null, undefined, NOW)).toBeNull() + expect(formatAgentsPanel([], undefined, NOW)).toBeNull() + }) + + test("one row per running agent", () => { + expect( + formatAgentsPanel( + [ + { agentId: "a", description: "one", status: "running", startedAt: NOW - 1_000, lastActivityAt: NOW }, + { agentId: "b", description: "two", status: "running", startedAt: NOW - 2_000, lastActivityAt: NOW }, + ], + undefined, + NOW, + ), + ).toEqual(["a: one · 0:01", "b: two · 0:02"]) }) - test("multi live summary without single-agent detail", () => { + test("terminal-only list renders zero rows", () => { expect( - formatAgentsLine([ + formatAgentsPanel( + [ + { agentId: "a", description: "x", status: "done" }, + { agentId: "b", description: "y", status: "failed" }, + ], + undefined, + NOW, + ), + ).toBeNull() + }) + + test("stalled agent is visually distinct in its label", () => { + const rows = formatAgentsPanel( + [ { agentId: "a", - description: "one", - status: "running", - }, - { - agentId: "b", - description: "two", + description: "quiet worker", status: "running", + startedAt: NOW - 60_000, + lastActivityAt: NOW - 40_000, }, - ]), - ).toBe("agents: 2 live") + ], + undefined, + NOW, + ) + expect(rows).toEqual(["a: quiet worker · 1:00 · stalled"]) }) - test("terminal-only list still counts", () => { - expect( - formatAgentsLine([ - { agentId: "a", description: "x", status: "done" }, - { agentId: "b", description: "y", status: "failed" }, - ]), - ).toBe("agents: 1 done · 1 failed") + test("bounds fan-out to maxVisible plus a +N more row", () => { + const running = Array.from({ length: 8 }, (_, i) => ({ + agentId: `agent-${i}`, + description: "working", + status: "running" as const, + startedAt: NOW, + lastActivityAt: NOW, + })) + const rows = formatAgentsPanel(running, undefined, NOW, 5) + expect(rows).toHaveLength(6) + expect(rows?.[5]).toBe("+3 more") }) test("observe empty id+desc hides", () => { expect( - formatAgentsLine([], { agentId: " ", description: " " }), + formatAgentsPanel([], { agentId: " ", description: " " }, NOW), ).toBeNull() }) }) @@ -280,10 +314,10 @@ describe("chromeFromSession", () => { }, ]) - const zones = formatChromeZones(state) + const zones = formatChromeZones(state, NOW) expect(zones.goal).toBe("goal: impl · 1/2 · ship cutover") expect(zones.task).toBe("task: wire catalogs (+1)") - expect(zones.agents).toContain("1 live") + expect(zones.agents).toEqual(["explore: map callers · grep"]) }) test("falls back agent id and goal condition; empty bags hide", () => { @@ -313,9 +347,9 @@ describe("chromeFromSession", () => { agentId: "explore", description: "watch", }) - expect(formatChromeZones(state).agents).toBe( + expect(formatChromeZones(state, NOW).agents).toEqual([ "observe: explore — watch", - ) + ]) }) }) diff --git a/src/tui-opentui/chrome-state.ts b/src/tui-opentui/chrome-state.ts index b52d5c623..8d775b18e 100644 --- a/src/tui-opentui/chrome-state.ts +++ b/src/tui-opentui/chrome-state.ts @@ -22,15 +22,21 @@ * stale. Observe mode can override the agents line via `state.observe`. */ +import { agentProgress, DEFAULT_STALL_MS } from "./agent-progress.js" +import { AGENTS_PANEL_MAX_VISIBLE } from "./geometry/zones.js" import type { ChromeZoneContent } from "./shell.js" -/** Subagent row shape for the agents chrome line (store-agnostic). */ +/** Subagent row shape for the agents chrome panel (store-agnostic). */ export type ChromeAgentSession = { readonly agentId: string readonly description: string readonly status: "running" | "done" | "failed" | "cancelled" /** Current tool while running (optional detail). */ readonly currentToolName?: string | null + /** Clock the worker started; feeds the panel row's elapsed time. */ + readonly startedAt?: number + /** Clock of the worker's last reported activity; feeds stalled detection. */ + readonly lastActivityAt?: number } /** @@ -93,7 +99,8 @@ export type ChromeLiveState = { export type FormattedChromeZones = { readonly goal: string | null readonly task: string | null - readonly agents: string | null + /** One line per rendered agents-panel row (null = hide zone, zero rows). */ + readonly agents: readonly string[] | null } const PHASE_SHORT: Record = { @@ -109,11 +116,14 @@ const PHASE_SHORT: Record = { * Empty / partial / inactive inputs yield null for the corresponding zone * so geometry collapses that strip (idleDefault 0). */ -export function formatChromeZones(state: ChromeLiveState): FormattedChromeZones { +export function formatChromeZones( + state: ChromeLiveState, + nowMs: number = Date.now(), +): FormattedChromeZones { return { goal: formatGoalLine(state.goal), task: formatTaskLine(state.task), - agents: formatAgentsLine(state.agents, state.observe), + agents: formatAgentsPanel(state.agents, state.observe, nowMs), } } @@ -202,63 +212,72 @@ function formatTaskLineFromRows(rows: readonly ChromeTaskRow[]): string | null { return compactLine("task", `${title}${suffix}`) } -export function formatAgentsLine( +/** + * 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. + */ +export function formatAgentsPanel( agents: readonly ChromeAgentSession[] | null | undefined, - observe?: ChromeLiveState["observe"], -): string | null { - if (observe !== null && observe !== undefined) { - const id = observe.agentId.trim() - const desc = observe.description.trim() - if (id.length === 0 && desc.length === 0) return null - const label = - id.length > 0 && desc.length > 0 - ? `${id} — ${desc}` - : id.length > 0 - ? id - : desc - return `observe: ${label}` - } + observe: ChromeLiveState["observe"], + nowMs: number, + maxVisible: number = AGENTS_PANEL_MAX_VISIBLE, + stallMs: number = DEFAULT_STALL_MS, +): readonly string[] | null { + const observeLine = formatObserveLine(observe) + if (observeLine !== undefined) return observeLine === null ? null : [observeLine] - if (agents === null || agents === undefined || agents.length === 0) { - return null - } + if (agents === null || agents === undefined || agents.length === 0) return null const running = agents.filter((s) => s.status === "running") - const done = agents.filter((s) => s.status === "done").length - const failed = agents.filter((s) => s.status === "failed").length - const cancelled = agents.filter((s) => s.status === "cancelled").length + if (running.length === 0) return null - const parts: string[] = [] - if (running.length > 0) { - parts.push(`${running.length} live`) - } - if (done > 0) parts.push(`${done} done`) - if (failed > 0) parts.push(`${failed} failed`) - if (cancelled > 0) parts.push(`${cancelled} cancelled`) - - // Prefer a summary count line; when a single agent is live, add its label. - if (running.length === 1) { - const s = running[0]! - const tool = - s.currentToolName !== undefined && - s.currentToolName !== null && - s.currentToolName.length > 0 - ? ` · ${s.currentToolName}` - : "" - const label = `${s.agentId}: ${s.description}${tool}`.trim() - if (label.length > 2) { - // "agents: 1 live · explore: map callers" - const summary = parts.length > 0 ? parts.join(" · ") : "1 live" - return compactLine("agents", `${summary} · ${label}`) - } - } + const visible = running.slice(0, maxVisible) + const rows = visible.map((s) => formatAgentRow(s, nowMs, stallMs)) + const hidden = running.length - visible.length + if (hidden > 0) rows.push(`+${hidden} more`) + return rows +} - if (parts.length === 0) { - // Only terminal sessions present — still show a count so inspect chrome - // can surface "agents: 2 done" when host passes full listForStrip(). - return compactLine("agents", `${agents.length}`) +function formatObserveLine(observe: ChromeLiveState["observe"]): string | null | undefined { + if (observe === null || observe === undefined) return undefined + const id = observe.agentId.trim() + const desc = observe.description.trim() + if (id.length === 0 && desc.length === 0) return null + const label = + id.length > 0 && desc.length > 0 ? `${id} — ${desc}` : id.length > 0 ? id : desc + return `observe: ${label}` +} + +function formatAgentRow(session: ChromeAgentSession, nowMs: number, stallMs: number): string { + const label = `${session.agentId}: ${session.description}`.trim() + const progress = + session.startedAt !== undefined + ? agentProgress( + { + status: "running", + currentToolName: session.currentToolName ?? null, + startedAt: session.startedAt, + lastActivityAt: session.lastActivityAt ?? session.startedAt, + }, + nowMs, + stallMs, + ) + : null + + if (progress !== null) { + const stalledSuffix = progress.stalled ? " · stalled" : "" + return `${label} · ${progress.stat}${stalledSuffix}` } - return compactLine("agents", parts.join(" · ")) + + // 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 + return tool !== undefined && tool !== null && tool.length > 0 + ? `${label} · ${tool}` + : label } /** @@ -330,6 +349,8 @@ export type ChromeSessionAgent = { readonly description: string readonly status: "running" | "done" | "failed" | "cancelled" readonly currentToolName?: string | null + readonly startedAt?: number + readonly lastActivityAt?: number } /** @@ -424,6 +445,10 @@ function mapSessionAgents( ...(a.currentToolName !== undefined ? { currentToolName: a.currentToolName } : {}), + ...(a.startedAt !== undefined ? { startedAt: a.startedAt } : {}), + ...(a.lastActivityAt !== undefined + ? { lastActivityAt: a.lastActivityAt } + : {}), } }) } diff --git a/src/tui-opentui/demo.ts b/src/tui-opentui/demo.ts index 5903de761..a06253415 100644 --- a/src/tui-opentui/demo.ts +++ b/src/tui-opentui/demo.ts @@ -310,13 +310,21 @@ renderer.keyInput.on("keypress", (key: KeyEvent) => { shell.prompt.value.length === 0 ) { const on = shell.layout.heights.agents > 0 - // Empty list → null (hide). Demo forces a zero-live summary string when on. setChromeZones(shell, { agents: on ? null : formatChromeZones({ - agents: [], - }).agents ?? "agents: 0 live", + agents: [ + { + agentId: "explore", + description: "map callers", + status: "running", + currentToolName: "grep", + startedAt: Date.now() - 42_000, + lastActivityAt: Date.now(), + }, + ], + }).agents, }) return } diff --git a/src/tui-opentui/geometry.test.ts b/src/tui-opentui/geometry.test.ts index 587e6dbc0..c0fb070c3 100644 --- a/src/tui-opentui/geometry.test.ts +++ b/src/tui-opentui/geometry.test.ts @@ -1,5 +1,6 @@ import { describe, expect, test } from "bun:test"; import { + AGENTS_PANEL_MAX_VISIBLE, COLLAPSE_ORDER, IDLE_TRANSCRIPT_FLOOR, OVERLAY_TRANSCRIPT_FLOOR, @@ -98,6 +99,33 @@ 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 + 3; n++) { + const requested = Math.min(n, AGENTS_PANEL_MAX_VISIBLE + 1); + const layout = idle80x24({ visibility: { agents: n } }); + expect(layout.heights.agents).toBe(requested); + } + }); + + test("zero agents costs zero chrome", () => { + const layout = idle80x24({ visibility: { agents: 0 } }); + expect(layout.heights.agents).toBe(0); + expect(layout.regions.agents).toBeUndefined(); + }); + + test("a large fan-out never grows the zone past its bounded max", () => { + const layout = idle80x24({ visibility: { agents: 50 } }); + expect(layout.heights.agents).toBe(ZONE_REGISTRY.agents.max); + expect(layout.heights.agents).toBe(AGENTS_PANEL_MAX_VISIBLE + 1); + }); + + test("a bounded agents panel never eats the transcript floor", () => { + const layout = idle80x24({ visibility: { agents: AGENTS_PANEL_MAX_VISIBLE + 1 } }); + expect(layout.transcriptHeight).toBeGreaterThanOrEqual(layout.transcriptFloor); + }); +}); + describe("resolveGeometry — collapse rules", () => { test("collapses optional strips before violating idle floor", () => { // Request every optional strip + tall progress on 24 rows. diff --git a/src/tui-opentui/geometry/index.ts b/src/tui-opentui/geometry/index.ts index d0e75000f..4e3c7eb25 100644 --- a/src/tui-opentui/geometry/index.ts +++ b/src/tui-opentui/geometry/index.ts @@ -1,4 +1,5 @@ export { + AGENTS_PANEL_MAX_VISIBLE, COLLAPSE_ORDER, IDLE_TRANSCRIPT_FLOOR, OVERLAY_MAX_FRACTION, diff --git a/src/tui-opentui/geometry/resolve.ts b/src/tui-opentui/geometry/resolve.ts index b0bcb0233..de57282d0 100644 --- a/src/tui-opentui/geometry/resolve.ts +++ b/src/tui-opentui/geometry/resolve.ts @@ -48,7 +48,8 @@ export type ZoneVisibility = { readonly progressDivider?: boolean; readonly goal?: boolean; readonly task?: boolean; - readonly agents?: boolean; + /** Agents panel: false/omit = 0 rows; true = 1 row; or an exact row count (bounded by the zone max). */ + readonly agents?: boolean | number; readonly pluginBanner?: boolean; /** Command banner: true → 1 row, or explicit 1|2. */ readonly commandBanner?: boolean | 1 | 2; @@ -141,7 +142,7 @@ export function desiredHeights(input: GeometryInput): MutableHeights { prompt: promptRows, goal: vis.goal ? 1 : 0, task: vis.task ? 1 : 0, - agents: vis.agents ? 1 : 0, + agents: clamp(boolOrRows(vis.agents, 1), 0, ZONE_REGISTRY.agents.max), 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 66f2f6dda..675cf8bb7 100644 --- a/src/tui-opentui/geometry/zones.ts +++ b/src/tui-opentui/geometry/zones.ts @@ -35,6 +35,13 @@ export type ZoneDeclaration = { readonly alwaysOn: boolean; }; +/** + * Bound on rendered agent rows in the live agents panel. A large fan-out + * 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; + /** * Fixed-with-test budgets from the constitution table. * Residual zones (transcript, overlay_host) use min/max as floor/cap hints; @@ -63,7 +70,15 @@ export const ZONE_REGISTRY: { readonly [K in ZoneId]: ZoneDeclaration } = { }, goal: { id: "goal", min: 0, max: 1, idleDefault: 0, alwaysOn: false }, task: { id: "task", min: 0, max: 1, idleDefault: 0, alwaysOn: false }, - agents: { id: "agents", min: 0, max: 1, idleDefault: 0, alwaysOn: false }, + // One row per running agent (bounded by AGENTS_PANEL_MAX_VISIBLE) plus an + // optional trailing "+N more" row. + agents: { + id: "agents", + min: 0, + max: AGENTS_PANEL_MAX_VISIBLE + 1, + idleDefault: 0, + alwaysOn: false, + }, plugin_banner: { id: "plugin_banner", min: 0, diff --git a/src/tui-opentui/shell.ts b/src/tui-opentui/shell.ts index 7889ffe1e..5f8afc8c0 100644 --- a/src/tui-opentui/shell.ts +++ b/src/tui-opentui/shell.ts @@ -531,8 +531,8 @@ export type AppShell = { readonly goalText: TextRenderable readonly taskBox: BoxRenderable readonly taskText: TextRenderable + /** One row per rendered agents-panel line; rebuilt whenever the line count changes. */ readonly agentsBox: BoxRenderable - readonly agentsText: TextRenderable readonly transcript: ScrollBoxRenderable readonly overlayHost: BoxRenderable readonly overlayTitle: TextRenderable @@ -1804,7 +1804,8 @@ type ShellInternals = { chrome: { goal: string task: string - agents: string + /** Agents panel rows (empty array = zone off), one row per rendered line. */ + agents: readonly string[] } } @@ -3998,7 +3999,7 @@ export function runPaletteAction( const bag = internals.get(shell) const on = (bag?.chrome.agents.length ?? 0) > 0 setChromeZones(shell, { - agents: on ? null : "agents: 0 running", + agents: on ? null : ["explore: map callers"], }) appendStreamRow(shell, { role: "system", @@ -4042,7 +4043,28 @@ export function runPaletteAction( export type ChromeZoneContent = { readonly goal?: string | null readonly task?: string | null - readonly agents?: string | null + /** One line per agents-panel row. Null/empty = hide the zone. */ + readonly agents?: readonly string[] | null +} + +/** A stalled row reads distinct from a working one by both label and color. */ +function isStalledAgentRow(line: string): boolean { + return line.endsWith(" · stalled") +} + +/** Rebuild agentsBox's row children to match the requested lines exactly. */ +function renderAgentsRows(shell: AppShell, lines: readonly string[]): void { + for (const child of [...shell.agentsBox.getChildren()]) { + shell.agentsBox.remove(child) + destroySubtree(child) + } + for (const line of lines) { + const row = new TextRenderable(shell.renderer as CliRenderer, { + content: ` ${line}`, + fg: isStalledAgentRow(line) ? UI.textDim : UI.inFlight, + }) + shell.agentsBox.add(row) + } } /** @@ -4063,23 +4085,25 @@ export function setChromeZones( bag.chrome.task = content.task ?? "" } if (content.agents !== undefined) { - bag.chrome.agents = content.agents ?? "" + bag.chrome.agents = content.agents ?? [] } const goalOn = bag.chrome.goal.length > 0 const taskOn = bag.chrome.task.length > 0 - const agentsOn = bag.chrome.agents.length > 0 + const agentsRowCount = bag.chrome.agents.length + const agentsOn = agentsRowCount > 0 shell.goalText.content = goalOn ? ` ${bag.chrome.goal}` : "" shell.taskText.content = taskOn ? ` ${bag.chrome.task}` : "" - shell.agentsText.content = agentsOn ? ` ${bag.chrome.agents}` : "" + renderAgentsRows(shell, bag.chrome.agents) - // Only a zone appearing or disappearing changes the row budget; retitling a - // zone that is already on must not re-resolve and re-apply the whole layout. + // 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 ( goalOn === bag.visibility.goal && taskOn === bag.visibility.task && - agentsOn === bag.visibility.agents + agentsRowCount === bag.visibility.agents ) { paintChrome(shell) return @@ -4090,7 +4114,7 @@ export function setChromeZones( ...bag.visibility, goal: goalOn, task: taskOn, - agents: agentsOn, + agents: agentsRowCount, }, overlayMode: bag.overlayMode, ...(bag.overlayBodyRows !== undefined @@ -4241,7 +4265,7 @@ export function enterSubagentObserve( shell.focus = openObserve(shell.focus, `observe-${session.sessionId}`) setChromeZones(shell, { - agents: `observe: ${session.agentId} — ${session.description}`, + agents: [`observe: ${session.agentId} — ${session.description}`], }) // Child chrome toast — must not route to parent snapshot. appendObserveStreamRow(shell, { @@ -4779,15 +4803,10 @@ export function createAppShell( width: "100%", height: 1, flexShrink: 0, + flexDirection: "column", backgroundColor: UI.ground, visible: false, }) - const agentsText = new TextRenderable(ctx, { - id: "shell-agents-text", - content: "", - fg: UI.done, - }) - agentsBox.add(agentsText) const transcript = new ScrollBoxRenderable(ctx, { id: "shell-transcript", @@ -5430,7 +5449,6 @@ export function createAppShell( taskBox, taskText, agentsBox, - agentsText, transcript, overlayHost, overlayTitle, @@ -5525,7 +5543,7 @@ export function createAppShell( landingSuggestionsVisible: true, landingAnimating: false, landingNowMs: 0, - chrome: { goal: "", task: "", agents: "" }, + chrome: { goal: "", task: "", agents: [] }, }) transcriptSpacers.set(shell, transcriptSpacer) if (onCommandOpt) setPaletteOnCommand(shell, onCommandOpt) diff --git a/src/tui-opentui/wave6.test.ts b/src/tui-opentui/wave6.test.ts index c02af88f2..b41ba1b4c 100644 --- a/src/tui-opentui/wave6.test.ts +++ b/src/tui-opentui/wave6.test.ts @@ -290,7 +290,7 @@ describe("Wave 6: chrome zones", () => { setChromeZones(shell, { goal: "goal: Wave 6", task: "task: chrome zones", - agents: "agents: 0", + agents: ["explore: map callers"], }) expect(shell.layout.heights.goal).toBe(1) @@ -306,7 +306,7 @@ describe("Wave 6: chrome zones", () => { const frame = h.captureCharFrame() expect(frame).toContain("goal: Wave 6") expect(frame).toContain("task: chrome zones") - expect(frame).toContain("agents: 0") + expect(frame).toContain("explore: map callers") setChromeZones(shell, { goal: null, task: null, agents: null }) expect(shell.layout.heights.goal).toBe(0) diff --git a/src/tui/runner.ts b/src/tui/runner.ts index a9fc1dc42..b451f8c57 100644 --- a/src/tui/runner.ts +++ b/src/tui/runner.ts @@ -2037,6 +2037,8 @@ export async function runTUI(initialConfig: Config): Promise { description: s.description, status: s.status, currentToolName: s.currentToolName, + startedAt: s.startedAt, + lastActivityAt: s.lastActivityAt, })), }), subscribeChrome: (notify) => { From 6de524ca34283ce38458da013e2619a6a745b0eb Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sat, 8 Aug 2026 09:34:02 -0700 Subject: [PATCH 2/5] Keep the agents panel live on wall clock, and stop rebuilding rows unchanged Critique review found two gaps in the agents panel: its elapsed clock and stalled flag only refreshed on the next unrelated chrome event (goal change, subagent progress), so a worker that went quiet with no further events never flipped to stalled; and setChromeZones rebuilt every agent row's TextRenderable on every call, including pushes that only touched goal or task. The sticky poll now repaints the agents panel on its own 200ms tick, matching the cadence already used for the transcript trailer. Row rebuild is now skipped unless the panel's actual lines changed. --- src/tui-opentui/product-host.test.ts | 40 +++++++++++++++++++++++++++- src/tui-opentui/product-host.ts | 5 ++++ src/tui-opentui/shell.ts | 12 +++++++-- src/tui-opentui/wave6.test.ts | 32 ++++++++++++++++++++++ 4 files changed, 86 insertions(+), 3 deletions(-) diff --git a/src/tui-opentui/product-host.test.ts b/src/tui-opentui/product-host.test.ts index 9740b24a9..a90c95701 100644 --- a/src/tui-opentui/product-host.test.ts +++ b/src/tui-opentui/product-host.test.ts @@ -47,6 +47,8 @@ async function mountHeadless( host: Awaited> emitter: EventEmitter destroyHarness: () => void + renderOnce: () => Promise + captureCharFrame: () => string }> { const harness = await createHarness({ width: 80, height: 24 }) const emitter = new EventEmitter() @@ -60,7 +62,13 @@ async function mountHeadless( createRenderer: async () => harness.renderer, ...overrides, }) - return { host, emitter, destroyHarness: harness.destroy } + return { + host, + emitter, + destroyHarness: harness.destroy, + renderOnce: harness.renderOnce, + captureCharFrame: harness.captureCharFrame, + } } function makeRequest( @@ -270,6 +278,36 @@ describe("mountProductHost", () => { ).not.toThrow() expect(host.shell.streamLog).toEqual([]) }) + + test("the agents panel's elapsed clock advances on the sticky poll tick, without another chrome push", async () => { + const now = Date.now() + const { host, renderOnce, captureCharFrame } = await mountHeadless({ + chrome: { + agents: [ + { + agentId: "explore", + description: "map callers", + status: "running", + startedAt: now - 59_000, + lastActivityAt: now, + }, + ], + }, + }) + try { + await renderOnce() + expect(captureCharFrame()).toContain("0:59") + + // No further chrome push or event — only wall-clock time passing. + // Only the 200ms sticky poll can be responsible for the clock moving. + await new Promise((r) => setTimeout(r, 1_100)) + await renderOnce() + expect(captureCharFrame()).not.toContain("0:59") + expect(captureCharFrame()).toMatch(/1:0\d/) + } finally { + host.dispose() + } + }) }) describe("provider-first model picker", () => { diff --git a/src/tui-opentui/product-host.ts b/src/tui-opentui/product-host.ts index f1e8de68d..c3305e3a6 100644 --- a/src/tui-opentui/product-host.ts +++ b/src/tui-opentui/product-host.ts @@ -392,6 +392,11 @@ export async function mountProductHost( if (config.subAgentSessions !== undefined) { bridge.syncAgentProgress(config.subAgentSessions()) } + // The agents panel's elapsed clock and stalled flag are a function of + // wall time, not just of the last event — repaint on the same tick as + // the transcript trailer so a worker that goes quiet still flips to + // "stalled" without waiting on an unrelated chrome push. + if (chromeState !== null) paintChromeZones() } catch { clearInterval(stickyPoll) } diff --git a/src/tui-opentui/shell.ts b/src/tui-opentui/shell.ts index 5f8afc8c0..c9be0f3dc 100644 --- a/src/tui-opentui/shell.ts +++ b/src/tui-opentui/shell.ts @@ -4084,8 +4084,13 @@ export function setChromeZones( if (content.task !== undefined) { bag.chrome.task = content.task ?? "" } + let agentsChanged = false if (content.agents !== undefined) { - bag.chrome.agents = content.agents ?? [] + const next = content.agents ?? [] + agentsChanged = + next.length !== bag.chrome.agents.length || + next.some((line, i) => line !== bag.chrome.agents[i]) + bag.chrome.agents = next } const goalOn = bag.chrome.goal.length > 0 @@ -4095,7 +4100,10 @@ export function setChromeZones( shell.goalText.content = goalOn ? ` ${bag.chrome.goal}` : "" shell.taskText.content = taskOn ? ` ${bag.chrome.task}` : "" - renderAgentsRows(shell, bag.chrome.agents) + // Rebuilding N TextRenderable children is real node churn; skip it unless + // the panel's actual lines changed (not every goal/task/agents push carries + // new agent data). + if (agentsChanged) renderAgentsRows(shell, bag.chrome.agents) // Only a zone appearing/disappearing or its row count changing alters the // row budget; retitling a zone whose row count is unchanged must not diff --git a/src/tui-opentui/wave6.test.ts b/src/tui-opentui/wave6.test.ts index b41ba1b4c..72f45917e 100644 --- a/src/tui-opentui/wave6.test.ts +++ b/src/tui-opentui/wave6.test.ts @@ -321,6 +321,38 @@ describe("Wave 6: chrome zones", () => { { width: 80, height: 24 }, ) }) + + test("agents panel rows are only rebuilt when the panel's lines actually change", async () => { + await withTestRenderer( + async (h) => { + const shell = createAppShell(h.renderer, { + terminal: { columns: 80, rows: 24 }, + wireKeys: false, + }) + try { + setChromeZones(shell, { agents: ["explore: map callers"] }) + const rowsBefore = [...shell.agentsBox.getChildren()] + expect(rowsBefore).toHaveLength(1) + + // An unrelated goal push must not touch the agents rows. + setChromeZones(shell, { goal: "goal: unrelated" }) + expect([...shell.agentsBox.getChildren()]).toEqual(rowsBefore) + + // Pushing the exact same agent lines again must not rebuild either. + setChromeZones(shell, { agents: ["explore: map callers"] }) + expect([...shell.agentsBox.getChildren()]).toEqual(rowsBefore) + + // Changed lines must rebuild. + setChromeZones(shell, { agents: ["explore: map callers · 0:01"] }) + expect([...shell.agentsBox.getChildren()]).not.toEqual(rowsBefore) + expect(shell.agentsBox.getChildren()).toHaveLength(1) + } finally { + shell.dispose() + } + }, + { width: 80, height: 24 }, + ) + }) }) describe("Wave 6: keyboard copy path", () => { From 58a96e8e38d7722f58a572ac0e0b6ce7c40e519c Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sat, 8 Aug 2026 09:46:50 -0700 Subject: [PATCH 3/5] Keep the stalled agent visible when a fan-out truncates the panel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Second-review pass found that the panel took whatever order the caller passed running agents in. The real feed sorts running sessions newest-first, so a fan-out past the visible cap folded the oldest — and therefore most likely stalled — worker into the trailing "+N more" row, silently hiding the one agent an operator most needs to see. Rows are now selected oldest-last-activity-first before slicing to the visible cap. Also shares the "stalled" row suffix as one constant between the formatter and the renderer instead of two independent string literals, and gives the panel's working rows a color distinct from the task zone immediately above it. --- src/tui-opentui/chrome-state.test.ts | 25 +++++++++++++++++++++++++ src/tui-opentui/chrome-state.ts | 18 ++++++++++++++++-- src/tui-opentui/shell.ts | 8 +++++--- 3 files changed, 46 insertions(+), 5 deletions(-) diff --git a/src/tui-opentui/chrome-state.test.ts b/src/tui-opentui/chrome-state.test.ts index d77bd7353..fe1208a70 100644 --- a/src/tui-opentui/chrome-state.test.ts +++ b/src/tui-opentui/chrome-state.test.ts @@ -266,6 +266,31 @@ describe("formatAgentsPanel", () => { formatAgentsPanel([], { agentId: " ", description: " " }, NOW), ).toBeNull() }) + + 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". + const newest = Array.from({ length: 5 }, (_, i) => ({ + agentId: `fresh-${i}`, + description: "just started", + status: "running" as const, + startedAt: NOW, + lastActivityAt: NOW, + })) + const stalled = { + agentId: "quiet", + description: "gone silent", + status: "running" as const, + startedAt: NOW - 300_000, + lastActivityAt: NOW - 250_000, + } + const rows = formatAgentsPanel([...newest, stalled], undefined, NOW, 5) + expect(rows?.some((r) => r.includes("quiet"))).toBe(true) + expect(rows?.some((r) => r.includes("stalled"))).toBe(true) + expect(rows).toHaveLength(6) + expect(rows?.[5]).toBe("+1 more") + }) }) describe("chromeFromSession", () => { diff --git a/src/tui-opentui/chrome-state.ts b/src/tui-opentui/chrome-state.ts index 8d775b18e..19a8b8adc 100644 --- a/src/tui-opentui/chrome-state.ts +++ b/src/tui-opentui/chrome-state.ts @@ -234,7 +234,14 @@ export function formatAgentsPanel( const running = agents.filter((s) => s.status === "running") if (running.length === 0) return null - const visible = running.slice(0, maxVisible) + // Oldest last-activity first: when a fan-out exceeds maxVisible, the + // agent most likely to be stalled must stay on screen, not the caller's + // input order (the real feed sorts running sessions newest-first, which + // would otherwise fold the stalled worker into "+N more" and hide it). + const byStaleness = [...running].sort( + (a, b) => (a.lastActivityAt ?? a.startedAt ?? 0) - (b.lastActivityAt ?? b.startedAt ?? 0), + ) + const visible = byStaleness.slice(0, maxVisible) const rows = visible.map((s) => formatAgentRow(s, nowMs, stallMs)) const hidden = running.length - visible.length if (hidden > 0) rows.push(`+${hidden} more`) @@ -251,6 +258,13 @@ function formatObserveLine(observe: ChromeLiveState["observe"]): string | null | return `observe: ${label}` } +/** + * Suffix marking a stalled row. Exported so the renderer (`shell.ts`) can + * detect stalled rows from the same literal this module writes, instead of + * each side hand-maintaining its own copy of the string to sniff. + */ +export const STALLED_ROW_SUFFIX = " · stalled" + function formatAgentRow(session: ChromeAgentSession, nowMs: number, stallMs: number): string { const label = `${session.agentId}: ${session.description}`.trim() const progress = @@ -268,7 +282,7 @@ function formatAgentRow(session: ChromeAgentSession, nowMs: number, stallMs: num : null if (progress !== null) { - const stalledSuffix = progress.stalled ? " · stalled" : "" + const stalledSuffix = progress.stalled ? STALLED_ROW_SUFFIX : "" return `${label} · ${progress.stat}${stalledSuffix}` } diff --git a/src/tui-opentui/shell.ts b/src/tui-opentui/shell.ts index c9be0f3dc..d702564ab 100644 --- a/src/tui-opentui/shell.ts +++ b/src/tui-opentui/shell.ts @@ -6,6 +6,7 @@ */ import { homedir } from "node:os" +import { STALLED_ROW_SUFFIX } from "./chrome-state.js" import { BoxRenderable, @@ -4049,7 +4050,7 @@ export type ChromeZoneContent = { /** A stalled row reads distinct from a working one by both label and color. */ function isStalledAgentRow(line: string): boolean { - return line.endsWith(" · stalled") + return line.endsWith(STALLED_ROW_SUFFIX) } /** Rebuild agentsBox's row children to match the requested lines exactly. */ @@ -4059,9 +4060,11 @@ function renderAgentsRows(shell: AppShell, lines: readonly string[]): void { destroySubtree(child) } for (const line of lines) { + // Green for working, not the task zone's bronze immediately above it — + // adjacent zones sharing a hue read as one undifferentiated block. const row = new TextRenderable(shell.renderer as CliRenderer, { content: ` ${line}`, - fg: isStalledAgentRow(line) ? UI.textDim : UI.inFlight, + fg: isStalledAgentRow(line) ? UI.textDim : UI.done, }) shell.agentsBox.add(row) } @@ -4096,7 +4099,6 @@ export function setChromeZones( const goalOn = bag.chrome.goal.length > 0 const taskOn = bag.chrome.task.length > 0 const agentsRowCount = bag.chrome.agents.length - const agentsOn = agentsRowCount > 0 shell.goalText.content = goalOn ? ` ${bag.chrome.goal}` : "" shell.taskText.content = taskOn ? ` ${bag.chrome.task}` : "" From 1b6cea0458649841a7cc7c07c917ceda18858e54 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sat, 8 Aug 2026 10:05:27 -0700 Subject: [PATCH 4/5] Separate agent-panel selection order from presentation order MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Greybeard's review found the fan-out fix from the previous commit had introduced a worse bug: sorting visible rows by lastActivityAt made every busy agent's row jump position on its next tool event, since that field changes on nearly every repaint. Selection (which agents survive a fan-out past the visible cap) and presentation (the order those survivors render in) are different questions — selection keeps the staleness sort so a stalled agent is never hidden, presentation now keys on startedAt, which is stable for the life of a running agent, with agentId as a tiebreak. Also: the agents zone now shrinks one row at a time under space pressure instead of collapsing straight to zero, matching how the progress zone already degrades — a 1-row panel is still meaningful (it carries the stalest agent plus its "+N more" trailer), so it earns gradual treatment instead of vanishing under exactly the pressure an operator most needs it. Rows are now width-clamped to the zone's measured content width, ellipsizing the free-form label while always preserving the elapsed/tool/stalled tail. The panel's stalled flag is now carried as an explicit field on each row instead of being encoded as a string suffix the renderer had to parse back out. The sticky poll's per-tick chrome repaint is now gated on a running agent actually existing, since it was otherwise repainting chrome twice a tick through setChromeZones's own unchanged-zone repaint. --- docs/TUI.md | 20 +++++++ src/tui-opentui/chrome-state.test.ts | 82 +++++++++++++++++++++------- src/tui-opentui/chrome-state.ts | 76 ++++++++++++++++---------- src/tui-opentui/geometry.test.ts | 19 +++++++ src/tui-opentui/geometry/resolve.ts | 16 ++++++ src/tui-opentui/product-host.ts | 11 +++- src/tui-opentui/shell.ts | 77 ++++++++++++++++++++------ src/tui-opentui/wave6.test.ts | 53 ++++++++++++++++-- 8 files changed, 280 insertions(+), 74 deletions(-) diff --git a/docs/TUI.md b/docs/TUI.md index 6f5ff71d6..a42ce17c1 100644 --- a/docs/TUI.md +++ b/docs/TUI.md @@ -75,6 +75,26 @@ terminal state (done/failed/cancelled) do not occupy a row; zero running agents is zero rows and zero chrome. `observe` mode overrides the panel with a single `observe: ` line instead of per-agent rows. +Which agents survive a fan-out past `AGENTS_PANEL_MAX_VISIBLE`, and the order +those survivors render in, are two different questions with two different +answers (`formatAgentsPanel` in `chrome-state.ts`). Selection — which N +agents are shown before the rest fold into `+N more` — keys on staleness +(`lastActivityAt`), so the agent most likely to be stalled is guaranteed a +row rather than the caller's feed order (which sorts running sessions +newest-first) silently hiding it. Presentation — the order the surviving +rows paint in — keys on `startedAt` instead: `lastActivityAt` changes on +every tool event, so sorting the visible rows by it would reshuffle the +panel on every repaint. `startedAt` is stable for the life of a running +agent, with `agentId` as a tiebreak for a simultaneous fan-out. + +Under space pressure, the zone shrinks one row at a time toward 1 rather +than collapsing straight to 0 (`COLLAPSE_ORDER` treats it like `progress`, +not like the single-row `goal`/`task` strips) — a 1-row panel still carries +the stalest agent plus its `+N more` trailer, so it stays meaningful all +the way down. Only once every other collapsible zone ahead of it in +`COLLAPSE_ORDER` and the panel itself are exhausted does it reach 0, the +same last-resort floor every other optional zone shares. + ## How pop-ups should feel A blocking surface (permissions, an operator question, the model/provider diff --git a/src/tui-opentui/chrome-state.test.ts b/src/tui-opentui/chrome-state.test.ts index fe1208a70..1345f1e59 100644 --- a/src/tui-opentui/chrome-state.test.ts +++ b/src/tui-opentui/chrome-state.test.ts @@ -85,7 +85,13 @@ describe("formatChromeZones", () => { const out = formatChromeZones(state, NOW) expect(out.goal).toBe("goal: review · 2/4 · 1:1 OpenTUI cutover") expect(out.task).toBe("task: chrome live helper (+2)") - expect(out.agents).toEqual(["explore: map setChromeZones callers · 0:05 · grep"]) + expect(out.agents).toEqual([ + { + label: "explore: map setChromeZones callers", + tail: " · 0:05 · grep", + stalled: false, + }, + ]) }) test("observe overrides the agents panel", () => { @@ -106,7 +112,11 @@ describe("formatChromeZones", () => { NOW, ) expect(out.agents).toEqual([ - "observe: explore — map callers of openListOverlay", + { + label: "observe: explore — map callers of openListOverlay", + tail: "", + stalled: false, + }, ]) }) }) @@ -205,17 +215,19 @@ describe("formatAgentsPanel", () => { expect(formatAgentsPanel([], undefined, NOW)).toBeNull() }) - test("one row per running agent", () => { - expect( - formatAgentsPanel( - [ - { agentId: "a", description: "one", status: "running", startedAt: NOW - 1_000, lastActivityAt: NOW }, - { agentId: "b", description: "two", status: "running", startedAt: NOW - 2_000, lastActivityAt: NOW }, - ], - undefined, - NOW, - ), - ).toEqual(["a: one · 0:01", "b: two · 0:02"]) + test("one row per running agent, oldest-started first", () => { + const rows = formatAgentsPanel( + [ + { agentId: "a", description: "one", status: "running", startedAt: NOW - 1_000, lastActivityAt: NOW }, + { agentId: "b", description: "two", status: "running", startedAt: NOW - 2_000, lastActivityAt: NOW }, + ], + undefined, + NOW, + ) + expect(rows).toEqual([ + { label: "b: two", tail: " · 0:02", stalled: false }, + { label: "a: one", tail: " · 0:01", stalled: false }, + ]) }) test("terminal-only list renders zero rows", () => { @@ -245,7 +257,9 @@ describe("formatAgentsPanel", () => { undefined, NOW, ) - expect(rows).toEqual(["a: quiet worker · 1:00 · stalled"]) + expect(rows).toEqual([ + { label: "a: quiet worker", tail: " · 1:00 · stalled", stalled: true }, + ]) }) test("bounds fan-out to maxVisible plus a +N more row", () => { @@ -258,7 +272,7 @@ describe("formatAgentsPanel", () => { })) const rows = formatAgentsPanel(running, undefined, NOW, 5) expect(rows).toHaveLength(6) - expect(rows?.[5]).toBe("+3 more") + expect(rows?.[5]).toEqual({ label: "+3 more", tail: "", stalled: false }) }) test("observe empty id+desc hides", () => { @@ -267,6 +281,32 @@ describe("formatAgentsPanel", () => { ).toBeNull() }) + 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. + const frame1 = [ + { agentId: "b", description: "second", status: "running" as const, startedAt: NOW - 1_000, lastActivityAt: NOW - 1_000 }, + { agentId: "a", description: "first", status: "running" as const, startedAt: NOW - 2_000, lastActivityAt: NOW - 2_000 }, + { agentId: "c", description: "third", status: "running" as const, startedAt: NOW - 500, lastActivityAt: NOW - 500 }, + ] + 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) + + expect(rowsBefore?.map((r) => r.label.split(":")[0])).toEqual( + rowsAfter?.map((r) => r.label.split(":")[0]), + ) + // Sanity: presentation order is oldest-started first (a, b, c), matching + // the tiebreak-free startedAt sort. + expect(rowsBefore?.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 @@ -286,10 +326,10 @@ describe("formatAgentsPanel", () => { lastActivityAt: NOW - 250_000, } const rows = formatAgentsPanel([...newest, stalled], undefined, NOW, 5) - expect(rows?.some((r) => r.includes("quiet"))).toBe(true) - expect(rows?.some((r) => r.includes("stalled"))).toBe(true) + expect(rows?.some((r) => r.label.includes("quiet"))).toBe(true) + expect(rows?.some((r) => r.stalled)).toBe(true) expect(rows).toHaveLength(6) - expect(rows?.[5]).toBe("+1 more") + expect(rows?.[5]).toEqual({ label: "+1 more", tail: "", stalled: false }) }) }) @@ -342,7 +382,9 @@ describe("chromeFromSession", () => { const zones = formatChromeZones(state, NOW) expect(zones.goal).toBe("goal: impl · 1/2 · ship cutover") expect(zones.task).toBe("task: wire catalogs (+1)") - expect(zones.agents).toEqual(["explore: map callers · grep"]) + expect(zones.agents).toEqual([ + { label: "explore: map callers", tail: " · grep", stalled: false }, + ]) }) test("falls back agent id and goal condition; empty bags hide", () => { @@ -373,7 +415,7 @@ describe("chromeFromSession", () => { description: "watch", }) expect(formatChromeZones(state, NOW).agents).toEqual([ - "observe: explore — watch", + { label: "observe: explore — watch", tail: "", stalled: false }, ]) }) }) diff --git a/src/tui-opentui/chrome-state.ts b/src/tui-opentui/chrome-state.ts index 19a8b8adc..d2b2c0d8b 100644 --- a/src/tui-opentui/chrome-state.ts +++ b/src/tui-opentui/chrome-state.ts @@ -95,12 +95,25 @@ export type ChromeLiveState = { } | null } +/** + * One rendered agents-panel row. `stalled` is a fact the formatter already + * knows from `agentProgress` — carried explicitly so the renderer never has + * to recover it by sniffing `text` for a marker string. `label` (agentId + + * description) is the part the renderer may ellipsize under width pressure; + * `tail` (elapsed/tool/stalled) must never be trimmed away. + */ +export type AgentPanelRow = { + readonly label: string + readonly tail: string + readonly stalled: boolean +} + /** Always-populated result for setChromeZones (null = hide zone). */ export type FormattedChromeZones = { readonly goal: string | null readonly task: string | null - /** One line per rendered agents-panel row (null = hide zone, zero rows). */ - readonly agents: readonly string[] | null + /** One row per rendered agents-panel line (null = hide zone, zero rows). */ + readonly agents: readonly AgentPanelRow[] | null } const PHASE_SHORT: Record = { @@ -225,47 +238,51 @@ export function formatAgentsPanel( nowMs: number, maxVisible: number = AGENTS_PANEL_MAX_VISIBLE, stallMs: number = DEFAULT_STALL_MS, -): readonly string[] | null { - const observeLine = formatObserveLine(observe) - if (observeLine !== undefined) return observeLine === null ? null : [observeLine] +): readonly AgentPanelRow[] | null { + const observeRow = formatObserveRow(observe) + if (observeRow !== undefined) return observeRow === null ? null : [observeRow] if (agents === null || agents === undefined || agents.length === 0) return null const running = agents.filter((s) => s.status === "running") if (running.length === 0) return null - // Oldest last-activity first: when a fan-out exceeds maxVisible, the - // agent most likely to be stalled must stay on screen, not the caller's - // input order (the real feed sorts running sessions newest-first, which - // would otherwise fold the stalled worker into "+N more" and hide it). - const byStaleness = [...running].sort( - (a, b) => (a.lastActivityAt ?? a.startedAt ?? 0) - (b.lastActivityAt ?? b.startedAt ?? 0), + // 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] + .sort( + (a, b) => (a.lastActivityAt ?? a.startedAt ?? 0) - (b.lastActivityAt ?? b.startedAt ?? 0), + ) + .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 visible = byStaleness.slice(0, maxVisible) - const rows = visible.map((s) => formatAgentRow(s, nowMs, stallMs)) - const hidden = running.length - visible.length - if (hidden > 0) rows.push(`+${hidden} more`) + const rows = presented.map((s) => formatAgentRow(s, nowMs, stallMs)) + if (hidden > 0) rows.push({ label: `+${hidden} more`, tail: "", stalled: false }) return rows } -function formatObserveLine(observe: ChromeLiveState["observe"]): string | null | undefined { +function formatObserveRow(observe: ChromeLiveState["observe"]): AgentPanelRow | null | undefined { if (observe === null || observe === undefined) return undefined const id = observe.agentId.trim() const desc = observe.description.trim() if (id.length === 0 && desc.length === 0) return null const label = id.length > 0 && desc.length > 0 ? `${id} — ${desc}` : id.length > 0 ? id : desc - return `observe: ${label}` + return { label: `observe: ${label}`, tail: "", stalled: false } } -/** - * Suffix marking a stalled row. Exported so the renderer (`shell.ts`) can - * detect stalled rows from the same literal this module writes, instead of - * each side hand-maintaining its own copy of the string to sniff. - */ -export const STALLED_ROW_SUFFIX = " · stalled" - -function formatAgentRow(session: ChromeAgentSession, nowMs: number, stallMs: number): string { +function formatAgentRow(session: ChromeAgentSession, nowMs: number, stallMs: number): AgentPanelRow { const label = `${session.agentId}: ${session.description}`.trim() const progress = session.startedAt !== undefined @@ -282,16 +299,15 @@ function formatAgentRow(session: ChromeAgentSession, nowMs: number, stallMs: num : null if (progress !== null) { - const stalledSuffix = progress.stalled ? STALLED_ROW_SUFFIX : "" - return `${label} · ${progress.stat}${stalledSuffix}` + const tail = progress.stalled ? ` · ${progress.stat} · stalled` : ` · ${progress.stat}` + return { label, tail, stalled: progress.stalled } } // 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 - return tool !== undefined && tool !== null && tool.length > 0 - ? `${label} · ${tool}` - : label + const tail = tool !== undefined && tool !== null && tool.length > 0 ? ` · ${tool}` : "" + return { label, tail, stalled: false } } /** diff --git a/src/tui-opentui/geometry.test.ts b/src/tui-opentui/geometry.test.ts index c0fb070c3..0c4275b29 100644 --- a/src/tui-opentui/geometry.test.ts +++ b/src/tui-opentui/geometry.test.ts @@ -124,6 +124,25 @@ describe("resolveGeometry — agents panel", () => { const layout = idle80x24({ visibility: { agents: AGENTS_PANEL_MAX_VISIBLE + 1 } }); expect(layout.transcriptHeight).toBeGreaterThanOrEqual(layout.transcriptFloor); }); + + test("under pressure the panel shrinks one row at a time rather than vanishing in one step", () => { + // A short terminal plus a couple of banners leaves a deficit banner + // rows alone cannot cover, forcing the resolver into the agents zone. + // A cliff bug would jump straight from the full request to 0; the fix + // must land partway, still nonzero and still under its full request. + const layout = resolveGeometry({ + terminal: { columns: 80, rows: 20 }, + visibility: { + commandBanner: 1, + settingsNotice: 1, + pluginBanner: true, + agents: AGENTS_PANEL_MAX_VISIBLE + 1, + }, + }); + expect(layout.heights.agents).toBeGreaterThan(0); + expect(layout.heights.agents).toBeLessThan(AGENTS_PANEL_MAX_VISIBLE + 1); + expect(layout.transcriptHeight).toBeGreaterThanOrEqual(layout.transcriptFloor); + }); }); describe("resolveGeometry — collapse rules", () => { diff --git a/src/tui-opentui/geometry/resolve.ts b/src/tui-opentui/geometry/resolve.ts index de57282d0..ed7539dfe 100644 --- a/src/tui-opentui/geometry/resolve.ts +++ b/src/tui-opentui/geometry/resolve.ts @@ -238,6 +238,22 @@ function collapseOnce(heights: MutableHeights, collapsed: ZoneId[]): ZoneId | nu return "progress"; } + if (id === "agents") { + // Shrink one row at a time rather than zeroing in one step: a 1-row + // panel still carries the stalest agent plus a "+N more" trailer + // (formatAgentsPanel's selection sort guarantees that ordering), so + // it stays meaningful all the way down instead of the zone vanishing + // under exactly the pressure an operator most needs to see it. + if (h > 1) { + heights.agents = h - 1; + if (!collapsed.includes("agents")) collapsed.push("agents"); + return "agents"; + } + heights.agents = 0; + if (!collapsed.includes("agents")) collapsed.push("agents"); + return "agents"; + } + // Drop optional / shrinkable to 0. heights[id] = 0; if (!collapsed.includes(id)) collapsed.push(id); diff --git a/src/tui-opentui/product-host.ts b/src/tui-opentui/product-host.ts index c3305e3a6..d2fde23b7 100644 --- a/src/tui-opentui/product-host.ts +++ b/src/tui-opentui/product-host.ts @@ -395,8 +395,15 @@ export async function mountProductHost( // The agents panel's elapsed clock and stalled flag are a function of // wall time, not just of the last event — repaint on the same tick as // the transcript trailer so a worker that goes quiet still flips to - // "stalled" without waiting on an unrelated chrome push. - if (chromeState !== null) paintChromeZones() + // "stalled" without waiting on an unrelated chrome push. Gated on a + // running agent existing: paintChromeZones() re-enters setChromeZones, + // which already calls paintChrome(shell) on its own unchanged-zone + // path, so calling it unconditionally would repaint chrome twice a + // tick for the common case (goal/task only, no agents) that has + // nothing time-based to refresh. + if (chromeState !== null && (chromeState.agents ?? []).some((a) => a.status === "running")) { + paintChromeZones() + } } catch { clearInterval(stickyPoll) } diff --git a/src/tui-opentui/shell.ts b/src/tui-opentui/shell.ts index d702564ab..0e4147ced 100644 --- a/src/tui-opentui/shell.ts +++ b/src/tui-opentui/shell.ts @@ -6,7 +6,7 @@ */ import { homedir } from "node:os" -import { STALLED_ROW_SUFFIX } from "./chrome-state.js" +import type { AgentPanelRow } from "./chrome-state.js" import { BoxRenderable, @@ -739,6 +739,10 @@ function defaultVisibility(visibility?: ZoneVisibility): ZoneVisibility { notice: false, progress: false, progressDivider: false, + // Explicit 0 rather than left undefined: the agents field is now a row + // count, and setChromeZones compares it by ===, so an undefined start + // forces one needless relayout the first time it is ever compared. + agents: 0, ...visibility, } } @@ -1806,7 +1810,7 @@ type ShellInternals = { goal: string task: string /** Agents panel rows (empty array = zone off), one row per rendered line. */ - agents: readonly string[] + agents: readonly AgentPanelRow[] } } @@ -4000,7 +4004,9 @@ export function runPaletteAction( const bag = internals.get(shell) const on = (bag?.chrome.agents.length ?? 0) > 0 setChromeZones(shell, { - agents: on ? null : ["explore: map callers"], + agents: on + ? null + : [{ label: "explore: map callers", tail: "", stalled: false }], }) appendStreamRow(shell, { role: "system", @@ -4044,29 +4050,48 @@ export function runPaletteAction( export type ChromeZoneContent = { readonly goal?: string | null readonly task?: string | null - /** One line per agents-panel row. Null/empty = hide the zone. */ - readonly agents?: readonly string[] | null + /** One row per agents-panel line. Null/empty = hide the zone. */ + readonly agents?: readonly AgentPanelRow[] | null } -/** A stalled row reads distinct from a working one by both label and color. */ -function isStalledAgentRow(line: string): boolean { - return line.endsWith(STALLED_ROW_SUFFIX) +/** + * Fit a row's label + tail into `maxWidth` columns, ellipsizing the label + * (agentId + description — free-form, model-authored, routinely long) + * before ever touching the tail (elapsed/tool/stalled). The tail carries + * the fact an operator glances at the panel to see, so it is preserved + * whole or not shown at all. + */ +function fitAgentRow(row: AgentPanelRow, maxWidth: number): string { + const full = ` ${row.label}${row.tail}` + if (full.length <= maxWidth) return full + + const budget = maxWidth - 1 - row.tail.length - 1 // leading space + ellipsis + if (budget <= 0) { + // Not even the tail fits — show as much of the tail as there is room + // for rather than an unreadable sliver of the label. + return ` ${full.slice(1, Math.max(0, maxWidth))}` + } + return ` ${row.label.slice(0, budget)}…${row.tail}` } -/** Rebuild agentsBox's row children to match the requested lines exactly. */ -function renderAgentsRows(shell: AppShell, lines: readonly string[]): void { +/** Rebuild agentsBox's row children to match the requested rows exactly. */ +function renderAgentsRows( + shell: AppShell, + rows: readonly AgentPanelRow[], + maxWidth: number, +): void { for (const child of [...shell.agentsBox.getChildren()]) { shell.agentsBox.remove(child) destroySubtree(child) } - for (const line of lines) { + 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. - const row = new TextRenderable(shell.renderer as CliRenderer, { - content: ` ${line}`, - fg: isStalledAgentRow(line) ? UI.textDim : UI.done, + const text = new TextRenderable(shell.renderer as CliRenderer, { + content: fitAgentRow(row, maxWidth), + fg: row.stalled ? UI.textDim : UI.done, }) - shell.agentsBox.add(row) + shell.agentsBox.add(text) } } @@ -4092,7 +4117,15 @@ export function setChromeZones( const next = content.agents ?? [] agentsChanged = next.length !== bag.chrome.agents.length || - next.some((line, i) => line !== bag.chrome.agents[i]) + next.some((row, i) => { + const prev = bag.chrome.agents[i] + return ( + prev === undefined || + row.label !== prev.label || + row.tail !== prev.tail || + row.stalled !== prev.stalled + ) + }) bag.chrome.agents = next } @@ -4105,7 +4138,9 @@ export function setChromeZones( // Rebuilding N TextRenderable children is real node churn; skip it unless // the panel's actual lines changed (not every goal/task/agents push carries // new agent data). - if (agentsChanged) renderAgentsRows(shell, bag.chrome.agents) + 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 @@ -4275,7 +4310,13 @@ export function enterSubagentObserve( shell.focus = openObserve(shell.focus, `observe-${session.sessionId}`) setChromeZones(shell, { - agents: [`observe: ${session.agentId} — ${session.description}`], + agents: [ + { + label: `observe: ${session.agentId} — ${session.description}`, + tail: "", + stalled: false, + }, + ], }) // Child chrome toast — must not route to parent snapshot. appendObserveStreamRow(shell, { diff --git a/src/tui-opentui/wave6.test.ts b/src/tui-opentui/wave6.test.ts index 72f45917e..8195dd5ae 100644 --- a/src/tui-opentui/wave6.test.ts +++ b/src/tui-opentui/wave6.test.ts @@ -290,7 +290,7 @@ describe("Wave 6: chrome zones", () => { setChromeZones(shell, { goal: "goal: Wave 6", task: "task: chrome zones", - agents: ["explore: map callers"], + agents: [{ label: "explore: map callers", tail: "", stalled: false }], }) expect(shell.layout.heights.goal).toBe(1) @@ -330,7 +330,9 @@ describe("Wave 6: chrome zones", () => { wireKeys: false, }) try { - setChromeZones(shell, { agents: ["explore: map callers"] }) + setChromeZones(shell, { + agents: [{ label: "explore: map callers", tail: "", stalled: false }], + }) const rowsBefore = [...shell.agentsBox.getChildren()] expect(rowsBefore).toHaveLength(1) @@ -339,11 +341,15 @@ describe("Wave 6: chrome zones", () => { expect([...shell.agentsBox.getChildren()]).toEqual(rowsBefore) // Pushing the exact same agent lines again must not rebuild either. - setChromeZones(shell, { agents: ["explore: map callers"] }) + setChromeZones(shell, { + agents: [{ label: "explore: map callers", tail: "", stalled: false }], + }) expect([...shell.agentsBox.getChildren()]).toEqual(rowsBefore) // Changed lines must rebuild. - setChromeZones(shell, { agents: ["explore: map callers · 0:01"] }) + setChromeZones(shell, { + agents: [{ label: "explore: map callers", tail: " · 0:01", stalled: false }], + }) expect([...shell.agentsBox.getChildren()]).not.toEqual(rowsBefore) expect(shell.agentsBox.getChildren()).toHaveLength(1) } finally { @@ -353,6 +359,45 @@ describe("Wave 6: chrome zones", () => { { width: 80, height: 24 }, ) }) + + test("a long agent description is ellipsized to the zone width, never wrapped or clipped", async () => { + await withTestRenderer( + async (h) => { + const shell = createAppShell(h.renderer, { + terminal: { columns: 80, rows: 24 }, + wireKeys: false, + }) + try { + const longDescription = + "investigate why the reactor loop keeps re-emitting duplicate tool_call.start events under concurrent subagent dispatch" + setChromeZones(shell, { + agents: [ + { label: `explore: ${longDescription}`, tail: " · 0:42 · grep", stalled: false }, + ], + }) + + await h.renderOnce() + const frame = h.captureCharFrame() + const agentLine = frame + .split("\n") + .find((line) => line.includes("· 0:42 · grep")) + expect(agentLine).toBeDefined() + // The frame line includes the shell's left side margin ahead of + // the zone's own content width. + expect(agentLine?.trimEnd().length).toBeLessThanOrEqual( + shell.layout.sideMargin + shell.layout.contentWidth, + ) + // The tail (what an operator glances at the panel to see) survives + // whole; only the free-form label is ellipsized. + expect(agentLine).toContain("…") + expect(agentLine).not.toContain(longDescription) + } finally { + shell.dispose() + } + }, + { width: 80, height: 24 }, + ) + }) }) describe("Wave 6: keyboard copy path", () => { From 1c1c5be3513232b035b3e45e186c6f9299fd9d07 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sat, 8 Aug 2026 10:14:06 -0700 Subject: [PATCH 5/5] Measure agent panel rows in terminal columns, not code units MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit fitAgentRow measured with String.length and cut with String.slice, so a CJK or emoji description — free-form model-authored text — undercounted its true width and the row overflowed its zone and wrapped, which is the bug the clamp exists to prevent. Route through stringWidth/sliceToWidth, which the repo already owns and holds to a contract test against OpenTUI. The degenerate branch also contradicted its comment: it promised the tail and returned the head of the label. Add sliceTailToWidth so it keeps the tail's trailing end, where the stalled marker lives. --- src/tui-opentui/shell.ts | 27 +++++++++++++--------- src/tui-opentui/wave6.test.ts | 42 +++++++++++++++++++++++++++++++++++ 2 files changed, 59 insertions(+), 10 deletions(-) diff --git a/src/tui-opentui/shell.ts b/src/tui-opentui/shell.ts index 0e4147ced..27d220af0 100644 --- a/src/tui-opentui/shell.ts +++ b/src/tui-opentui/shell.ts @@ -30,7 +30,7 @@ import { composePromptActionBarModelLabel, type PromptActionBarModelLabelInput, } from "../tui/components/prompt-action-bar-label.js" -import { stringWidth } from "../tui/view/height.js" +import { sliceTailToWidth, sliceToWidth, stringWidth } from "../tui/view/height.js" import { listPathSuggestions } from "../tui/components/at-mention/list.js" import { parseAtState } from "../tui/components/at-mention/parse.js" import { @@ -4055,23 +4055,30 @@ export type ChromeZoneContent = { } /** - * Fit a row's label + tail into `maxWidth` columns, ellipsizing the label - * (agentId + description — free-form, model-authored, routinely long) + * Fit a row's label + tail into `maxWidth` terminal columns, ellipsizing the + * label (agentId + description — free-form, model-authored, routinely long, + * and not guaranteed narrow: CJK and emoji run two columns per code point) * before ever touching the tail (elapsed/tool/stalled). The tail carries * the fact an operator glances at the panel to see, so it is preserved - * whole or not shown at all. + * whole or not shown at all. Measured and sliced in columns via + * `stringWidth`/`sliceToWidth` (`src/tui/view/height.ts`) rather than UTF-16 + * code units — `.length` undercounts wide glyphs, which is exactly the class + * of bug that would make a row overflow its zone and wrap. */ function fitAgentRow(row: AgentPanelRow, maxWidth: number): string { const full = ` ${row.label}${row.tail}` - if (full.length <= maxWidth) return full + if (stringWidth(full) <= maxWidth) return full - const budget = maxWidth - 1 - row.tail.length - 1 // leading space + ellipsis + const leadingSpace = 1 + const ellipsis = 1 + const budget = maxWidth - leadingSpace - stringWidth(row.tail) - ellipsis if (budget <= 0) { - // Not even the tail fits — show as much of the tail as there is room - // for rather than an unreadable sliver of the label. - return ` ${full.slice(1, Math.max(0, maxWidth))}` + // Not even the tail fits at full width — keep as much of the tail's + // trailing end (where the "stalled" marker lives) as there is room for, + // rather than an unreadable sliver of the label. + return ` ${sliceTailToWidth(row.tail, maxWidth - leadingSpace)}` } - return ` ${row.label.slice(0, budget)}…${row.tail}` + return ` ${sliceToWidth(row.label, budget)}…${row.tail}` } /** Rebuild agentsBox's row children to match the requested rows exactly. */ diff --git a/src/tui-opentui/wave6.test.ts b/src/tui-opentui/wave6.test.ts index 8195dd5ae..ddf9fcf8e 100644 --- a/src/tui-opentui/wave6.test.ts +++ b/src/tui-opentui/wave6.test.ts @@ -24,6 +24,7 @@ import { streamRowCount, } from "./shell" import { createRecordingClipboard } from "./copy-path" +import { stringWidth } from "../tui/view/height" describe("Wave 6: command palette", () => { test("open → navigate → Esc restores prompt", async () => { @@ -398,6 +399,47 @@ describe("Wave 6: chrome zones", () => { { width: 80, height: 24 }, ) }) + + test("a wide-character (CJK/emoji) description fits the laid-out width in columns, not UTF-16 units", async () => { + await withTestRenderer( + async (h) => { + const shell = createAppShell(h.renderer, { + terminal: { columns: 80, rows: 24 }, + wireKeys: false, + }) + try { + // Each CJK character is one UTF-16 code unit but two terminal + // columns; .length would undercount this description's true width + // by roughly half, letting the row overflow the zone and wrap — + // exactly the bug width-clamping exists to prevent. + const wideDescription = "调查代理循环中重复出现的工具调用事件问题 across every dispatched worker" + setChromeZones(shell, { + agents: [ + { label: `explore: ${wideDescription}`, tail: " · 0:42 · grep", stalled: false }, + ], + }) + + await h.renderOnce() + const frame = h.captureCharFrame() + const agentLine = frame + .split("\n") + .find((line) => line.includes("· 0:42 · grep")) + expect(agentLine).toBeDefined() + expect(stringWidth(agentLine!.trimEnd())).toBeLessThanOrEqual( + shell.layout.sideMargin + shell.layout.contentWidth, + ) + expect(agentLine).toContain("…") + expect(agentLine).toContain("· 0:42 · grep") + + // No wrap: the zone stays a single row for a single agent. + expect(shell.layout.heights.agents).toBe(1) + } finally { + shell.dispose() + } + }, + { width: 80, height: 24 }, + ) + }) }) describe("Wave 6: keyboard copy path", () => {