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) {