From 58e3b154100e9f0af1edf767c6dd598b0a134894 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Thu, 6 Aug 2026 23:30:38 -0700 Subject: [PATCH] Show live progress on a dispatched sub-agent's transcript row MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A task call's row painted a bare dot until its result landed, so a parent that dispatched several sub-agents at once had no way to tell a worker mid-turn from one that had gone silent — every unfinished dispatch looked identical. The sub-agent session store already tracks each worker's current tool and now its last activity clock; the runtime bridge polls it on the same cadence it already ticks the chrome and rewrites each outstanding task call's row in place with elapsed time, current tool, and a mark that tells working apart from stalled. The row itself never grows an extra line and the rewrite touches only that one row, regardless of how many other rows sit above it. Resolving a dispatch drops its live elapsed-time trailer for the result's own answer rather than leaving the last progress reading stuck on a finished row. --- src/subagent/session-store.ts | 8 ++ src/tui-opentui/agent-progress.test.ts | 49 +++++++++++ src/tui-opentui/agent-progress.ts | 57 ++++++++++++ src/tui-opentui/product-host.ts | 10 +++ src/tui-opentui/runner-host.test.ts | 1 + src/tui-opentui/runner-host.ts | 8 ++ src/tui-opentui/runtime-bridge.test.ts | 115 ++++++++++++++++++++++++- src/tui-opentui/runtime-bridge.ts | 79 ++++++++++++++++- src/tui-opentui/stream.test.ts | 41 +++++++++ src/tui-opentui/stream.ts | 16 +++- src/tui-opentui/tool-rows.test.ts | 13 +++ src/tui-opentui/tool-rows.ts | 9 +- 12 files changed, 399 insertions(+), 7 deletions(-) create mode 100644 src/tui-opentui/agent-progress.test.ts create mode 100644 src/tui-opentui/agent-progress.ts diff --git a/src/subagent/session-store.ts b/src/subagent/session-store.ts index ea5993507..6fd8e25b6 100644 --- a/src/subagent/session-store.ts +++ b/src/subagent/session-store.ts @@ -27,6 +27,10 @@ export type SubAgentSession = { currentToolName: string | null; entries: SubAgentTranscriptEntry[]; startedAt: number; + // Clock of the last event this session recorded (a stream token, a tool + // start/end, a status change). Distinct from startedAt so the strip can + // tell a worker mid-turn from one that has gone silent. + lastActivityAt: number; finishedAt?: number; report?: string; error?: string; @@ -159,6 +163,7 @@ export function createSubAgentSessionStore( const markCancelled = (session: SubAgentSession, reason: string): void => { session.status = "cancelled"; session.finishedAt = now(); + session.lastActivityAt = now(); session.currentToolName = null; session.error = reason; pushEntry(session, { @@ -223,6 +228,7 @@ export function createSubAgentSessionStore( const session = sessions.get(id); if (session === undefined) return; fn(session); + session.lastActivityAt = now(); bumpRevision(id); notify(); }; @@ -264,6 +270,7 @@ export function createSubAgentSessionStore( currentToolName: null, entries: [], startedAt: now(), + lastActivityAt: now(), ...(input.parentSessionId !== undefined ? { parentSessionId: input.parentSessionId } : {}), }; sessions.set(id, session); @@ -470,6 +477,7 @@ function cloneSession(session: SubAgentSession): SubAgentSession { currentToolName: session.currentToolName, entries: session.entries.map(cloneEntry), startedAt: session.startedAt, + lastActivityAt: session.lastActivityAt, ...(session.finishedAt !== undefined ? { finishedAt: session.finishedAt } : {}), ...(session.report !== undefined ? { report: session.report } : {}), ...(session.error !== undefined ? { error: session.error } : {}), diff --git a/src/tui-opentui/agent-progress.test.ts b/src/tui-opentui/agent-progress.test.ts new file mode 100644 index 000000000..aefdcb69b --- /dev/null +++ b/src/tui-opentui/agent-progress.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, test } from "bun:test" +import { agentProgress, clockLabel } from "./agent-progress" + +describe("clockLabel", () => { + test("formats sub-minute and multi-minute elapsed as m:ss", () => { + expect(clockLabel(0)).toBe("0:00") + expect(clockLabel(42_000)).toBe("0:42") + expect(clockLabel(90_000)).toBe("1:30") + }) +}) + +describe("agentProgress", () => { + const base = { + status: "running" as const, + currentToolName: "grep", + startedAt: 0, + lastActivityAt: 0, + } + + test("terminal sessions have no pending-row progress", () => { + expect(agentProgress({ ...base, status: "done" }, 1000)).toBeNull() + expect(agentProgress({ ...base, status: "failed" }, 1000)).toBeNull() + expect(agentProgress({ ...base, status: "cancelled" }, 1000)).toBeNull() + }) + + test("a running session reports elapsed time and its current tool", () => { + const progress = agentProgress({ ...base, lastActivityAt: 42_000 }, 42_000) + expect(progress).toEqual({ stat: "0:42 · grep", working: true, stalled: false }) + }) + + test("a running session with no current tool reports elapsed time alone", () => { + const progress = agentProgress( + { ...base, currentToolName: null, lastActivityAt: 42_000 }, + 42_000, + ) + expect(progress).toEqual({ stat: "0:42", working: true, stalled: false }) + }) + + test("silence past the stall window flips working to stalled", () => { + const progress = agentProgress({ ...base, lastActivityAt: 0 }, 31_000, 30_000) + expect(progress).toEqual({ stat: "0:31 · grep", working: false, stalled: true }) + }) + + test("recent activity keeps a long-running session marked working", () => { + const progress = agentProgress({ ...base, lastActivityAt: 100_000 }, 100_500, 30_000) + expect(progress?.working).toBe(true) + expect(progress?.stalled).toBe(false) + }) +}) diff --git a/src/tui-opentui/agent-progress.ts b/src/tui-opentui/agent-progress.ts new file mode 100644 index 000000000..dcea4ef7a --- /dev/null +++ b/src/tui-opentui/agent-progress.ts @@ -0,0 +1,57 @@ +/** + * Live progress for a dispatched sub-agent's pending row in the transcript. + * + * A "task" tool call renders as one row for its whole lifetime (see + * `runtime-bridge.ts`'s `syncAgentProgress`). While the call is outstanding + * this fills in what a bare pending mark cannot say: how long the worker has + * been running, what it is doing right now, and whether it has gone quiet + * long enough to look hung rather than merely slow. + */ + +/** Minimal session shape this module reads — avoids a hard dep on the store. */ +export type AgentProgressSession = { + readonly status: "running" | "done" | "failed" | "cancelled"; + readonly currentToolName: string | null; + readonly startedAt: number; + readonly lastActivityAt: number; +}; + +export type AgentProgress = { + /** Dim trailer painted after the row's subject, e.g. "0:42 · grep". */ + readonly stat: string; + /** True while the worker has reported activity within the stall window. */ + readonly working: boolean; + /** True once silence has run longer than the stall window. */ + readonly stalled: boolean; +}; + +/** Silence after which a running worker reads as hung rather than thinking. */ +export const DEFAULT_STALL_MS = 30_000; + +/** "m:ss" — compact enough to sit in a row's dim trailer alongside a tool name. */ +export function clockLabel(ms: number): string { + const totalSeconds = Math.max(0, Math.floor(ms / 1000)); + const minutes = Math.floor(totalSeconds / 60); + const seconds = totalSeconds % 60; + return `${minutes}:${String(seconds).padStart(2, "0")}`; +} + +/** + * Progress for a running session's pending row, or null once it has finished — + * a terminal session resolves its row through the tool-result path instead. + */ +export function agentProgress( + session: AgentProgressSession, + nowMs: number, + stallMs: number = DEFAULT_STALL_MS, +): AgentProgress | null { + if (session.status !== "running") return null; + const elapsed = clockLabel(nowMs - session.startedAt); + const tool = session.currentToolName; + const stalled = nowMs - session.lastActivityAt >= stallMs; + return { + stat: tool !== null && tool.length > 0 ? `${elapsed} · ${tool}` : elapsed, + working: !stalled, + stalled, + }; +} diff --git a/src/tui-opentui/product-host.ts b/src/tui-opentui/product-host.ts index 2dc83beda..de6bf83ad 100644 --- a/src/tui-opentui/product-host.ts +++ b/src/tui-opentui/product-host.ts @@ -13,6 +13,7 @@ import { checkWidthContract, widthContractNotice } from "./width-contract.js" import { attachSessionBridge, type SessionBridge, + type TaskProgressSession, type TurnMonitorOptions, } from "./runtime-bridge.js" import { openModelPickerOverlay } from "./overlays.js" @@ -105,6 +106,12 @@ export type ProductHostConfig = { * this to view real subagent sessions. */ readonly onObserveRequest?: PaletteOnObserveRequest + /** + * Live sub-agent sessions read on the chrome poll cadence to refresh + * outstanding `task` rows with elapsed time, current tool, and stall state. + * Omitted hosts (tests, the demo shell) simply paint bare pending rows. + */ + readonly subAgentSessions?: () => readonly TaskProgressSession[] /** * Renderer factory override for headless mounting in tests. * Defaults to the real `createCliRenderer`; tests inject a @@ -299,6 +306,9 @@ export async function mountProductHost( if (disposed) return try { paintChrome(shell) + if (config.subAgentSessions !== undefined) { + bridge.syncAgentProgress(config.subAgentSessions()) + } } catch { clearInterval(stickyPoll) } diff --git a/src/tui-opentui/runner-host.test.ts b/src/tui-opentui/runner-host.test.ts index 5f6d931c8..55ddcf909 100644 --- a/src/tui-opentui/runner-host.test.ts +++ b/src/tui-opentui/runner-host.test.ts @@ -49,6 +49,7 @@ function session(over: Partial): SubAgentSession { currentToolName: null, entries: [], startedAt: 0, + lastActivityAt: 0, ...over, } } diff --git a/src/tui-opentui/runner-host.ts b/src/tui-opentui/runner-host.ts index 92c8a23f5..888e45d15 100644 --- a/src/tui-opentui/runner-host.ts +++ b/src/tui-opentui/runner-host.ts @@ -239,6 +239,14 @@ export async function mountRunnerHost(deps: RunnerHostDeps): Promise onCommand: deps.onCommand, chrome: chromeFromSession(deps.chrome()), onObserveRequest: () => observeSessionFromSubAgents(deps.subAgentSessions()), + subAgentSessions: () => + deps.subAgentSessions().map((s) => ({ + id: s.id, + status: s.status, + currentToolName: s.currentToolName, + startedAt: s.startedAt, + lastActivityAt: s.lastActivityAt, + })), ...(deps.createRenderer !== undefined ? { createRenderer: deps.createRenderer } : {}), ...(deps.telemetryNotice !== undefined ? { telemetryNotice: deps.telemetryNotice } diff --git a/src/tui-opentui/runtime-bridge.test.ts b/src/tui-opentui/runtime-bridge.test.ts index bf4521628..cc521e590 100644 --- a/src/tui-opentui/runtime-bridge.test.ts +++ b/src/tui-opentui/runtime-bridge.test.ts @@ -1,11 +1,12 @@ -import { describe, expect, test } from "bun:test" +import { describe, expect, spyOn, test } from "bun:test" import { FIXTURE_BUSY_SESSION, attachSessionBridge, createRecordingPort, mapReactorLike, + type TaskProgressSession, } from "./runtime-bridge" -import { createAppShell } from "./shell" +import { appendStreamRow, createAppShell, streamRowCount } from "./shell" import { withTestRenderer } from "./harness" import { badgeCount } from "./session-queue" @@ -600,3 +601,113 @@ describe("parallel sub-agent dispatch on the live session bridge", () => { ) }) }) + +describe("syncAgentProgress", () => { + function taskSession(over: Partial): TaskProgressSession { + return { + id: "task-1", + status: "running", + currentToolName: "grep", + startedAt: 0, + lastActivityAt: 0, + ...over, + } + } + + test("updates the dispatch row in place without appending or removing rows", async () => { + await withTestRenderer( + async (h) => { + const shell = createAppShell(h.renderer, { + terminal: { columns: 80, rows: 24 }, + wireKeys: false, + run: "busy", + }) + // Padding rows ahead of the dispatch: proves churn stays bounded by + // outstanding task calls, not by transcript length. + for (let i = 0; i < 40; i++) { + appendStreamRow(shell, { role: "assistant", text: `filler ${i}` }) + } + let nowMs = 0 + const bridge = attachSessionBridge(shell, createRecordingPort(), { + now: () => nowMs, + }) + try { + bridge.handle({ + type: "inference.tool_call.end", + data: { + name: "task", + callId: "task-1", + arguments: { description: "Review permission gate" }, + }, + }) + await h.renderOnce() + const rowCountBefore = streamRowCount(shell) + const removeSpy = spyOn(shell.transcript, "remove") + + nowMs = 42_000 + bridge.syncAgentProgress([taskSession({ lastActivityAt: nowMs })]) + bridge.syncAgentProgress([ + taskSession({ currentToolName: "grep", lastActivityAt: nowMs }), + ]) + + expect(streamRowCount(shell)).toBe(rowCountBefore) + // One rewrite per changed tick, never proportional to the 40 padding rows. + expect(removeSpy.mock.calls.length).toBeLessThanOrEqual(2) + + const row = shell.streamLog[rowCountBefore - 1]! + expect(row.pending).toBe(true) + expect(row.agentWorking).toBe(true) + expect(row.stat).toContain("grep") + + nowMs = 72_000 + bridge.syncAgentProgress([ + taskSession({ currentToolName: "grep", lastActivityAt: 42_000 }), + ]) + const stalledRow = shell.streamLog[rowCountBefore - 1]! + expect(stalledRow.agentWorking).toBe(false) + + removeSpy.mockRestore() + } finally { + bridge.dispose() + shell.dispose() + } + }, + { width: 80, height: 24 }, + ) + }) + + test("a finished session's row is left to the tool-result path", async () => { + await withTestRenderer( + async (h) => { + const shell = createAppShell(h.renderer, { + terminal: { columns: 80, rows: 24 }, + wireKeys: false, + run: "busy", + }) + const bridge = attachSessionBridge(shell, createRecordingPort()) + try { + bridge.handle({ + type: "inference.tool_call.end", + data: { + name: "task", + callId: "task-1", + arguments: { description: "Review mouse/paste" }, + }, + }) + bridge.handle({ + type: "tool.done", + data: { result: { callId: "task-1", name: "task", content: "done", isError: false } }, + }) + const index = shell.streamLog.length - 1 + bridge.syncAgentProgress([taskSession({ status: "done" })]) + expect(shell.streamLog[index]!.pending).not.toBe(true) + expect(shell.streamLog[index]!.agentWorking).toBeUndefined() + } finally { + bridge.dispose() + shell.dispose() + } + }, + { width: 80, height: 24 }, + ) + }) +}) diff --git a/src/tui-opentui/runtime-bridge.ts b/src/tui-opentui/runtime-bridge.ts index 1b4769d16..2f3bddedd 100644 --- a/src/tui-opentui/runtime-bridge.ts +++ b/src/tui-opentui/runtime-bridge.ts @@ -66,6 +66,13 @@ import { } from "./tool-rows.js" import type { StreamRow } from "./stream.js" import { advanceRevealChars, flattenReasoningText, type Thought } from "./thinking.js" +import { agentProgress, type AgentProgressSession } from "./agent-progress.js" + +/** Tool name a sub-agent dispatch call carries — its row gets live progress. */ +const TASK_TOOL_NAME = "task" + +/** A sub-agent session as `syncAgentProgress` needs it: identified, and live-readable. */ +export type TaskProgressSession = AgentProgressSession & { readonly id: string } import { PRODUCTION_REACTOR_TYPES, createStreamMapContext, @@ -155,6 +162,12 @@ export type SessionBridge = { /** Current derived turn phase (progress label, stall clock, quota window). */ readonly turn: TurnState readonly shell: AppShell + /** + * Refresh outstanding `task` rows with each worker's live progress. The + * caller supplies the sessions (from `SubAgentSessionStore.listForStrip()` + * or similar) on whatever cadence it already polls at. + */ + syncAgentProgress: (sessions: readonly TaskProgressSession[]) => void } const NOOP_PORT: SessionPort = { @@ -305,6 +318,12 @@ type BridgeBag = { toolRows: Map /** Row of the newest in-flight call, for results that carry no call id. */ lastToolRow: number + /** + * callIds of outstanding `task` calls — a subset of `toolRows`' keys. Kept + * separate so `syncAgentProgress` never has to walk every in-flight tool to + * find the handful that are sub-agent dispatches. + */ + taskCallIds: Set /** * Row index where the inference attempt in progress began, or null when no * boundary is armed. The mapper decides when to mark, clear and roll back; @@ -484,6 +503,9 @@ function applyToolCall( appendStreamRow(shell, row) } if (event.callId !== undefined) bag.toolRows.set(event.callId, index) + if (event.callId !== undefined && event.name === TASK_TOOL_NAME) { + bag.taskCallIds.add(event.callId) + } bag.lastToolRow = index } @@ -504,7 +526,10 @@ function applyToolResult( }) const tracked = event.callId !== undefined ? bag.toolRows.get(event.callId) : undefined - if (event.callId !== undefined) bag.toolRows.delete(event.callId) + if (event.callId !== undefined) { + bag.toolRows.delete(event.callId) + bag.taskCallIds.delete(event.callId) + } const index = tracked ?? bag.lastToolRow const call = streamRowAt(shell, index) if (call === undefined || call.pending !== true) { @@ -514,6 +539,48 @@ function applyToolResult( replaceStreamRowAt(shell, index, mergeToolRows(call, result)) } +/** + * Refresh every outstanding `task` call's row with its worker's live progress — + * elapsed time, current tool, and whether it has gone quiet. Rewrites each row + * in place through `replaceStreamRowAt` (the same path a tool result resolves + * through); a session that finished, or is missing from `sessions` (already + * pruned, or never started), leaves its row untouched rather than reverting to + * a bare pending mark. + * + * Bounded by outstanding task calls, not transcript length: an idle sub-agent + * dispatch costs nothing here, and a live one costs exactly one row rewrite. + */ +function syncAgentProgress( + shell: AppShell, + bag: BridgeBag, + sessions: readonly TaskProgressSession[], + nowMs: number, +): void { + if (bag.taskCallIds.size === 0) return + for (const callId of bag.taskCallIds) { + const index = bag.toolRows.get(callId) + if (index === undefined) { + bag.taskCallIds.delete(callId) + continue + } + const row = streamRowAt(shell, index) + if (row === undefined || row.pending !== true) { + bag.taskCallIds.delete(callId) + continue + } + const session = sessions.find((s) => s.id === callId) + if (session === undefined) continue + const progress = agentProgress(session, nowMs) + if (progress === null) continue + if (row.stat === progress.stat && row.agentWorking === progress.working) continue + replaceStreamRowAt(shell, index, { + ...row, + stat: progress.stat, + agentWorking: progress.working, + }) + } +} + /** * Retract everything the failed attempt painted, then forget the row * bookkeeping that pointed into it — a rolled-back tool call has no row left @@ -525,7 +592,10 @@ function rollbackAttempt(shell: AppShell, bag: BridgeBag): void { if (boundary === null || boundary >= streamRowCount(shell)) return truncateStreamRows(shell, boundary) for (const [callId, index] of [...bag.toolRows]) { - if (index >= boundary) bag.toolRows.delete(callId) + if (index >= boundary) { + bag.toolRows.delete(callId) + bag.taskCallIds.delete(callId) + } } if (bag.lastToolRow >= boundary) bag.lastToolRow = -1 if (bag.turnThinking !== null && bag.turnThinking.index >= boundary) { @@ -644,6 +714,7 @@ export function attachSessionBridge( now, toolRows: new Map(), lastToolRow: -1, + taskCallIds: new Set(), attemptRow: null, turnThinking: null, } @@ -896,6 +967,10 @@ export function attachSessionBridge( get turn() { return bag.turn }, + syncAgentProgress: (sessions) => { + if (bag.disposed) return + syncAgentProgress(shell, bag, sessions, now()) + }, dispose: () => { bag.disposed = true applyCadence(null) diff --git a/src/tui-opentui/stream.test.ts b/src/tui-opentui/stream.test.ts index 3cc39dd67..dafcfff33 100644 --- a/src/tui-opentui/stream.test.ts +++ b/src/tui-opentui/stream.test.ts @@ -368,3 +368,44 @@ describe("block labels", () => { expect(blockLabel(corbits, critic, CREW)).toBe("● critic") }) }) + +describe("sub-agent dispatch row marks", () => { + const dispatch = toolCallRow({ + name: "task", + arguments: JSON.stringify({ description: "Review permission gate" }), + }) + + test("a bare pending call reads as the plain dot", () => { + expect(streamRowGutter(dispatch, SOLO).content).toContain("·") + }) + + test("an actively working dispatch reads distinctly from the plain dot", () => { + const working = { ...dispatch, agentWorking: true } + const gutter = streamRowGutter(working, SOLO).content + expect(gutter).toContain("◐") + expect(gutter).not.toContain("·") + }) + + test("a stalled dispatch reads distinctly from both working and plain pending", () => { + const stalled = { ...dispatch, agentWorking: false } + const gutter = streamRowGutter(stalled, SOLO).content + expect(gutter).toContain("!") + expect(gutter).not.toContain("◐") + expect(gutter).not.toContain("·") + }) + + test("elapsed time and current tool paint as the row's dim trailer", () => { + const working = { ...dispatch, agentWorking: true, stat: "0:42 · grep" } + const line = toolSentenceLines(working, 60) + .flat() + .map((s) => s.text) + .join("") + expect(line).toContain("0:42 · grep") + }) + + test("a resolved dispatch drops back to the plain done mark", () => { + const result = toolResultRow({ name: "task", content: "8 lines", isError: false }) + const merged = mergeToolRows({ ...dispatch, agentWorking: true }, result) + expect(streamRowGutter(merged, SOLO).content).toContain("✓") + }) +}) diff --git a/src/tui-opentui/stream.ts b/src/tui-opentui/stream.ts index 5b207b2a6..4d0ffac94 100644 --- a/src/tui-opentui/stream.ts +++ b/src/tui-opentui/stream.ts @@ -128,6 +128,13 @@ export type StreamRow = { readonly verb?: string /** Diff stat or line range painted dim after the subject, e.g. "+1/-0". */ readonly stat?: string + /** + * A dispatched sub-agent's row while its `task` call is still pending: true + * once it has reported activity within the stall window, false once the + * silence has run long enough to look hung rather than merely slow. Absent + * for every row that is not a live sub-agent dispatch. + */ + readonly agentWorking?: boolean } /** @@ -212,6 +219,10 @@ const MARK_OK = "✓" const MARK_FAILED = "×" /** A call still in flight has no verdict yet, and must not borrow one. */ const MARK_PENDING = "·" +/** A dispatched sub-agent actively reporting progress — distinct from the bare dot. */ +const MARK_AGENT_ACTIVE = "◐" +/** A dispatched sub-agent gone quiet past the stall window. */ +const MARK_AGENT_STALLED = "!" /** * Glyphs are single-cell so nothing after them can slip out of the meta column. @@ -268,7 +279,10 @@ export function blockLabel( /** Where a tool row stands: in flight, answered, or answered badly. */ function toolMark(row: StreamRow): string { if (row.failed === true) return MARK_FAILED - return row.pending === true ? MARK_PENDING : MARK_OK + if (row.pending !== true) return MARK_OK + if (row.agentWorking === true) return MARK_AGENT_ACTIVE + if (row.agentWorking === false) return MARK_AGENT_STALLED + return MARK_PENDING } /** diff --git a/src/tui-opentui/tool-rows.test.ts b/src/tui-opentui/tool-rows.test.ts index 5dbab1eb4..c15bcf09a 100644 --- a/src/tui-opentui/tool-rows.test.ts +++ b/src/tui-opentui/tool-rows.test.ts @@ -86,6 +86,19 @@ describe("a call and its answer", () => { expect(rows[0]?.detail?.length).toBeGreaterThan(0) }) + test("a resolved sub-agent dispatch drops its live elapsed-time trailer for the real answer", () => { + const rows: StreamRow[] = [] + pushToolCall(rows, { + name: "task", + arguments: JSON.stringify({ description: "Review mouse/paste" }), + }) + rows[0] = { ...rows[0]!, agentWorking: true, stat: "0:42 · bash" } + + pushToolResult(rows, { name: "task", content: "8 lines" }) + expect(rows[0]?.pending).toBeUndefined() + expect(rows[0]?.stat).toBe("8 lines") + }) + test("an answer with no call on the log still gets a row", () => { const rows: StreamRow[] = [] pushToolResult(rows, { name: "shell", content: "orphan" }) diff --git a/src/tui-opentui/tool-rows.ts b/src/tui-opentui/tool-rows.ts index f3d18636a..a62aa9ba7 100644 --- a/src/tui-opentui/tool-rows.ts +++ b/src/tui-opentui/tool-rows.ts @@ -77,15 +77,20 @@ function countNoun(count: number, noun: string): string { */ export function mergeToolRows(call: StreamRow, result: StreamRow): StreamRow { const failed = result.failed === true - const { pending: _pending, ...answered } = call + const { pending: _pending, agentWorking: _agentWorking, stat: _stat, ...answered } = call const addendum = failed ? undefined : resultAddendum(result) + // A live sub-agent's elapsed-time trailer is scaffolding for the wait, not a + // fact about the call the way a diff's own +/- count is — the answer's stat + // must win over it rather than being shadowed by whatever it last read. + const callStat = call.agentWorking !== undefined ? undefined : call.stat const base: StreamRow = { ...answered, text: result.text, summary: call.summary ?? "", ...(failed || call.failed === true ? { failed: true } : {}), // A diff already states its own +/- counts; nothing the answer says beats it. - ...(call.stat === undefined && addendum !== undefined ? { stat: addendum } : {}), + ...(callStat === undefined && addendum !== undefined ? { stat: addendum } : {}), + ...(callStat !== undefined ? { stat: callStat } : {}), } if (call.coalesced === true) {