Skip to content

Commit b63681b

Browse files
Live-clock in-flight tool rows, clear stale stall notice (#529)
* 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 * 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.
1 parent f981c7c commit b63681b

5 files changed

Lines changed: 230 additions & 10 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,19 @@ matching `## [X.Y.Z]` section (plus install instructions). Do not maintain
1111
parallel copies under `docs/` or `scripts/notes/`. At cut time: rename
1212
`## [Unreleased]` to `## [X.Y.Z] - YYYY-MM-DD`, then run the release script.
1313

14+
## [Unreleased]
15+
16+
### TUI
17+
18+
- **In-flight tool rows show elapsed time.** Ordinary pending calls (MCP,
19+
search, shell) tick a live clock the same way Task rows already do, so a
20+
slow-but-alive call is distinguishable from a hung turn.
21+
22+
- **The stall notice comes down the moment activity resumes.** It is a live
23+
diagnosis, not a sticky banner: a tool finishing or the turn settling
24+
clears it on that paint, even if the monitor tick has already been
25+
cancelled.
26+
1427
## [0.2.102] - 2026-08-22
1528

1629
### Permissions

‎docs/TUI.md‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,9 @@ instant a tool batch resolves and `awaitingResponse` flips back to true).
140140
That wait has no signal to tell "still coming" from "never coming" apart, so
141141
it is never auto-aborted no matter how long it runs; it still surfaces via
142142
the notice, keeping the operator in control of whether to give up on it.
143+
The notice is a live diagnosis, not a sticky banner: it comes down on the
144+
same paint as the activity that ends the silence, including when the turn
145+
settles before the next monitor tick.
143146

144147
An idle session animates nothing at all: the monitor tick stops entirely
145148
rather than repainting an unchanging frame.
@@ -211,7 +214,10 @@ operator-preferred Amp/Codex-style lines:
211214

212215
`runtime-bridge` paints each `task` call as a stream row and rewrites it in
213216
place via `syncAgentProgress` / `agentProgress` (elapsed clock, current tool,
214-
stall marker). There is no standing FLEET board and no dual-rail agents chrome:
217+
stall marker). Ordinary in-flight tool rows get the same elapsed clock
218+
(`syncToolElapsed`) without the current-tool suffix, so a slow MCP or
219+
network call is distinguishable from a hung turn. There is no standing
220+
FLEET board and no dual-rail agents chrome:
215221
`formatChromeZones` always returns both zones null (`task` and `agents`), and
216222
geometry is stack-only (`layoutMode: "stack"`, `railWidth: 0`). Checklist and
217223
agents strips are parked pending rebuild; Alt+T / direct `setChromeZones` may

‎src/tui/runtime-bridge.test.ts‎

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -944,6 +944,101 @@ describe("syncAgentProgress", () => {
944944
})
945945
})
946946

