@@ -48,12 +48,15 @@ export interface DynamicWorkflowMember {
4848 startedAtMs ?: number ;
4949 endedAtMs ?: number ;
5050 /**
51- * Observed-stage progress heuristic (0-100): the protocol emits no per-task
52- * percentage, so stage floors map to observed events and streamed deltas
53- * creep asymptotically toward a ceiling. May hold fractional values
54- * internally; display floors it. Only a terminal event reaches 100.
51+ * Tool calls observed for this agent. Real work done, monotonic — unlike a
52+ * percentage, which would need a total nobody can know in advance.
5553 */
56- progressPercent : number ;
54+ toolCalls : number ;
55+ /**
56+ * When this agent last produced any observed event. Its age is the liveness
57+ * signal: a working agent stays near zero, a wedged one climbs without bound.
58+ */
59+ lastEventAtMs : number ;
5760}
5861
5962export interface DynamicWorkflowActivity {
@@ -246,7 +249,7 @@ export class DynamicWorkflowMissionControlComponent implements Component {
246249 if ( member . phase === 'running' ) return ;
247250 member . phase = 'running' ;
248251 member . startedAtMs ??= Date . now ( ) ;
249- this . advanceMemberProgress ( member , DYNAMIC_WORKFLOW_RENDERING . startedProgress ) ;
252+ member . lastEventAtMs = Date . now ( ) ;
250253 delete member . statusDetail ;
251254 this . recordActivity ( member . index , 'Started' ) ;
252255 }
@@ -258,7 +261,8 @@ export class DynamicWorkflowMissionControlComponent implements Component {
258261 const member = this . findMemberByAgentId ( input . agentId ) ;
259262 if ( member === undefined || isTerminalPhase ( member . phase ) ) return ;
260263 this . markStarted ( input . agentId ) ;
261- this . advanceMemberProgress ( member , DYNAMIC_WORKFLOW_RENDERING . toolActivityProgress ) ;
264+ member . toolCalls += 1 ;
265+ member . lastEventAtMs = Date . now ( ) ;
262266 const latest = input . name === undefined ? 'Using a tool' : `Using ${ input . name } ` ;
263267 this . setLatest ( member , latest , true ) ;
264268 // Streamed text that follows starts a new line, never continues this label.
@@ -270,10 +274,7 @@ export class DynamicWorkflowMissionControlComponent implements Component {
270274 if ( member === undefined || isTerminalPhase ( member . phase ) || input . delta . length === 0 ) return ;
271275 this . markStarted ( input . agentId ) ;
272276 const recordActivity = input . delta . includes ( '\n' ) || member . latest . length === 0 ;
273- // Progress reflects the observed stage only. The protocol emits no per-task
274- // completion signal, so streamed text never advances past its stage floor —
275- // elapsed time and the latest line carry liveness instead.
276- this . advanceMemberProgress ( member , DYNAMIC_WORKFLOW_RENDERING . modelActivityProgress ) ;
277+ member . lastEventAtMs = Date . now ( ) ;
277278 const carried = member . latestFromTool === true ? '' : member . latest ;
278279 const latest = latestNonEmptyLine ( `${ carried } ${ input . delta } ` ) ;
279280 member . latestFromTool = false ;
@@ -547,7 +548,7 @@ export class DynamicWorkflowMissionControlComponent implements Component {
547548 const header = width >= DYNAMIC_WORKFLOW_RENDERING . memberProgressMinWidth
548549 ? [
549550 padToWidth ( 'ID' , 3 ) ,
550- padToWidth ( 'PROGRESS ' , DYNAMIC_WORKFLOW_RENDERING . memberProgressWidth ) ,
551+ padToWidth ( 'WORK IDLE ' , DYNAMIC_WORKFLOW_RENDERING . memberProgressWidth ) ,
551552 padToWidth ( 'STATE' , 6 ) ,
552553 'TASK' ,
553554 ] . join ( ' ' )
@@ -563,19 +564,14 @@ export class DynamicWorkflowMissionControlComponent implements Component {
563564 member . phase ,
564565 Math . floor ( Math . max ( 0 , nowMs - this . model . startedAtMs ) / BRAILLE_SPINNER_INTERVAL_MS ) ,
565566 ) ;
566- const progressPercent = member . progressPercent ;
567- const showProgress = width >= DYNAMIC_WORKFLOW_RENDERING . memberProgressMinWidth ;
568- const progress = `${ renderProgressCube ( progressPercent ) } ${ currentTheme . fg (
569- 'textMuted' ,
570- `${ String ( Math . floor ( progressPercent ) ) . padStart ( 3 , ' ' ) } %` ,
571- ) } `;
572- const progressColumn = padToWidth (
573- progress ,
567+ const showWork = width >= DYNAMIC_WORKFLOW_RENDERING . memberProgressMinWidth ;
568+ const workColumn = padToWidth (
569+ renderWorkCell ( member , nowMs ) ,
574570 DYNAMIC_WORKFLOW_RENDERING . memberProgressWidth ,
575571 ) ;
576572 const stateColumn = padToWidth ( state , 6 ) ;
577- const prefix = showProgress
578- ? `${ id } ${ progressColumn } ${ stateColumn } `
573+ const prefix = showWork
574+ ? `${ id } ${ workColumn } ${ stateColumn } `
579575 : `${ id } ${ padToWidth ( state , 6 ) } ` ;
580576 const task = member . item || 'Delegated agent' ;
581577 const latest = member . latest . length > 0 && member . latest !== task ? member . latest : undefined ;
@@ -585,8 +581,8 @@ export class DynamicWorkflowMissionControlComponent implements Component {
585581 const elapsed = member . startedAtMs === undefined
586582 ? undefined
587583 : `${ String ( elapsedSeconds ( member . startedAtMs , member . endedAtMs ?? nowMs ) ) } s` ;
588- const showDetail = showProgress && detail !== undefined && detail . length > 0 ;
589- const showElapsed = showProgress && elapsed !== undefined ;
584+ const showDetail = showWork && detail !== undefined && detail . length > 0 ;
585+ const showElapsed = showWork && elapsed !== undefined ;
590586 const tail = [
591587 showDetail ? currentTheme . fg ( 'textDim' , detail ) : '' ,
592588 showElapsed ? currentTheme . fg ( 'textMuted' , elapsed ) : '' ,
@@ -659,7 +655,8 @@ export class DynamicWorkflowMissionControlComponent implements Component {
659655 item : '' ,
660656 phase : this . model . inputComplete ? 'queued' : 'pending' ,
661657 latest : '' ,
662- progressPercent : 0 ,
658+ toolCalls : 0 ,
659+ lastEventAtMs : Date . now ( ) ,
663660 } ) ;
664661 }
665662 }
@@ -684,20 +681,12 @@ export class DynamicWorkflowMissionControlComponent implements Component {
684681 const normalizedDetail = normalizeText ( detail ) ;
685682 member . phase = phase ;
686683 member . endedAtMs = Date . now ( ) ;
687- member . progressPercent = 100 ;
684+ member . lastEventAtMs = Date . now ( ) ;
688685 member . statusDetail = normalizedDetail . length > 0 ? normalizedDetail : undefined ;
689686 const label = phase === 'completed' ? 'Completed' : phase === 'failed' ? 'Failed' : 'Cancelled' ;
690687 this . recordActivity ( member . index , normalizedDetail . length > 0 ? `${ label } : ${ normalizedDetail } ` : label ) ;
691688 }
692689
693- /**
694- * Progress only ever advances; stages map to observed events, never to time.
695- * Creep is per observed event too (each streamed delta), so no timers exist.
696- */
697- private advanceMemberProgress ( member : DynamicWorkflowMember , targetPercent : number ) : void {
698- member . progressPercent = Math . max ( member . progressPercent , targetPercent ) ;
699- }
700-
701690 private setLatest ( member : DynamicWorkflowMember , latest : string , recordActivity : boolean ) : void {
702691 const normalized = normalizeText ( latest ) ;
703692 if ( normalized . length === 0 || member . latest === normalized ) return ;
@@ -972,23 +961,6 @@ function decodeXmlEntities(value: string): string {
972961}
973962
974963/** Maps a percent to one of the dotted cube levels; the cube fills bottom-up. */
975- function renderProgressCube ( percent : number ) : string {
976- const bounded = Math . min ( 100 , Math . max ( 0 , percent ) ) ;
977- const levels = DYNAMIC_WORKFLOW_RENDERING . cubeFillLevels ;
978- const level = bounded <= 0
979- ? 0
980- : bounded >= 100
981- ? levels . length - 1
982- : Math . min (
983- levels . length - 2 ,
984- Math . ceil ( bounded * ( levels . length - 2 ) / 100 ) ,
985- ) ;
986- const fill = levels [ level ] ! ;
987- const fillToken = bounded >= 100 ? 'progressHead' : 'progressFill' ;
988- return currentTheme . fg ( 'progressEmpty' , '▏' ) +
989- currentTheme . fg ( fillToken , fill . repeat ( 2 ) ) +
990- currentTheme . fg ( 'progressEmpty' , '▕' ) ;
991- }
992964
993965function requestPhaseLabel ( phase : DynamicWorkflowRequestPhase ) : string {
994966 const labels : Record < DynamicWorkflowRequestPhase , string > = {
@@ -1047,6 +1019,30 @@ function normalizeText(text: string | undefined): string {
10471019 return text ?. replaceAll ( / \s + / g, ' ' ) . trim ( ) ?? '' ;
10481020}
10491021
1022+ /**
1023+ * The WORK cell: tool calls done, and how long this agent has been silent.
1024+ *
1025+ * There is deliberately no percentage. Nothing knows how many steps an agent
1026+ * will take, so any percent is invented — the old one pinned every tool-using
1027+ * agent at 75% until it finished, which made a wedged agent look identical to a
1028+ * busy one. A count and an idle age are both real and answer the actual
1029+ * question: is this thing still working?
1030+ */
1031+ function renderWorkCell ( member : DynamicWorkflowMember , nowMs : number ) : string {
1032+ const tools = currentTheme . fg ( 'textDim' , `${ String ( member . toolCalls ) . padStart ( 3 , ' ' ) } ⚒` ) ;
1033+ if ( isTerminalPhase ( member . phase ) ) {
1034+ return `${ tools } ${ currentTheme . fg ( 'textMuted' , ' –' ) } ` ;
1035+ }
1036+ const idleMs = Math . max ( 0 , nowMs - member . lastEventAtMs ) ;
1037+ const idleSeconds = Math . floor ( idleMs / 1000 ) ;
1038+ const token = idleMs >= DYNAMIC_WORKFLOW_RENDERING . stalledIdleMs
1039+ ? 'error'
1040+ : idleMs >= DYNAMIC_WORKFLOW_RENDERING . quietIdleMs
1041+ ? 'warning'
1042+ : 'textMuted' ;
1043+ return `${ tools } ${ currentTheme . fg ( token , `${ String ( idleSeconds ) } s` . padStart ( 4 , ' ' ) ) } ` ;
1044+ }
1045+
10501046/**
10511047 * The STATE cell for one row.
10521048 *
0 commit comments