diff --git a/docs/TUI.md b/docs/TUI.md index ed1068407..a42ce17c1 100644 --- a/docs/TUI.md +++ b/docs/TUI.md @@ -53,6 +53,48 @@ 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. + +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 2f703ba5b..1345f1e59 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,42 @@ 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([ + { + label: "explore: map setChromeZones callers", + tail: " · 0:05 · grep", + stalled: false, + }, + ]) }) - 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([ + { + label: "observe: explore — map callers of openListOverlay", + tail: "", + stalled: false, + }, + ]) }) }) @@ -195,43 +209,128 @@ 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("multi live summary without single-agent detail", () => { + 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", () => { 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([ + { label: "a: quiet worker", tail: " · 1:00 · stalled", stalled: true }, + ]) }) - 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]).toEqual({ label: "+3 more", tail: "", stalled: false }) }) test("observe empty id+desc hides", () => { expect( - formatAgentsLine([], { agentId: " ", description: " " }), + formatAgentsPanel([], { agentId: " ", description: " " }, NOW), ).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 + // 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.label.includes("quiet"))).toBe(true) + expect(rows?.some((r) => r.stalled)).toBe(true) + expect(rows).toHaveLength(6) + expect(rows?.[5]).toEqual({ label: "+1 more", tail: "", stalled: false }) + }) }) describe("chromeFromSession", () => { @@ -280,10 +379,12 @@ 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([ + { label: "explore: map callers", tail: " · grep", stalled: false }, + ]) }) test("falls back agent id and goal condition; empty bags hide", () => { @@ -313,9 +414,9 @@ describe("chromeFromSession", () => { agentId: "explore", description: "watch", }) - expect(formatChromeZones(state).agents).toBe( - "observe: explore — watch", - ) + expect(formatChromeZones(state, NOW).agents).toEqual([ + { label: "observe: explore — watch", tail: "", stalled: false }, + ]) }) }) diff --git a/src/tui-opentui/chrome-state.ts b/src/tui-opentui/chrome-state.ts index b52d5c623..d2b2c0d8b 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 } /** @@ -89,11 +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 - readonly agents: string | null + /** One row per rendered agents-panel line (null = hide zone, zero rows). */ + readonly agents: readonly AgentPanelRow[] | null } const PHASE_SHORT: Record = { @@ -109,11 +129,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 +225,89 @@ 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 AgentPanelRow[] | null { + const observeRow = formatObserveRow(observe) + if (observeRow !== undefined) return observeRow === null ? null : [observeRow] - 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 + + // 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 rows = presented.map((s) => formatAgentRow(s, nowMs, stallMs)) + if (hidden > 0) rows.push({ label: `+${hidden} more`, tail: "", stalled: false }) + return rows +} - 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}`) - } - } +function formatObserveRow(observe: ChromeLiveState["observe"]): AgentPanelRow | null | undefined { + if (observe === null || observe === undefined) return undefined + const id = observe.agentId.trim() + const desc = observe.description.trim() + if (id.length === 0 && desc.length === 0) return null + const label = + id.length > 0 && desc.length > 0 ? `${id} — ${desc}` : id.length > 0 ? id : desc + return { label: `observe: ${label}`, tail: "", stalled: false } +} - 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 formatAgentRow(session: ChromeAgentSession, nowMs: number, stallMs: number): AgentPanelRow { + 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 tail = progress.stalled ? ` · ${progress.stat} · stalled` : ` · ${progress.stat}` + return { label, tail, stalled: progress.stalled } } - 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 + const tail = tool !== undefined && tool !== null && tool.length > 0 ? ` · ${tool}` : "" + return { label, tail, stalled: false } } /** @@ -330,6 +379,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 +475,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..0c4275b29 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,52 @@ 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); + }); + + 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", () => { 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..ed7539dfe 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), @@ -237,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/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/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..d2fde23b7 100644 --- a/src/tui-opentui/product-host.ts +++ b/src/tui-opentui/product-host.ts @@ -392,6 +392,18 @@ 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. 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 7889ffe1e..27d220af0 100644 --- a/src/tui-opentui/shell.ts +++ b/src/tui-opentui/shell.ts @@ -6,6 +6,7 @@ */ import { homedir } from "node:os" +import type { AgentPanelRow } from "./chrome-state.js" import { BoxRenderable, @@ -29,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 { @@ -531,8 +532,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 @@ -738,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, } } @@ -1804,7 +1809,8 @@ type ShellInternals = { chrome: { goal: string task: string - agents: string + /** Agents panel rows (empty array = zone off), one row per rendered line. */ + agents: readonly AgentPanelRow[] } } @@ -3998,7 +4004,9 @@ 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 + : [{ label: "explore: map callers", tail: "", stalled: false }], }) appendStreamRow(shell, { role: "system", @@ -4042,7 +4050,56 @@ export function runPaletteAction( export type ChromeZoneContent = { readonly goal?: string | null readonly task?: string | null - readonly agents?: string | null + /** One row per agents-panel line. Null/empty = hide the zone. */ + readonly agents?: readonly AgentPanelRow[] | null +} + +/** + * 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. 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 (stringWidth(full) <= maxWidth) return full + + const leadingSpace = 1 + const ellipsis = 1 + const budget = maxWidth - leadingSpace - stringWidth(row.tail) - ellipsis + if (budget <= 0) { + // 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 ` ${sliceToWidth(row.label, budget)}…${row.tail}` +} + +/** 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 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 text = new TextRenderable(shell.renderer as CliRenderer, { + content: fitAgentRow(row, maxWidth), + fg: row.stalled ? UI.textDim : UI.done, + }) + shell.agentsBox.add(text) + } } /** @@ -4062,24 +4119,43 @@ 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((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 } 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 shell.goalText.content = goalOn ? ` ${bag.chrome.goal}` : "" shell.taskText.content = taskOn ? ` ${bag.chrome.task}` : "" - shell.agentsText.content = agentsOn ? ` ${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, shell.layout.contentWidth) + } - // 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 +4166,7 @@ export function setChromeZones( ...bag.visibility, goal: goalOn, task: taskOn, - agents: agentsOn, + agents: agentsRowCount, }, overlayMode: bag.overlayMode, ...(bag.overlayBodyRows !== undefined @@ -4241,7 +4317,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, { @@ -4779,15 +4861,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 +5507,6 @@ export function createAppShell( taskBox, taskText, agentsBox, - agentsText, transcript, overlayHost, overlayTitle, @@ -5525,7 +5601,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..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 () => { @@ -290,7 +291,7 @@ describe("Wave 6: chrome zones", () => { setChromeZones(shell, { goal: "goal: Wave 6", task: "task: chrome zones", - agents: "agents: 0", + agents: [{ label: "explore: map callers", tail: "", stalled: false }], }) expect(shell.layout.heights.goal).toBe(1) @@ -306,7 +307,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) @@ -321,6 +322,124 @@ 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: [{ label: "explore: map callers", tail: "", stalled: false }], + }) + 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: [{ label: "explore: map callers", tail: "", stalled: false }], + }) + expect([...shell.agentsBox.getChildren()]).toEqual(rowsBefore) + + // 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()).toHaveLength(1) + } finally { + shell.dispose() + } + }, + { 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 }, + ) + }) + + 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", () => { 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) => {