11import { truncateToWidth , visibleWidth , type Component } from '@earendil-works/pi-tui' ;
22
3- import {
4- BRAILLE_SPINNER_FRAMES ,
5- BRAILLE_SPINNER_INTERVAL_MS ,
6- DYNAMIC_WORKFLOW_RENDERING ,
7- } from '#/tui/constant/rendering' ;
3+ import { DYNAMIC_WORKFLOW_RENDERING } from '#/tui/constant/rendering' ;
84import { currentTheme } from '#/tui/theme' ;
95import { shimmerText } from '#/tui/utils/shimmer' ;
106
@@ -55,16 +51,6 @@ export interface DynamicWorkflowMember {
5551 statusDetail ?: string ;
5652 startedAtMs ?: number ;
5753 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 ;
6854}
6955
7056export interface DynamicWorkflowActivity {
@@ -109,16 +95,23 @@ export interface DynamicWorkflowMissionControlOptions {
10995 readonly availableRows ?: ( ) => number | undefined ;
11096}
11197
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.
98+ const PHASE_LABELS : Record < DynamicWorkflowPhase , string > = {
99+ pending : 'PEND' ,
100+ queued : 'WAIT' ,
117101 running : 'RUN' ,
118- suspended : '! HOLD' ,
119- completed : '✓ DONE' ,
120- failed : '× FAIL' ,
121- cancelled : '– STOP' ,
102+ suspended : 'HOLD' ,
103+ completed : 'DONE' ,
104+ failed : 'FAIL' ,
105+ cancelled : 'STOP' ,
106+ } ;
107+
108+ const PHASE_GLYPHS : Record < Exclude < DynamicWorkflowPhase , 'running' > , string > = {
109+ pending : '○' ,
110+ queued : '○' ,
111+ suspended : '◑' ,
112+ completed : '✓' ,
113+ failed : '×' ,
114+ cancelled : '–' ,
122115} ;
123116
124117const PHASE_COLORS : Record < DynamicWorkflowPhase , 'textMuted' | 'primary' | 'success' | 'warning' | 'error' > = {
@@ -257,7 +250,6 @@ export class DynamicWorkflowMissionControlComponent implements Component {
257250 if ( member . phase === 'running' ) return ;
258251 member . phase = 'running' ;
259252 member . startedAtMs ??= Date . now ( ) ;
260- member . lastEventAtMs = Date . now ( ) ;
261253 delete member . statusDetail ;
262254 this . recordActivity ( member . index , 'Started' ) ;
263255 }
@@ -268,9 +260,7 @@ export class DynamicWorkflowMissionControlComponent implements Component {
268260 } ) : void {
269261 const member = this . findMemberByAgentId ( input . agentId ) ;
270262 if ( member === undefined || isTerminalPhase ( member . phase ) ) return ;
271- this . markStarted ( input . agentId ) ;
272- member . toolCalls += 1 ;
273- member . lastEventAtMs = Date . now ( ) ;
263+ if ( member . phase === 'pending' || member . phase === 'queued' ) this . markStarted ( input . agentId ) ;
274264 const latest = input . name === undefined ? 'Using a tool' : `Using ${ input . name } ` ;
275265 this . setLatest ( member , latest , true ) ;
276266 // Streamed text that follows starts a new line, never continues this label.
@@ -280,8 +270,7 @@ export class DynamicWorkflowMissionControlComponent implements Component {
280270 appendModelDelta ( input : { readonly agentId : string ; readonly delta : string } ) : void {
281271 const member = this . findMemberByAgentId ( input . agentId ) ;
282272 if ( member === undefined || isTerminalPhase ( member . phase ) || input . delta . length === 0 ) return ;
283- this . markStarted ( input . agentId ) ;
284- member . lastEventAtMs = Date . now ( ) ;
273+ if ( member . phase === 'pending' || member . phase === 'queued' ) this . markStarted ( input . agentId ) ;
285274 const combined = `${ member . carry } ${ input . delta } ` ;
286275 // Only the text after the last newline is still being written. A delta that
287276 // ends exactly at a newline leaves nothing pending, so carrying the closed
@@ -450,7 +439,9 @@ export class DynamicWorkflowMissionControlComponent implements Component {
450439 }
451440
452441 if ( members . length > 0 && rowBudget - lines . length >= 2 ) {
453- lines . push ( this . renderTableHeader ( width ) ) ;
442+ if ( width >= DYNAMIC_WORKFLOW_RENDERING . frameMinWidth ) {
443+ lines . push ( this . renderTableHeader ( width ) ) ;
444+ }
454445 const slots = rowBudget - lines . length ;
455446 const needsMore = members . length > slots ;
456447 const memberSlots = needsMore && slots >= 2 ? slots - 1 : slots ;
@@ -539,7 +530,8 @@ export class DynamicWorkflowMissionControlComponent implements Component {
539530 baseToken : 'primary' ,
540531 shimmerToken : 'primaryShimmer' ,
541532 frame : Math . floor (
542- Math . max ( 0 , nowMs - this . model . startedAtMs ) / BRAILLE_SPINNER_INTERVAL_MS ,
533+ Math . max ( 0 , nowMs - this . model . startedAtMs ) /
534+ DYNAMIC_WORKFLOW_RENDERING . aggregateShimmerFrameMs ,
543535 ) ,
544536 windowSize : 4 ,
545537 } ) ;
@@ -577,11 +569,11 @@ export class DynamicWorkflowMissionControlComponent implements Component {
577569 const header = width >= DYNAMIC_WORKFLOW_RENDERING . memberProgressMinWidth
578570 ? [
579571 padToWidth ( 'ID' , 3 ) ,
580- padToWidth ( 'WORK IDLE ' , DYNAMIC_WORKFLOW_RENDERING . memberProgressWidth ) ,
581- padToWidth ( 'STATE' , 6 ) ,
572+ padToWidth ( 'PROGRESS ' , DYNAMIC_WORKFLOW_RENDERING . memberProgressWidth ) ,
573+ padToWidth ( 'STATE' , DYNAMIC_WORKFLOW_RENDERING . stateColumnWidth ) ,
582574 'TASK' ,
583575 ] . join ( ' ' )
584- : `${ padToWidth ( 'ID' , 3 ) } ${ padToWidth ( 'STATE ' , 6 ) } TASK` ;
576+ : `${ padToWidth ( 'ID' , 3 ) } ${ padToWidth ( 'STATUS ' , DYNAMIC_WORKFLOW_RENDERING . stateColumnWidth ) } TASK` ;
585577 return truncateToWidth ( currentTheme . fg ( 'textDim' , header ) , width ) ;
586578 }
587579
@@ -594,19 +586,18 @@ export class DynamicWorkflowMissionControlComponent implements Component {
594586 const id = currentTheme . fg ( 'primary' , String ( member . index ) . padStart ( 3 , '0' ) ) ;
595587 // All running rows share the workflow's clock, so they spin in step instead
596588 // 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 ) ,
600- ) ;
601- const showWork = width >= DYNAMIC_WORKFLOW_RENDERING . memberProgressMinWidth ;
602- const workColumn = padToWidth (
603- renderWorkCell ( member , nowMs ) ,
604- DYNAMIC_WORKFLOW_RENDERING . memberProgressWidth ,
589+ const frame = Math . floor (
590+ Math . max ( 0 , nowMs - this . model . startedAtMs ) / DYNAMIC_WORKFLOW_RENDERING . progressFrameMs ,
605591 ) ;
606- const stateColumn = padToWidth ( state , 6 ) ;
607- const prefix = showWork
608- ? `${ id } ${ workColumn } ${ stateColumn } `
609- : `${ id } ${ padToWidth ( state , 6 ) } ` ;
592+ const showProgress = width >= DYNAMIC_WORKFLOW_RENDERING . memberProgressMinWidth ;
593+ const prefix = showProgress
594+ ? `${ id } ${
595+ centerToWidth (
596+ renderProgressGlyph ( member . phase , frame ) ,
597+ DYNAMIC_WORKFLOW_RENDERING . memberProgressWidth ,
598+ )
599+ } ${ padToWidth ( renderStateLabel ( member . phase ) , DYNAMIC_WORKFLOW_RENDERING . stateColumnWidth ) } `
600+ : `${ id } ${ padToWidth ( renderCompactStatus ( member . phase , frame ) , DYNAMIC_WORKFLOW_RENDERING . stateColumnWidth ) } ` ;
610601 const task = member . item || 'Delegated agent' ;
611602 // The elision is display-only: the dedup below still compares whole items,
612603 // so a streamed line that merely repeats the task is still suppressed.
@@ -624,7 +615,7 @@ export class DynamicWorkflowMissionControlComponent implements Component {
624615
625616 // The elapsed cell is short and fixed, so it is reserved first — but only
626617 // while the task still keeps its floor.
627- const elapsedPart = showWork && elapsed !== undefined
618+ const elapsedPart = showProgress && elapsed !== undefined
628619 ? `${ MEMBER_SEPARATOR } ${ currentTheme . fg ( 'textMuted' , elapsed ) } `
629620 : '' ;
630621 const elapsedWidth = visibleWidth ( elapsedPart ) ;
@@ -640,7 +631,7 @@ export class DynamicWorkflowMissionControlComponent implements Component {
640631 DYNAMIC_WORKFLOW_RENDERING . memberTaskMinWidth ,
641632 Math . floor ( rest * DYNAMIC_WORKFLOW_RENDERING . memberTaskShare ) ,
642633 ) ;
643- const detailBudget = showWork && detail !== undefined && detail . length > 0
634+ const detailBudget = showProgress && detail !== undefined && detail . length > 0
644635 ? rest - Math . min ( visibleWidth ( shownTask ) , taskCap ) - MEMBER_SEPARATOR . length
645636 : 0 ;
646637 const detailPart = detailBudget >= DYNAMIC_WORKFLOW_RENDERING . memberDetailMinWidth
@@ -721,8 +712,6 @@ export class DynamicWorkflowMissionControlComponent implements Component {
721712 phase : this . model . inputComplete ? 'queued' : 'pending' ,
722713 latest : '' ,
723714 carry : '' ,
724- toolCalls : 0 ,
725- lastEventAtMs : Date . now ( ) ,
726715 } ) ;
727716 }
728717 }
@@ -747,7 +736,6 @@ export class DynamicWorkflowMissionControlComponent implements Component {
747736 const normalizedDetail = normalizeText ( detail ) ;
748737 member . phase = phase ;
749738 member . endedAtMs = Date . now ( ) ;
750- member . lastEventAtMs = Date . now ( ) ;
751739 member . statusDetail = normalizedDetail . length > 0 ? normalizedDetail : undefined ;
752740 const label = phase === 'completed' ? 'Completed' : phase === 'failed' ? 'Failed' : 'Cancelled' ;
753741 this . recordActivity ( member . index , normalizedDetail . length > 0 ? `${ label } : ${ normalizedDetail } ` : label ) ;
@@ -931,7 +919,8 @@ function parseDynamicWorkflowResultStatuses(output: string): DynamicWorkflowResu
931919 outcome === 'completed' ||
932920 outcome === 'failed' ||
933921 outcome === 'aborted' ||
934- outcome === 'cancelled'
922+ outcome === 'cancelled' ||
923+ outcome === 'schema_error'
935924 ) {
936925 // Omitted `index` falls back to the lowest free slot so unordered tags
937926 // still render in ascending row order.
@@ -953,7 +942,11 @@ function parseDynamicWorkflowResultStatuses(output: string): DynamicWorkflowResu
953942 index,
954943 agentId : xmlAttribute ( attrs , 'agent_id' ) ,
955944 item : xmlAttribute ( attrs , 'item' ) ,
956- status : outcome === 'aborted' || outcome === 'cancelled' ? 'cancelled' : outcome ,
945+ status : outcome === 'aborted' || outcome === 'cancelled'
946+ ? 'cancelled'
947+ : outcome === 'schema_error'
948+ ? 'failed'
949+ : outcome ,
957950 detail : normalizeText ( decodeXmlEntities ( body ) ) ,
958951 } ) ;
959952 }
@@ -1132,61 +1125,26 @@ function commonPrefixLength(left: string, right: string, limit: number): number
11321125 return index ;
11331126}
11341127
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 , ' ' ) ) } ` ;
1128+ function renderProgressGlyph ( phase : DynamicWorkflowPhase , frame : number ) : string {
1129+ const glyph = phase === 'running'
1130+ ? DYNAMIC_WORKFLOW_RENDERING . progressFrames [ frame % DYNAMIC_WORKFLOW_RENDERING . progressFrames . length ] ??
1131+ DYNAMIC_WORKFLOW_RENDERING . progressFrames [ 0 ]
1132+ : PHASE_GLYPHS [ phase ] ;
1133+ return currentTheme . fg ( PHASE_COLORS [ phase ] , glyph ) ;
11571134}
11581135
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' ;
1136+ function renderStateLabel ( phase : DynamicWorkflowPhase ) : string {
1137+ return currentTheme . fg ( PHASE_COLORS [ phase ] , PHASE_LABELS [ phase ] ) ;
11741138}
11751139
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 } ` ;
1140+ function renderCompactStatus ( phase : DynamicWorkflowPhase , frame : number ) : string {
1141+ return `${ renderProgressGlyph ( phase , frame ) } ${ renderStateLabel ( phase ) } ` ;
1142+ }
1143+
1144+ function centerToWidth ( text : string , width : number ) : string {
1145+ const paddingWidth = Math . max ( 0 , width - visibleWidth ( text ) ) ;
1146+ const left = Math . floor ( paddingWidth / 2 ) ;
1147+ return `${ ' ' . repeat ( left ) } ${ text } ${ ' ' . repeat ( paddingWidth - left ) } ` ;
11901148}
11911149
11921150function padToWidth ( text : string , width : number ) : string {
0 commit comments