@@ -73,6 +73,7 @@ import type { StreamRow } from "./stream.js"
7373import { advanceRevealChars , flattenReasoningText , type Thought } from "./thinking.js"
7474import {
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 : [ ] ,
@@ -878,6 +936,7 @@ export function attachSessionBridge(
878936 if ( bag . openRow !== null && bag . openRow . kind === "thinking" ) {
879937 advanceOpenReveal ( shell , bag . openRow , nowMs )
880938 }
939+ syncToolElapsed ( shell , bag , nowMs )
881940 const input = {
882941 isProcessing : turn . isProcessing ,
883942 status : turn . status ,
@@ -1162,6 +1221,11 @@ export function attachSessionBridge(
11621221 const level = stallLevel ( stallArgs )
11631222 if ( level === "notice" ) {
11641223 setStatusFlash ( shell , STALL_NOTICE_MESSAGE )
1224+ } else if ( shell . statusFlash === STALL_NOTICE_MESSAGE ) {
1225+ // Activity resumed after the notice was posted: it carries no ttl (it
1226+ // must stay up for as long as the silence lasts), so nothing else would
1227+ // ever take it down once the run starts producing again.
1228+ setStatusFlash ( shell , null )
11651229 }
11661230
11671231 // Same "is this stalled at all" question `paintPhase` asks above — call
0 commit comments