diff --git a/CHANGELOG.md b/CHANGELOG.md index 10b644d1f..f31f5326d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,19 @@ matching `## [X.Y.Z]` section (plus install instructions). Do not maintain parallel copies under `docs/` or `scripts/notes/`. At cut time: rename `## [Unreleased]` to `## [X.Y.Z] - YYYY-MM-DD`, then run the release script. +## [Unreleased] + +### TUI + +- **In-flight tool rows show elapsed time.** Ordinary pending calls (MCP, + search, shell) tick a live clock the same way Task rows already do, so a + slow-but-alive call is distinguishable from a hung turn. + +- **The stall notice comes down the moment activity resumes.** It is a live + diagnosis, not a sticky banner: a tool finishing or the turn settling + clears it on that paint, even if the monitor tick has already been + cancelled. + ## [0.2.102] - 2026-08-22 ### Permissions diff --git a/docs/TUI.md b/docs/TUI.md index 717a1ac00..80e03b853 100644 --- a/docs/TUI.md +++ b/docs/TUI.md @@ -140,6 +140,9 @@ instant a tool batch resolves and `awaitingResponse` flips back to true). That wait has no signal to tell "still coming" from "never coming" apart, so it is never auto-aborted no matter how long it runs; it still surfaces via the notice, keeping the operator in control of whether to give up on it. +The notice is a live diagnosis, not a sticky banner: it comes down on the +same paint as the activity that ends the silence, including when the turn +settles before the next monitor tick. An idle session animates nothing at all: the monitor tick stops entirely rather than repainting an unchanging frame. @@ -211,7 +214,10 @@ operator-preferred Amp/Codex-style lines: `runtime-bridge` paints each `task` call as a stream row and rewrites it in place via `syncAgentProgress` / `agentProgress` (elapsed clock, current tool, -stall marker). There is no standing FLEET board and no dual-rail agents chrome: +stall marker). Ordinary in-flight tool rows get the same elapsed clock +(`syncToolElapsed`) without the current-tool suffix, so a slow MCP or +network call is distinguishable from a hung turn. There is no standing +FLEET board and no dual-rail agents chrome: `formatChromeZones` always returns both zones null (`task` and `agents`), and geometry is stack-only (`layoutMode: "stack"`, `railWidth: 0`). Checklist and agents strips are parked pending rebuild; Alt+T / direct `setChromeZones` may diff --git a/src/tui/runtime-bridge.test.ts b/src/tui/runtime-bridge.test.ts index 534b50fcc..b08599f44 100644 --- a/src/tui/runtime-bridge.test.ts +++ b/src/tui/runtime-bridge.test.ts @@ -944,6 +944,101 @@ describe("syncAgentProgress", () => { }) }) +describe("in-flight tool row elapsed time", () => { + test("an ordinary pending call's row grows a live clock, then loses it to the answer", async () => { + await withTestRenderer( + async (h) => { + const shell = createAppShell(h.renderer, { + terminal: { columns: 80, rows: 24 }, + wireKeys: false, + run: "busy", + }) + let nowMs = 0 + let tick: (() => void) | undefined + const bridge = attachSessionBridge(shell, createRecordingPort(), { + now: () => nowMs, + schedule: (fn) => { + tick = fn + return () => { + tick = undefined + } + }, + }) + try { + bridge.handle({ type: "inference.start", data: {} }) + bridge.handle({ + type: "inference.tool_call.end", + data: { name: "run_shell", callId: "c1", arguments: "sleep 30" }, + }) + const index = streamRowCount(shell) - 1 + expect(shell.streamLog[index]!.stat).toBeUndefined() + + nowMs = 65_000 + tick?.() + expect(shell.streamLog[index]!.stat).toBe("1:05") + + bridge.handle({ + type: "tool.done", + data: { result: { callId: "c1", name: "run_shell", content: "ok", isError: false } }, + }) + // The elapsed clock was scaffolding for the wait, not a fact worth + // keeping — the answer's own addendum takes the row over. + expect(shell.streamLog[index]!.stat).not.toBe("1:05") + } finally { + bridge.dispose() + shell.dispose() + } + }, + { width: 80, height: 24 }, + ) + }) + + test("a diff call keeps its own +/- stat instead of an elapsed clock", async () => { + await withTestRenderer( + async (h) => { + const shell = createAppShell(h.renderer, { + terminal: { columns: 80, rows: 24 }, + wireKeys: false, + run: "busy", + }) + let nowMs = 0 + let tick: (() => void) | undefined + const bridge = attachSessionBridge(shell, createRecordingPort(), { + now: () => nowMs, + schedule: (fn) => { + tick = fn + return () => { + tick = undefined + } + }, + }) + try { + bridge.handle({ type: "inference.start", data: {} }) + bridge.handle({ + type: "inference.tool_call.end", + data: { + name: "write_file", + callId: "c1", + arguments: JSON.stringify({ path: "a.txt", content: "hi\n" }), + }, + }) + const index = streamRowCount(shell) - 1 + const before = shell.streamLog[index]!.stat + expect(before).toContain("+") + + nowMs = 65_000 + tick?.() + expect(shell.streamLog[index]!.stat).toBe(before) + } finally { + bridge.dispose() + shell.dispose() + } + }, + { width: 80, height: 24 }, + ) + }) +}) + describe("task checklist calls stay out of the transcript", () => { test("a manage_tasks call and its result paint no rows", async () => { await withTestRenderer( diff --git a/src/tui/runtime-bridge.ts b/src/tui/runtime-bridge.ts index 8ff0866ff..5a486678c 100644 --- a/src/tui/runtime-bridge.ts +++ b/src/tui/runtime-bridge.ts @@ -73,6 +73,7 @@ import type { StreamRow } from "./stream.js" import { advanceRevealChars, flattenReasoningText, type Thought } from "./thinking.js" import { agentProgress, + clockLabel, fleetProgress, type AgentProgressSession, } from "./agent-progress.js" @@ -356,6 +357,12 @@ type BridgeBag = { now: () => number /** Transcript row each in-flight call occupies, so its result can resolve it. */ toolRows: Map + /** + * When each in-flight ordinary tool call started, so its row can carry a + * live elapsed clock instead of sitting on a static pending mark for the + * length of a slow call — the one case a healthy turn reads as dead. + */ + toolCallStartedAt: Map /** Row of the newest in-flight call, for results that carry no call id. */ lastToolRow: number /** @@ -564,7 +571,15 @@ function applyToolCall( } else { appendStreamRow(shell, row) } - if (event.callId !== undefined) bag.toolRows.set(event.callId, index) + if (event.callId !== undefined) { + bag.toolRows.set(event.callId, index) + // A diff call's row already carries a "+n/-n" stat — that is the fact + // worth keeping, not an elapsed clock, so only ordinary calls (no stat of + // their own) pick up the live timer. + if (row.stat === undefined) { + bag.toolCallStartedAt.set(event.callId, bag.now()) + } + } if (event.callId !== undefined && event.name === TASK_TOOL_NAME) { bag.taskCallIds.add(event.callId) } @@ -590,13 +605,20 @@ function applyToolResult( }) const tracked = event.callId !== undefined ? bag.toolRows.get(event.callId) : undefined + // The elapsed clock was scaffolding for the wait, not a fact about the + // call — clear it before the merge so it never crowds out the answer's own + // addendum (e.g. "3 lines") the way a diff's own +/- count is allowed to. + const clockOwned = + event.callId !== undefined && bag.toolCallStartedAt.has(event.callId) if (event.callId !== undefined) { bag.toolRows.delete(event.callId) + bag.toolCallStartedAt.delete(event.callId) bag.taskCallIds.delete(event.callId) } if (bag.toolRows.size === 0) shell.inFlightTool = null const index = tracked ?? bag.lastToolRow - const call = streamRowAt(shell, index) + const rawCall = streamRowAt(shell, index) + const call = clockOwned && rawCall !== undefined ? omitStat(rawCall) : rawCall if (call === undefined || call.pending !== true) { appendStreamRow(shell, result) return @@ -642,6 +664,40 @@ function syncAgentProgress( } } +/** Drop `stat` entirely rather than set it `undefined` (exactOptionalPropertyTypes). */ +function omitStat(row: StreamRow): StreamRow { + const { stat: _stat, ...rest } = row + return rest +} + +/** + * Refresh every plain in-flight tool call's row with how long it has been + * running. A `task` dispatch already gets this (and more) from + * `syncAgentProgress`, so those calls are skipped here rather than double + * painted. Without a live clock an ordinary call's row sits on a static + * pending mark for however long the tool takes — indistinguishable from a + * hung turn once that stretches past a few seconds. + */ +function syncToolElapsed(shell: AppShell, bag: BridgeBag, nowMs: number): void { + if (bag.toolCallStartedAt.size === 0) return + for (const [callId, startedAt] of bag.toolCallStartedAt) { + if (bag.taskCallIds.has(callId)) continue + const index = bag.toolRows.get(callId) + if (index === undefined) { + bag.toolCallStartedAt.delete(callId) + continue + } + const row = streamRowAt(shell, index) + if (row === undefined || row.pending !== true) { + bag.toolCallStartedAt.delete(callId) + continue + } + const stat = clockLabel(nowMs - startedAt) + if (row.stat === stat) continue + replaceStreamRowAt(shell, index, { ...row, stat }) + } +} + /** * Retract everything the failed attempt painted, then forget the row * bookkeeping that pointed into it — a rolled-back tool call has no row left @@ -655,6 +711,7 @@ function rollbackAttempt(shell: AppShell, bag: BridgeBag): void { for (const [callId, index] of [...bag.toolRows]) { if (index >= boundary) { bag.toolRows.delete(callId) + bag.toolCallStartedAt.delete(callId) bag.taskCallIds.delete(callId) } } @@ -802,6 +859,7 @@ export function attachSessionBridge( quotaFired: false, now, toolRows: new Map(), + toolCallStartedAt: new Map(), lastToolRow: -1, taskCallIds: new Set(), agentSessions: [], @@ -869,6 +927,17 @@ export function attachSessionBridge( const paintPhaseAt = (nowMs: number, isStalled: boolean): void => { const turn = bag.turn + // The stall notice is a live diagnosis, not a sticky banner: it has to + // set *and* clear on every paint — including handle() — because the + // cadence timer is cancelled the moment the turn settles. If we only + // touched it from tick(), a tool.done → inference.done burst that lands + // before the next tick would leave the banner up forever. + const level = stallLevel(stallArgsFor(nowMs)) + if (level === "notice") { + setStatusFlash(shell, STALL_NOTICE_MESSAGE) + } else if (shell.statusFlash === STALL_NOTICE_MESSAGE) { + setStatusFlash(shell, null) + } // The landing mark rides this same re-entry: it animates through the // draw/fill loop while a turn is live and holds its filled frame otherwise. paintLanding(shell, nowMs, turn.isProcessing) @@ -878,6 +947,7 @@ export function attachSessionBridge( if (bag.openRow !== null && bag.openRow.kind === "thinking") { advanceOpenReveal(shell, bag.openRow, nowMs) } + syncToolElapsed(shell, bag, nowMs) const input = { isProcessing: turn.isProcessing, status: turn.status, @@ -1157,13 +1227,6 @@ export function attachSessionBridge( return } - // Notice only — the phase still paints below, because a ramp that stops - // moving is the very thing that reads as a hang. - const level = stallLevel(stallArgs) - if (level === "notice") { - setStatusFlash(shell, STALL_NOTICE_MESSAGE) - } - // Same "is this stalled at all" question `paintPhase` asks above — call // the one definition (`isStalledForDisplay`) rather than re-deriving it // from `stallLevel`'s result, so the two call sites can never disagree. diff --git a/src/tui/turn-monitor.test.ts b/src/tui/turn-monitor.test.ts index 110adb1ce..c8308a66a 100644 --- a/src/tui/turn-monitor.test.ts +++ b/src/tui/turn-monitor.test.ts @@ -326,6 +326,49 @@ describe("stall watchdog", () => { }) }) + test("clears the notice once activity resumes, rather than leaving it up", async () => { + await withTestRenderer(async (h) => { + const t: Harness = await setup(h) + try { + t.bridge.submit("build it", "immediate") + t.port.clear() + + t.advance(500) + t.tick() + expect(t.shell.statusFlash).toBe(STALL_NOTICE_MESSAGE) + + // The model starts producing again — the notice must not linger past + // the silence it was reporting. handle() itself has to take it down; + // waiting for the next tick leaves a window where the turn can settle + // and cancel the cadence, which would strand the banner forever. + t.bridge.handle({ type: "inference.text.delta", data: { token: "ok" } }) + expect(t.shell.statusFlash).not.toBe(STALL_NOTICE_MESSAGE) + } finally { + t.bridge.dispose() + } + }) + }) + + test("clears the notice when the turn settles before the next tick", async () => { + await withTestRenderer(async (h) => { + const t: Harness = await setup(h) + try { + t.bridge.submit("build it", "immediate") + t.port.clear() + + t.advance(500) + t.tick() + expect(t.shell.statusFlash).toBe(STALL_NOTICE_MESSAGE) + + t.bridge.handle({ type: "inference.done", data: {} }) + // Cadence is cancelled on settle. The notice has to already be gone. + expect(t.shell.statusFlash).not.toBe(STALL_NOTICE_MESSAGE) + } finally { + t.bridge.dispose() + } + }) + }) + test("aborts and flashes once a mid-stream hang crosses the stall timeout", async () => { await withTestRenderer(async (h) => { const t: Harness = await setup(h)