From 50606eec184f2c49fb36620f7e58b6b26fd23f9b Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sat, 22 Aug 2026 16:02:57 -0700 Subject: [PATCH 1/2] Live-clock in-flight tool rows and clear the stale stall notice An ordinary tool call's row sat on a static pending mark for the whole call, indistinguishable from a hung turn once a slow tool ran for minutes. Give it the same live elapsed clock Task rows already carry, skipping calls whose row already states its own fact (a diff's +/- count) and dropping the clock once the answer lands. The "no response for a while" notice had no ttl by design (it must stay up for as long as the silence lasts) but nothing ever cleared it when the run started producing again, so it lingered on screen after the turn was visibly alive. Clear it on the next tick once the stall level drops back to quiet. Fixes CL-6894 https://linear.app/abklabs/issue/CL-6894 --- src/tui/runtime-bridge.test.ts | 95 ++++++++++++++++++++++++++++++++++ src/tui/runtime-bridge.ts | 68 +++++++++++++++++++++++- src/tui/turn-monitor.test.ts | 22 ++++++++ 3 files changed, 183 insertions(+), 2 deletions(-) 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..bc19b483a 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: [], @@ -878,6 +936,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, @@ -1162,6 +1221,11 @@ export function attachSessionBridge( const level = stallLevel(stallArgs) if (level === "notice") { setStatusFlash(shell, STALL_NOTICE_MESSAGE) + } else if (shell.statusFlash === STALL_NOTICE_MESSAGE) { + // Activity resumed after the notice was posted: it carries no ttl (it + // must stay up for as long as the silence lasts), so nothing else would + // ever take it down once the run starts producing again. + setStatusFlash(shell, null) } // Same "is this stalled at all" question `paintPhase` asks above — call diff --git a/src/tui/turn-monitor.test.ts b/src/tui/turn-monitor.test.ts index 110adb1ce..c96377c7a 100644 --- a/src/tui/turn-monitor.test.ts +++ b/src/tui/turn-monitor.test.ts @@ -326,6 +326,28 @@ 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. + t.bridge.handle({ type: "inference.text.delta", data: { token: "ok" } }) + t.tick() + 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) From 48e37b1d40d8661b945ec2517536ca057d6088a5 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sat, 22 Aug 2026 16:27:48 -0700 Subject: [PATCH 2/2] Clear the stall notice on paint, not only on the watchdog tick The cadence timer is cancelled the moment the turn settles, so a tool.done then inference.done burst that lands before the next tick would otherwise leave the banner up forever. --- CHANGELOG.md | 13 +++++++++++++ docs/TUI.md | 8 +++++++- src/tui/runtime-bridge.ts | 23 +++++++++++------------ src/tui/turn-monitor.test.ts | 23 ++++++++++++++++++++++- 4 files changed, 53 insertions(+), 14 deletions(-) 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.ts b/src/tui/runtime-bridge.ts index bc19b483a..5a486678c 100644 --- a/src/tui/runtime-bridge.ts +++ b/src/tui/runtime-bridge.ts @@ -927,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) @@ -1216,18 +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) - } else if (shell.statusFlash === STALL_NOTICE_MESSAGE) { - // Activity resumed after the notice was posted: it carries no ttl (it - // must stay up for as long as the silence lasts), so nothing else would - // ever take it down once the run starts producing again. - setStatusFlash(shell, null) - } - // 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 c96377c7a..c8308a66a 100644 --- a/src/tui/turn-monitor.test.ts +++ b/src/tui/turn-monitor.test.ts @@ -338,9 +338,30 @@ describe("stall watchdog", () => { expect(t.shell.statusFlash).toBe(STALL_NOTICE_MESSAGE) // The model starts producing again — the notice must not linger past - // the silence it was reporting. + // 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()