Skip to content

Commit ef9b53d

Browse files
committed
feat(tui): show dynamic workflow progress rings
1 parent 97afb85 commit ef9b53d

3 files changed

Lines changed: 157 additions & 264 deletions

File tree

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

Lines changed: 54 additions & 94 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
import { truncateToWidth, visibleWidth, type Component } from '@earendil-works/pi-tui';
22

33
import {
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

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

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

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

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

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ export const DYNAMIC_WORKFLOW_RENDERING = {
2727
frameMinWidth: 21,
2828
frameHorizontalInset: 4,
2929
memberProgressMinWidth: 60,
30-
memberProgressWidth: 9,
30+
memberProgressWidth: 8,
3131
/** Least room the task keeps before the detail may claim any of the row. */
3232
memberTaskMinWidth: 12,
3333
/** Share of the free row the task may take before the detail gets the rest. */
@@ -46,10 +46,6 @@ export const DYNAMIC_WORKFLOW_RENDERING = {
4646
* buffered text from growing for as long as the agent runs.
4747
*/
4848
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,
5349
} as const;
5450

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

0 commit comments

Comments
 (0)