947+
describe("in-flight tool row elapsed time", () => {
948+
test("an ordinary pending call's row grows a live clock, then loses it to the answer", async () => {
949+
await withTestRenderer(
950+
async (h) => {
951+
const shell = createAppShell(h.renderer, {
952+
terminal: { columns: 80, rows: 24 },
953+
wireKeys: false,
954+
run: "busy",
955+
})
956+
let nowMs = 0
957+
let tick: (() => void) | undefined
958+
const bridge = attachSessionBridge(shell, createRecordingPort(), {
959+
now: () => nowMs,
960+
schedule: (fn) => {
961+
tick = fn
962+
return () => {
963+
tick = undefined
964+
}
965+
},
966+
})
967+
try {
968+
bridge.handle({ type: "inference.start", data: {} })
969+
bridge.handle({
970+
type: "inference.tool_call.end",
971+
data: { name: "run_shell", callId: "c1", arguments: "sleep 30" },
972+
})
973+
const index = streamRowCount(shell) - 1
974+
expect(shell.streamLog[index]!.stat).toBeUndefined()
975+
976+
nowMs = 65_000
977+
tick?.()
978+
expect(shell.streamLog[index]!.stat).toBe("1:05")
979+
980+
bridge.handle({
981+
type: "tool.done",
982+
data: { result: { callId: "c1", name: "run_shell", content: "ok", isError: false } },
983+
})
984+
// The elapsed clock was scaffolding for the wait, not a fact worth
985+
// keeping — the answer's own addendum takes the row over.
986+
expect(shell.streamLog[index]!.stat).not.toBe("1:05")
987+
} finally {
988+
bridge.dispose()
989+
shell.dispose()
990+
}
991+
},
992+
{ width: 80, height: 24 },
993+
)
994+
})
995+
996+
test("a diff call keeps its own +/- stat instead of an elapsed clock", async () => {
997+
await withTestRenderer(
998+
async (h) => {
999+
const shell = createAppShell(h.renderer, {
1000+
terminal: { columns: 80, rows: 24 },
1001+
wireKeys: false,
1002+
run: "busy",
1003+
})
1004+
let nowMs = 0
1005+
let tick: (() => void) | undefined
1006+
const bridge = attachSessionBridge(shell, createRecordingPort(), {
1007+
now: () => nowMs,
1008+
schedule: (fn) => {
1009+
tick = fn
1010+
return () => {
1011+
tick = undefined
1012+
}
1013+
},
1014+
})
1015+
try {
1016+
bridge.handle({ type: "inference.start", data: {} })
1017+
bridge.handle({
1018+
type: "inference.tool_call.end",
1019+
data: {
1020+
name: "write_file",
1021+
callId: "c1",
1022+
arguments: JSON.stringify({ path: "a.txt", content: "hi\n" }),
1023+
},
1024+
})
1025+
const index = streamRowCount(shell) - 1
1026+
const before = shell.streamLog[index]!.stat
1027+
expect(before).toContain("+")
1028+
1029+
nowMs = 65_000
1030+
tick?.()
1031+
expect(shell.streamLog[index]!.stat).toBe(before)
1032+
} finally {
1033+
bridge.dispose()
1034+
shell.dispose()
1035+
}
1036+
},
1037+
{ width: 80, height: 24 },
1038+
)
1039+
})
1040+
})
1041+
9471042
describe("task checklist calls stay out of the transcript", () => {
9481043
test("a manage_tasks call and its result paint no rows", async () => {
9491044
await withTestRenderer(

‎src/tui/runtime-bridge.ts‎

Lines changed: 72 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,7 @@ import type { StreamRow } from "./stream.js"
7373
import { advanceRevealChars, flattenReasoningText, type Thought } from "./thinking.js"
7474
import {
7575
agentProgress,
76+
clockLabel,
7677
fleetProgress,
7778
type AgentProgressSession,
7879
} from "./agent-progress.js"
@@ -356,6 +357,12 @@ type BridgeBag = {
356357
now: () => number
357358
/** Transcript row each in-flight call occupies, so its result can resolve it. */
358359
toolRows: Map<string, number>
360+
/**
361+
* When each in-flight ordinary tool call started, so its row can carry a
362+
* live elapsed clock instead of sitting on a static pending mark for the
363+
* length of a slow call — the one case a healthy turn reads as dead.
364+
*/
365+
toolCallStartedAt: Map<string, number>
359366
/** Row of the newest in-flight call, for results that carry no call id. */
360367
lastToolRow: number
361368
/**
@@ -564,7 +571,15 @@ function applyToolCall(
564571
} else {
565572
appendStreamRow(shell, row)
566573
}
567-
if (event.callId !== undefined) bag.toolRows.set(event.callId, index)
574+
if (event.callId !== undefined) {
575+
bag.toolRows.set(event.callId, index)
576+
// A diff call's row already carries a "+n/-n" stat — that is the fact
577+
// worth keeping, not an elapsed clock, so only ordinary calls (no stat of
578+
// their own) pick up the live timer.
579+
if (row.stat === undefined) {
580+
bag.toolCallStartedAt.set(event.callId, bag.now())
581+
}
582+
}
568583
if (event.callId !== undefined && event.name === TASK_TOOL_NAME) {
569584
bag.taskCallIds.add(event.callId)
570585
}
@@ -590,13 +605,20 @@ function applyToolResult(
590605
})
591606
const tracked =
592607
event.callId !== undefined ? bag.toolRows.get(event.callId) : undefined
608+
// The elapsed clock was scaffolding for the wait, not a fact about the
609+
// call — clear it before the merge so it never crowds out the answer's own
610+
// addendum (e.g. "3 lines") the way a diff's own +/- count is allowed to.
611+
const clockOwned =
612+
event.callId !== undefined && bag.toolCallStartedAt.has(event.callId)
593613
if (event.callId !== undefined) {
594614
bag.toolRows.delete(event.callId)
615+
bag.toolCallStartedAt.delete(event.callId)
595616
bag.taskCallIds.delete(event.callId)
596617
}
597618
if (bag.toolRows.size === 0) shell.inFlightTool = null
598619
const index = tracked ?? bag.lastToolRow
599-
const call = streamRowAt(shell, index)
620+
const rawCall = streamRowAt(shell, index)
621+
const call = clockOwned && rawCall !== undefined ? omitStat(rawCall) : rawCall
600622
if (call === undefined || call.pending !== true) {
601623
appendStreamRow(shell, result)
602624
return
@@ -642,6 +664,40 @@ function syncAgentProgress(
642664
}
643665
}
644666

667+
/** Drop `stat` entirely rather than set it `undefined` (exactOptionalPropertyTypes). */
668+
function omitStat(row: StreamRow): StreamRow {
669+
const { stat: _stat, ...rest } = row
670+
return rest
671+
}
672+
673+
/**
674+
* Refresh every plain in-flight tool call's row with how long it has been
675+
* running. A `task` dispatch already gets this (and more) from
676+
* `syncAgentProgress`, so those calls are skipped here rather than double
677+
* painted. Without a live clock an ordinary call's row sits on a static
678+
* pending mark for however long the tool takes — indistinguishable from a
679+
* hung turn once that stretches past a few seconds.
680+
*/
681+
function syncToolElapsed(shell: AppShell, bag: BridgeBag, nowMs: number): void {
682+
if (bag.toolCallStartedAt.size === 0) return
683+
for (const [callId, startedAt] of bag.toolCallStartedAt) {
684+
if (bag.taskCallIds.has(callId)) continue
685+
const index = bag.toolRows.get(callId)
686+
if (index === undefined) {
687+
bag.toolCallStartedAt.delete(callId)
688+
continue
689+
}
690+
const row = streamRowAt(shell, index)
691+
if (row === undefined || row.pending !== true) {
692+
bag.toolCallStartedAt.delete(callId)
693+
continue
694+
}
695+
const stat = clockLabel(nowMs - startedAt)
696+
if (row.stat === stat) continue
697+
replaceStreamRowAt(shell, index, { ...row, stat })
698+
}
699+
}
700+
645701
/**
646702
* Retract everything the failed attempt painted, then forget the row
647703
* 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 {
655711
for (const [callId, index] of [...bag.toolRows]) {
656712
if (index >= boundary) {
657713
bag.toolRows.delete(callId)
714+
bag.toolCallStartedAt.delete(callId)
658715
bag.taskCallIds.delete(callId)
659716
}
660717
}
@@ -802,6 +859,7 @@ export function attachSessionBridge(
802859
quotaFired: false,
803860
now,
804861
toolRows: new Map(),
862+
toolCallStartedAt: new Map(),
805863
lastToolRow: -1,
806864
taskCallIds: new Set(),
807865
agentSessions: [],
@@ -869,6 +927,17 @@ export function attachSessionBridge(
869927

870928
const paintPhaseAt = (nowMs: number, isStalled: boolean): void => {
871929
const turn = bag.turn
930+
// The stall notice is a live diagnosis, not a sticky banner: it has to
931+
// set *and* clear on every paint — including handle() — because the
932+
// cadence timer is cancelled the moment the turn settles. If we only
933+
// touched it from tick(), a tool.done → inference.done burst that lands
934+
// before the next tick would leave the banner up forever.
935+
const level = stallLevel(stallArgsFor(nowMs))
936+
if (level === "notice") {
937+
setStatusFlash(shell, STALL_NOTICE_MESSAGE)
938+
} else if (shell.statusFlash === STALL_NOTICE_MESSAGE) {
939+
setStatusFlash(shell, null)
940+
}
872941
// The landing mark rides this same re-entry: it animates through the
873942
// draw/fill loop while a turn is live and holds its filled frame otherwise.
874943
paintLanding(shell, nowMs, turn.isProcessing)
@@ -878,6 +947,7 @@ export function attachSessionBridge(
878947
if (bag.openRow !== null && bag.openRow.kind === "thinking") {
879948
advanceOpenReveal(shell, bag.openRow, nowMs)
880949
}
950+
syncToolElapsed(shell, bag, nowMs)
881951
const input = {
882952
isProcessing: turn.isProcessing,
883953
status: turn.status,
@@ -1157,13 +1227,6 @@ export function attachSessionBridge(
11571227
return
11581228
}
11591229

1160-
// Notice only — the phase still paints below, because a ramp that stops
1161-
// moving is the very thing that reads as a hang.
1162-
const level = stallLevel(stallArgs)
1163-
if (level === "notice") {
1164-
setStatusFlash(shell, STALL_NOTICE_MESSAGE)
1165-
}
1166-
11671230
// Same "is this stalled at all" question `paintPhase` asks above — call
11681231
// the one definition (`isStalledForDisplay`) rather than re-deriving it
11691232
// from `stallLevel`'s result, so the two call sites can never disagree.

‎src/tui/turn-monitor.test.ts‎

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -326,6 +326,49 @@ describe("stall watchdog", () => {
326326
})
327327
})
328328

329+
test("clears the notice once activity resumes, rather than leaving it up", async () => {
330+
await withTestRenderer(async (h) => {
331+
const t: Harness = await setup(h)
332+
try {
333+
t.bridge.submit("build it", "immediate")
334+
t.port.clear()
335+
336+
t.advance(500)
337+
t.tick()
338+
expect(t.shell.statusFlash).toBe(STALL_NOTICE_MESSAGE)
339+
340+
// The model starts producing again — the notice must not linger past
341+
// the silence it was reporting. handle() itself has to take it down;
342+
// waiting for the next tick leaves a window where the turn can settle
343+
// and cancel the cadence, which would strand the banner forever.
344+
t.bridge.handle({ type: "inference.text.delta", data: { token: "ok" } })
345+
expect(t.shell.statusFlash).not.toBe(STALL_NOTICE_MESSAGE)
346+
} finally {
347+
t.bridge.dispose()
348+
}
349+
})
350+
})
351+
352+
test("clears the notice when the turn settles before the next tick", async () => {
353+
await withTestRenderer(async (h) => {
354+
const t: Harness = await setup(h)
355+
try {
356+
t.bridge.submit("build it", "immediate")
357+
t.port.clear()
358+
359+
t.advance(500)
360+
t.tick()
361+
expect(t.shell.statusFlash).toBe(STALL_NOTICE_MESSAGE)
362+
363+
t.bridge.handle({ type: "inference.done", data: {} })
364+
// Cadence is cancelled on settle. The notice has to already be gone.
365+
expect(t.shell.statusFlash).not.toBe(STALL_NOTICE_MESSAGE)
366+
} finally {
367+
t.bridge.dispose()
368+
}
369+
})
370+
})
371+
329372
test("aborts and flashes once a mid-stream hang crosses the stall timeout", async () => {
330373
await withTestRenderer(async (h) => {
331374
const t: Harness = await setup(h)

0 commit comments

Comments
 (0)