11import { truncateToWidth , visibleWidth , type Component } from '@earendil-works/pi-tui' ;
22
33import {
4- BRAILLE_SPINNER_FRAMES ,
54 BRAILLE_SPINNER_INTERVAL_MS ,
65 DYNAMIC_WORKFLOW_RENDERING ,
76} from '#/tui/constant/rendering' ;
@@ -55,16 +54,6 @@ export interface DynamicWorkflowMember {
5554 statusDetail ?: string ;
5655 startedAtMs ?: number ;
5756 endedAtMs ?: number ;
58- /**
59- * Tool calls observed for this agent. Real work done, monotonic — unlike a
60- * percentage, which would need a total nobody can know in advance.
61- */
62- toolCalls : number ;
63- /**
64- * When this agent last produced any observed event. Its age is the liveness
65- * signal: a working agent stays near zero, a wedged one climbs without bound.
66- */
67- lastEventAtMs : number ;
6857}
6958
7059export interface DynamicWorkflowActivity {
@@ -109,16 +98,27 @@ export interface DynamicWorkflowMissionControlOptions {
10998 readonly availableRows ?: ( ) => number | undefined ;
11099}
111100
112- const PHASE_TOKENS : Record < DynamicWorkflowPhase , string > = {
113- pending : '◌ PEND' ,
114- queued : '◌ WAIT' ,
115- // Label only: a running row is the one phase that animates, so its symbol is
116- // a spinner supplied per frame by renderPhaseCell rather than a fixed glyph.
101+ const DYNAMIC_WORKFLOW_PROGRESS_FRAMES = [ '○' , '◔' , '◑' , '◕' ] as const ;
102+ const DYNAMIC_WORKFLOW_PROGRESS_FRAME_MS = BRAILLE_SPINNER_INTERVAL_MS * 2 ;
103+ const STATE_COLUMN_WIDTH = 6 ;
104+
105+ const PHASE_LABELS : Record < DynamicWorkflowPhase , string > = {
106+ pending : 'PEND' ,
107+ queued : 'WAIT' ,
117108 running : 'RUN' ,
118- suspended : '! HOLD' ,
119- completed : '✓ DONE' ,
120- failed : '× FAIL' ,
121- cancelled : '– STOP' ,
109+ suspended : 'HOLD' ,
110+ completed : 'DONE' ,
111+ failed : 'FAIL' ,
112+ cancelled : 'STOP' ,
113+ } ;
114+
115+ const PHASE_GLYPHS : Record < Exclude < DynamicWorkflowPhase , 'running' > , string > = {
116+ pending : '○' ,
117+ queued : '○' ,
118+ suspended : '◑' ,
119+ completed : '●' ,
120+ failed : '×' ,
121+ cancelled : '–' ,
122122} ;
123123
124124const PHASE_COLORS : Record < DynamicWorkflowPhase , 'textMuted' | 'primary' | 'success' | 'warning' | 'error' > = {
@@ -257,7 +257,6 @@ export class DynamicWorkflowMissionControlComponent implements Component {
257257 if ( member . phase === 'running' ) return ;
258258 member . phase = 'running' ;
259259 member . startedAtMs ??= Date . now ( ) ;
260- member . lastEventAtMs = Date . now ( ) ;
261260 delete member . statusDetail ;
262261 this . recordActivity ( member . index , 'Started' ) ;
263262 }
@@ -269,8 +268,6 @@ export class DynamicWorkflowMissionControlComponent implements Component {
269268 const member = this . findMemberByAgentId ( input . agentId ) ;
270269 if ( member === undefined || isTerminalPhase ( member . phase ) ) return ;
271270 this . markStarted ( input . agentId ) ;
272- member . toolCalls += 1 ;
273- member . lastEventAtMs = Date . now ( ) ;
274271 const latest = input . name === undefined ? 'Using a tool' : `Using ${ input . name } ` ;
275272 this . setLatest ( member , latest , true ) ;
276273 // Streamed text that follows starts a new line, never continues this label.
@@ -281,7 +278,6 @@ export class DynamicWorkflowMissionControlComponent implements Component {
281278 const member = this . findMemberByAgentId ( input . agentId ) ;
282279 if ( member === undefined || isTerminalPhase ( member . phase ) || input . delta . length === 0 ) return ;
283280 this . markStarted ( input . agentId ) ;
284- member . lastEventAtMs = Date . now ( ) ;
285281 const combined = `${ member . carry } ${ input . delta } ` ;
286282 // Only the text after the last newline is still being written. A delta that
287283 // ends exactly at a newline leaves nothing pending, so carrying the closed
@@ -450,7 +446,9 @@ export class DynamicWorkflowMissionControlComponent implements Component {
450446 }
451447
452448 if ( members . length > 0 && rowBudget - lines . length >= 2 ) {
453- lines . push ( this . renderTableHeader ( width ) ) ;
449+ if ( width >= DYNAMIC_WORKFLOW_RENDERING . frameMinWidth ) {
450+ lines . push ( this . renderTableHeader ( width ) ) ;
451+ }
454452 const slots = rowBudget - lines . length ;
455453 const needsMore = members . length > slots ;
456454 const memberSlots = needsMore && slots >= 2 ? slots - 1 : slots ;
@@ -577,11 +575,11 @@ export class DynamicWorkflowMissionControlComponent implements Component {
577575 const header = width >= DYNAMIC_WORKFLOW_RENDERING . memberProgressMinWidth
578576 ? [
579577 padToWidth ( 'ID' , 3 ) ,
580- padToWidth ( 'WORK IDLE ' , DYNAMIC_WORKFLOW_RENDERING . memberProgressWidth ) ,
581- padToWidth ( 'STATE' , 6 ) ,
578+ padToWidth ( 'PROGRESS ' , DYNAMIC_WORKFLOW_RENDERING . memberProgressWidth ) ,
579+ padToWidth ( 'STATE' , STATE_COLUMN_WIDTH ) ,
582580 'TASK' ,
583581 ] . join ( ' ' )
584- : `${ padToWidth ( 'ID' , 3 ) } ${ padToWidth ( 'STATE ' , 6 ) } TASK` ;
582+ : `${ padToWidth ( 'ID' , 3 ) } ${ padToWidth ( 'STATUS ' , STATE_COLUMN_WIDTH ) } TASK` ;
585583 return truncateToWidth ( currentTheme . fg ( 'textDim' , header ) , width ) ;
586584 }
587585
@@ -594,19 +592,19 @@ export class DynamicWorkflowMissionControlComponent implements Component {
594592 const id = currentTheme . fg ( 'primary' , String ( member . index ) . padStart ( 3 , '0' ) ) ;
595593 // All running rows share the workflow's clock, so they spin in step instead
596594 // of drifting apart by whenever each agent happened to start.
597- const state = renderPhaseCell (
598- member . phase ,
599- Math . floor ( Math . max ( 0 , nowMs - this . model . startedAtMs ) / BRAILLE_SPINNER_INTERVAL_MS ) ,
595+ const frame = Math . floor (
596+ Math . max ( 0 , nowMs - this . model . startedAtMs ) / DYNAMIC_WORKFLOW_PROGRESS_FRAME_MS ,
600597 ) ;
601- const showWork = width >= DYNAMIC_WORKFLOW_RENDERING . memberProgressMinWidth ;
602- const workColumn = padToWidth (
603- renderWorkCell ( member , nowMs ) ,
598+ const showProgress = width >= DYNAMIC_WORKFLOW_RENDERING . memberProgressMinWidth ;
599+ const progressColumn = centerToWidth (
600+ renderProgressGlyph ( member . phase , frame ) ,
604601 DYNAMIC_WORKFLOW_RENDERING . memberProgressWidth ,
605602 ) ;
606- const stateColumn = padToWidth ( state , 6 ) ;
607- const prefix = showWork
608- ? `${ id } ${ workColumn } ${ stateColumn } `
609- : `${ id } ${ padToWidth ( state , 6 ) } ` ;
603+ const stateColumn = padToWidth ( renderStateLabel ( member . phase ) , STATE_COLUMN_WIDTH ) ;
604+ const compactStatus = padToWidth ( renderCompactStatus ( member . phase , frame ) , STATE_COLUMN_WIDTH ) ;
605+ const prefix = showProgress
606+ ? `${ id } ${ progressColumn } ${ stateColumn } `
607+ : `${ id } ${ compactStatus } ` ;
610608 const task = member . item || 'Delegated agent' ;
611609 // The elision is display-only: the dedup below still compares whole items,
612610 // so a streamed line that merely repeats the task is still suppressed.
@@ -624,7 +622,7 @@ export class DynamicWorkflowMissionControlComponent implements Component {
624622
625623 // The elapsed cell is short and fixed, so it is reserved first — but only
626624 // while the task still keeps its floor.
627- const elapsedPart = showWork && elapsed !== undefined
625+ const elapsedPart = showProgress && elapsed !== undefined
628626 ? `${ MEMBER_SEPARATOR } ${ currentTheme . fg ( 'textMuted' , elapsed ) } `
629627 : '' ;
630628 const elapsedWidth = visibleWidth ( elapsedPart ) ;
@@ -640,7 +638,7 @@ export class DynamicWorkflowMissionControlComponent implements Component {
640638 DYNAMIC_WORKFLOW_RENDERING . memberTaskMinWidth ,
641639 Math . floor ( rest * DYNAMIC_WORKFLOW_RENDERING . memberTaskShare ) ,
642640 ) ;
643- const detailBudget = showWork && detail !== undefined && detail . length > 0
641+ const detailBudget = showProgress && detail !== undefined && detail . length > 0
644642 ? rest - Math . min ( visibleWidth ( shownTask ) , taskCap ) - MEMBER_SEPARATOR . length
645643 : 0 ;
646644 const detailPart = detailBudget >= DYNAMIC_WORKFLOW_RENDERING . memberDetailMinWidth
@@ -721,8 +719,6 @@ export class DynamicWorkflowMissionControlComponent implements Component {
721719 phase : this . model . inputComplete ? 'queued' : 'pending' ,
722720 latest : '' ,
723721 carry : '' ,
724- toolCalls : 0 ,
725- lastEventAtMs : Date . now ( ) ,
726722 } ) ;
727723 }
728724 }
@@ -747,7 +743,6 @@ export class DynamicWorkflowMissionControlComponent implements Component {
747743 const normalizedDetail = normalizeText ( detail ) ;
748744 member . phase = phase ;
749745 member . endedAtMs = Date . now ( ) ;
750- member . lastEventAtMs = Date . now ( ) ;
751746 member . statusDetail = normalizedDetail . length > 0 ? normalizedDetail : undefined ;
752747 const label = phase === 'completed' ? 'Completed' : phase === 'failed' ? 'Failed' : 'Cancelled' ;
753748 this . recordActivity ( member . index , normalizedDetail . length > 0 ? `${ label } : ${ normalizedDetail } ` : label ) ;
@@ -1132,61 +1127,26 @@ function commonPrefixLength(left: string, right: string, limit: number): number
11321127 return index ;
11331128}
11341129
1135- /**
1136- * The WORK cell: tool calls done, and how long this agent has been silent.
1137- *
1138- * There is deliberately no percentage. Nothing knows how many steps an agent
1139- * will take, so any percent is invented — the old one pinned every tool-using
1140- * agent at 75% until it finished, which made a wedged agent look identical to a
1141- * busy one. A count and an idle age are both real and answer the actual
1142- * question: is this thing still working?
1143- */
1144- function renderWorkCell ( member : DynamicWorkflowMember , nowMs : number ) : string {
1145- const tools = currentTheme . fg ( 'textDim' , `${ String ( member . toolCalls ) . padStart ( 3 , ' ' ) } ⚒` ) ;
1146- // A row that has not started has no silence to measure: its clock would run
1147- // from the launch of the whole workflow, so a queue that is simply long would
1148- // paint every waiting row red. Only a finished row and an unstarted one share
1149- // the placeholder; the reason differs, but neither has an idle age.
1150- if ( isTerminalPhase ( member . phase ) || member . phase === 'pending' || member . phase === 'queued' ) {
1151- return `${ tools } ${ currentTheme . fg ( 'textMuted' , ' –' ) } ` ;
1152- }
1153- const idleMs = Math . max ( 0 , nowMs - member . lastEventAtMs ) ;
1154- const idleSeconds = Math . floor ( idleMs / 1000 ) ;
1155- const token = idleColor ( member . phase , idleMs ) ;
1156- return `${ tools } ${ currentTheme . fg ( token , `${ String ( idleSeconds ) } s` . padStart ( 4 , ' ' ) ) } ` ;
1130+ function renderProgressGlyph ( phase : DynamicWorkflowPhase , frame : number ) : string {
1131+ const glyph = phase === 'running'
1132+ ? DYNAMIC_WORKFLOW_PROGRESS_FRAMES [ frame % DYNAMIC_WORKFLOW_PROGRESS_FRAMES . length ] ??
1133+ DYNAMIC_WORKFLOW_PROGRESS_FRAMES [ 0 ]
1134+ : PHASE_GLYPHS [ phase ] ;
1135+ return currentTheme . fg ( PHASE_COLORS [ phase ] , glyph ) ;
11571136}
11581137
1159- /**
1160- * How loud an idle age reads.
1161- *
1162- * Only a running row can stall. A suspended one is waiting on the user by
1163- * design, so it keeps the count without the alarm colours.
1164- */
1165- function idleColor (
1166- phase : DynamicWorkflowPhase ,
1167- idleMs : number ,
1168- ) : 'textMuted' | 'warning' | 'error' {
1169- if ( phase === 'running' ) {
1170- if ( idleMs >= DYNAMIC_WORKFLOW_RENDERING . stalledIdleMs ) return 'error' ;
1171- if ( idleMs >= DYNAMIC_WORKFLOW_RENDERING . quietIdleMs ) return 'warning' ;
1172- }
1173- return 'textMuted' ;
1138+ function renderStateLabel ( phase : DynamicWorkflowPhase ) : string {
1139+ return currentTheme . fg ( PHASE_COLORS [ phase ] , PHASE_LABELS [ phase ] ) ;
11741140}
11751141
1176- /**
1177- * The STATE cell for one row.
1178- *
1179- * Every phase but `running` is a fixed symbol plus its label. A running row
1180- * spins a dim grey braille dot instead, so "this agent is working" reads as
1181- * motion rather than as another coloured dot competing with the periwinkle the
1182- * panel already uses for identity.
1183- */
1184- function renderPhaseCell ( phase : DynamicWorkflowPhase , frame : number ) : string {
1185- const label = currentTheme . fg ( PHASE_COLORS [ phase ] , PHASE_TOKENS [ phase ] ) ;
1186- if ( phase !== 'running' ) return label ;
1187- const spinner =
1188- BRAILLE_SPINNER_FRAMES [ frame % BRAILLE_SPINNER_FRAMES . length ] ?? BRAILLE_SPINNER_FRAMES [ 0 ] ?? '' ;
1189- return `${ currentTheme . fg ( 'textDim' , spinner ) } ${ label } ` ;
1142+ function renderCompactStatus ( phase : DynamicWorkflowPhase , frame : number ) : string {
1143+ return `${ renderProgressGlyph ( phase , frame ) } ${ renderStateLabel ( phase ) } ` ;
1144+ }
1145+
1146+ function centerToWidth ( text : string , width : number ) : string {
1147+ const paddingWidth = Math . max ( 0 , width - visibleWidth ( text ) ) ;
1148+ const left = Math . floor ( paddingWidth / 2 ) ;
1149+ return `${ ' ' . repeat ( left ) } ${ text } ${ ' ' . repeat ( paddingWidth - left ) } ` ;
11901150}
11911151
11921152function padToWidth ( text : string , width : number ) : string {
0 commit comments