Skip to content

Commit 1f45a5f

Browse files
authored
feat(tui): show Dynamic Workflow progress rings (#54)
## Related Issue No linked issue. ## Problem Dynamic Workflow rows showed tool-call counts and idle age under `WORK IDLE`. These labels looked like completion progress and worker state, but they represented only observed events. Long-running work could therefore look stalled. ## What changed - Replace `WORK IDLE` with an indeterminate circular progress glyph and a separate lifecycle `STATE` column. - Keep pending, running, suspended, completed, failed, cancelled, and schema-error outcomes distinct and freeze terminal rows. - Preserve responsive compact rendering, lifecycle ordering, result reconciliation, and width-safe task details. - Update Dynamic Workflow documentation and focused/integration coverage. ## Verification - `pnpm test`: 651 test files passed, 11 skipped; 9,951 tests passed, 71 skipped, 2 todo. - Mission Control component: 64/64 tests passed. - TUI message flow: 175/175 tests passed. - `pnpm --filter @pythoughts/pythinker-code run typecheck`: passed. - `pnpm exec tsgo -p apps/pythinker-code/tsconfig.json --noEmit`: passed. - Live TUI smoke: two-subagent Dynamic Workflow reached `2/2 complete`, with running progress frames and fixed `DONE` rows. ## Checklist - [x] I have read the [CONTRIBUTING](https://github.com/Pythoughts-labs/pythinker-code/blob/main/CONTRIBUTING.md) document. - [x] I have linked a related issue, or explained the problem above. - [x] I have added tests that prove my feature works. - [x] Ran `gen-changesets` skill, or this PR needs no changeset. - [x] Ran `gen-docs` skill, or this PR needs no doc update. <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit - **New Features** - Redesigned Dynamic Workflow progress display with animated progress indicators and clear lifecycle states. - Added responsive status columns that compact or hide details on narrow screens. - Completed workflows now show stable completion indicators. - **Bug Fixes** - Schema validation errors are now correctly reported as failed workflow outcomes. - Suspended workflows resume with accurate status updates. - **Documentation** - Updated Dynamic Workflow documentation to describe the new progress indicators, statuses, and responsive layout. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
1 parent 5dba3ba commit 1f45a5f

6 files changed

Lines changed: 240 additions & 288 deletions

File tree

.changeset/workflow-progress.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@pythoughts/pythinker-code": minor
3+
---
4+
5+
Show indeterminate lifecycle progress for Dynamic Workflow rows in the TUI, and report schema-error outcomes as failed.

apps/pythinker-code/src/tui/components/messages/dynamic-workflow-mission-control.ts

Lines changed: 63 additions & 105 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,6 @@
11
import { 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';
84
import { currentTheme } from '#/tui/theme';
95
import { 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

7056
export 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

124117
const 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

11921150
function padToWidth(text: string, width: number): string {

apps/pythinker-code/src/tui/constant/rendering.ts

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,15 @@ export const DYNAMIC_WORKFLOW_RENDERING = {
2727
frameMinWidth: 21,
2828
frameHorizontalInset: 4,
2929
memberProgressMinWidth: 60,
30-
memberProgressWidth: 9,
30+
memberProgressWidth: 8,
31+
/** Least width of the lifecycle STATE column in member rows. */
32+
stateColumnWidth: 6,
33+
/** Cadence for the live aggregate-label shimmer. */
34+
aggregateShimmerFrameMs: BRAILLE_SPINNER_INTERVAL_MS,
35+
/** Thin-arc frames for a running row; all rows share one clock. */
36+
progressFrames: ['◜', '◝', '◞', '◟'],
37+
/** Arc cadence in milliseconds. */
38+
progressFrameMs: 120,
3139
/** Least room the task keeps before the detail may claim any of the row. */
3240
memberTaskMinWidth: 12,
3341
/** Share of the free row the task may take before the detail gets the rest. */
@@ -46,10 +54,6 @@ export const DYNAMIC_WORKFLOW_RENDERING = {
4654
* buffered text from growing for as long as the agent runs.
4755
*/
4856
memberLatestMaxChars: 512,
49-
/** Idle age at which a row's silence is worth noticing. */
50-
quietIdleMs: 60_000,
51-
/** Idle age at which a row has almost certainly stalled. */
52-
stalledIdleMs: 180_000,
5357
} as const;
5458

5559
/** Live activity labels: one shown at a time, rotating on a fixed cadence. */

0 commit comments

Comments
 (0)