Skip to content

Commit d8b3888

Browse files
committed
fix(tui): refine workflow progress animation
1 parent c623567 commit d8b3888

5 files changed

Lines changed: 50 additions & 48 deletions

File tree

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

Lines changed: 8 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -98,10 +98,6 @@ export interface DynamicWorkflowMissionControlOptions {
9898
readonly availableRows?: () => number | undefined;
9999
}
100100

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-
105101
const PHASE_LABELS: Record<DynamicWorkflowPhase, string> = {
106102
pending: 'PEND',
107103
queued: 'WAIT',
@@ -116,7 +112,7 @@ const PHASE_GLYPHS: Record<Exclude<DynamicWorkflowPhase, 'running'>, string> = {
116112
pending: '○',
117113
queued: '○',
118114
suspended: '◑',
119-
completed: '',
115+
completed: '',
120116
failed: '×',
121117
cancelled: '–',
122118
};
@@ -261,7 +257,6 @@ export class DynamicWorkflowMissionControlComponent implements Component {
261257
this.recordActivity(member.index, 'Started');
262258
}
263259

264-
265260
recordToolCall(input: {
266261
readonly agentId: string;
267262
readonly name?: string;
@@ -577,10 +572,10 @@ export class DynamicWorkflowMissionControlComponent implements Component {
577572
? [
578573
padToWidth('ID', 3),
579574
padToWidth('PROGRESS', DYNAMIC_WORKFLOW_RENDERING.memberProgressWidth),
580-
padToWidth('STATE', STATE_COLUMN_WIDTH),
575+
padToWidth('STATE', DYNAMIC_WORKFLOW_RENDERING.stateColumnWidth),
581576
'TASK',
582577
].join(' ')
583-
: `${padToWidth('ID', 3)} ${padToWidth('STATUS', STATE_COLUMN_WIDTH)} TASK`;
578+
: `${padToWidth('ID', 3)} ${padToWidth('STATUS', DYNAMIC_WORKFLOW_RENDERING.stateColumnWidth)} TASK`;
584579
return truncateToWidth(currentTheme.fg('textDim', header), width);
585580
}
586581

@@ -594,7 +589,7 @@ export class DynamicWorkflowMissionControlComponent implements Component {
594589
// All running rows share the workflow's clock, so they spin in step instead
595590
// of drifting apart by whenever each agent happened to start.
596591
const frame = Math.floor(
597-
Math.max(0, nowMs - this.model.startedAtMs) / DYNAMIC_WORKFLOW_PROGRESS_FRAME_MS,
592+
Math.max(0, nowMs - this.model.startedAtMs) / DYNAMIC_WORKFLOW_RENDERING.progressFrameMs,
598593
);
599594
const showProgress = width >= DYNAMIC_WORKFLOW_RENDERING.memberProgressMinWidth;
600595
const prefix = showProgress
@@ -603,8 +598,8 @@ export class DynamicWorkflowMissionControlComponent implements Component {
603598
renderProgressGlyph(member.phase, frame),
604599
DYNAMIC_WORKFLOW_RENDERING.memberProgressWidth,
605600
)
606-
} ${padToWidth(renderStateLabel(member.phase), STATE_COLUMN_WIDTH)} `
607-
: `${id} ${padToWidth(renderCompactStatus(member.phase, frame), STATE_COLUMN_WIDTH)} `;
601+
} ${padToWidth(renderStateLabel(member.phase), DYNAMIC_WORKFLOW_RENDERING.stateColumnWidth)} `
602+
: `${id} ${padToWidth(renderCompactStatus(member.phase, frame), DYNAMIC_WORKFLOW_RENDERING.stateColumnWidth)} `;
608603
const task = member.item || 'Delegated agent';
609604
// The elision is display-only: the dedup below still compares whole items,
610605
// so a streamed line that merely repeats the task is still suppressed.
@@ -1134,8 +1129,8 @@ function commonPrefixLength(left: string, right: string, limit: number): number
11341129

11351130
function renderProgressGlyph(phase: DynamicWorkflowPhase, frame: number): string {
11361131
const glyph = phase === 'running'
1137-
? DYNAMIC_WORKFLOW_PROGRESS_FRAMES[frame % DYNAMIC_WORKFLOW_PROGRESS_FRAMES.length] ??
1138-
DYNAMIC_WORKFLOW_PROGRESS_FRAMES[0]
1132+
? DYNAMIC_WORKFLOW_RENDERING.progressFrames[frame % DYNAMIC_WORKFLOW_RENDERING.progressFrames.length] ??
1133+
DYNAMIC_WORKFLOW_RENDERING.progressFrames[0]
11391134
: PHASE_GLYPHS[phase];
11401135
return currentTheme.fg(PHASE_COLORS[phase], glyph);
11411136
}

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

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,12 @@ export const DYNAMIC_WORKFLOW_RENDERING = {
2828
frameHorizontalInset: 4,
2929
memberProgressMinWidth: 60,
3030
memberProgressWidth: 8,
31+
/** Least width of the lifecycle STATE column in member rows. */
32+
stateColumnWidth: 6,
33+
/** Thin-arc frames for a running row; all rows share one clock. */
34+
progressFrames: ['◜', '◝', '◞', '◟'],
35+
/** Arc cadence in milliseconds. */
36+
progressFrameMs: 120,
3137
/** Least room the task keeps before the detail may claim any of the row. */
3238
memberTaskMinWidth: 12,
3339
/** Share of the free row the task may take before the detail gets the rest. */

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

Lines changed: 31 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ function renderText(component: DynamicWorkflowMissionControlComponent, width = 1
2121
}
2222

2323
/** Lifecycle progress glyph and label for a running row. */
24-
const RUNNING_CELL = /[]\s+RUN/u;
24+
const RUNNING_CELL = /[]\s+RUN/u;
2525

2626
/** Head of a task cell that lost the preamble every row shared. */
2727
const TASK_ELISION_MARK = '…';
@@ -143,7 +143,7 @@ describe('DynamicWorkflowMissionControlComponent', () => {
143143
});
144144
expect(component.applyResult(result)).toBe(true);
145145
expect(memberLine(renderText(component, 120), 1)).toMatch(/×\s+FAIL\s+Schema work/u);
146-
expect(memberLine(renderText(component, 120), 2)).toMatch(/\s+DONE\s+Normal work/u);
146+
expect(memberLine(renderText(component, 120), 2)).toMatch(/\s+DONE\s+Normal work/u);
147147
});
148148

149149
it('ignores blank items so no phantom row waits forever', () => {
@@ -160,8 +160,8 @@ describe('DynamicWorkflowMissionControlComponent', () => {
160160
component.markCompleted('agent-2', 'Done two');
161161

162162
const output = renderText(component, 120);
163-
expect(memberLine(output, 1)).toContain(' DONE');
164-
expect(memberLine(output, 2)).toContain(' DONE');
163+
expect(memberLine(output, 1)).toContain(' DONE');
164+
expect(memberLine(output, 2)).toContain(' DONE');
165165
// memberRowCount also counts activity lines, so assert the row's absence.
166166
expect(() => memberLine(output, 3)).toThrow(/Missing Dynamic Workflow member 003/u);
167167
expect(aggregateLine(output)).toContain('2/2 complete');
@@ -192,7 +192,7 @@ describe('DynamicWorkflowMissionControlComponent', () => {
192192
expect(component.applyResult(result)).toBe(true);
193193

194194
const output = renderText(component, 120);
195-
expect(memberLine(output, 1)).toContain(' DONE');
195+
expect(memberLine(output, 1)).toContain(' DONE');
196196
expect(output).not.toContain('Unsupported');
197197
});
198198

@@ -243,7 +243,7 @@ describe('DynamicWorkflowMissionControlComponent', () => {
243243
expect(component.applyResult(result)).toBe(true);
244244

245245
const output = renderText(component, 120);
246-
expect(memberLine(output, 1)).toContain(' DONE');
246+
expect(memberLine(output, 1)).toContain(' DONE');
247247
expect(output).toContain('Accepted result');
248248
expect(output).not.toContain('Out-of-range result');
249249
expect(output).not.toContain('Duplicate result');
@@ -321,8 +321,8 @@ describe('DynamicWorkflowMissionControlComponent', () => {
321321
expect(aggregateLine(output)).toContain('2/3 complete');
322322
expect(aggregateLine(output)).not.toMatch(/\b\d+%/u);
323323
expect(aggregateLine(output)).not.toContain('━');
324-
expect(memberLine(output, 1)).toMatch(/[]\s+RUN\s+Layout hierarchy/u);
325-
expect(memberLine(output, 2)).toMatch(/\s+DONE\s+Interaction audit/u);
324+
expect(memberLine(output, 1)).toMatch(/[]\s+RUN\s+Layout hierarchy/u);
325+
expect(memberLine(output, 2)).toMatch(/\s+DONE\s+Interaction audit/u);
326326
expect(output).not.toMatch(/[]{4,}/u);
327327
});
328328

@@ -408,11 +408,11 @@ describe('DynamicWorkflowMissionControlComponent', () => {
408408
};
409409

410410
const first = colouredMemberLine();
411-
vi.setSystemTime(BRAILLE_SPINNER_INTERVAL_MS * 2);
411+
vi.setSystemTime(120);
412412
const second = colouredMemberLine();
413413

414-
expect(first).toContain(chalk.hex(darkColors.primary)(''));
415-
expect(second).toContain(chalk.hex(darkColors.primary)(''));
414+
expect(first).toContain(chalk.hex(darkColors.primary)(''));
415+
expect(second).toContain(chalk.hex(darkColors.primary)(''));
416416
expect(first).toContain(chalk.hex(darkColors.primary)('RUN'));
417417
vi.setSystemTime(BRAILLE_SPINNER_INTERVAL_MS);
418418

@@ -439,7 +439,7 @@ describe('DynamicWorkflowMissionControlComponent', () => {
439439

440440
const output = renderText(component, 120);
441441
expect(output).toContain('– Cancelled');
442-
expect(memberLine(output, 1)).toMatch(/[]\s+RUN\s+Running work/u);
442+
expect(memberLine(output, 1)).toMatch(/[]\s+RUN\s+Running work/u);
443443
expect(memberLine(output, 2)).toMatch(/\s+WAIT\s+Queued work/u);
444444
expect(output).not.toContain('– STOP');
445445
expect(output).not.toContain('⠋ Orchestrating');
@@ -454,7 +454,7 @@ describe('DynamicWorkflowMissionControlComponent', () => {
454454
component.markFailed('agent-1', 'Late failure');
455455

456456
const output = renderText(component, 100);
457-
expect(memberLine(output, 1)).toMatch(/\s+DONE\s+Layout hierarchy/u);
457+
expect(memberLine(output, 1)).toMatch(/\s+DONE\s+Layout hierarchy/u);
458458
expect(output).toContain('Finished first');
459459
expect(output).not.toContain('Late failure');
460460
});
@@ -474,7 +474,7 @@ describe('DynamicWorkflowMissionControlComponent', () => {
474474
].join('\n'));
475475

476476
const output = renderText(component, 120);
477-
expect(memberLine(output, 1)).toMatch(/\s+DONE\s+Observed first/u);
477+
expect(memberLine(output, 1)).toMatch(/\s+DONE\s+Observed first/u);
478478
expect(output).toContain('Observed completion');
479479
expect(output).not.toContain('Late result failure');
480480
expect(memberLine(output, 2)).toMatch(/×\s+FAIL\s+Result-only second/u);
@@ -495,7 +495,7 @@ describe('DynamicWorkflowMissionControlComponent', () => {
495495

496496
const output = renderText(component, 120);
497497
expect(aggregateLine(output)).toContain('1/1 complete');
498-
expect(memberLine(output, 1)).toContain(' DONE');
498+
expect(memberLine(output, 1)).toContain(' DONE');
499499
expect(output).not.toContain('002');
500500
expect(output).not.toContain('Phantom failure');
501501
expect(output).not.toMatch(/\d+%/u);
@@ -515,7 +515,7 @@ describe('DynamicWorkflowMissionControlComponent', () => {
515515
expect(renderText(component, 100)).toContain('Rate limited');
516516

517517
component.markStarted('agent-1');
518-
expect(memberLine(renderText(component, 100), 1)).toMatch(/[]\s+RUN/u);
518+
expect(memberLine(renderText(component, 100), 1)).toMatch(/[]\s+RUN/u);
519519
});
520520

521521
it('prefers a suspension detail over stale model progress in the member row', () => {
@@ -602,7 +602,7 @@ describe('DynamicWorkflowMissionControlComponent', () => {
602602
component.markCancelled('agent-6');
603603

604604
const output = renderText(component, 140);
605-
for (const token of ['○ WAIT', '◑ HOLD', ' DONE', '× FAIL', '– STOP']) {
605+
for (const token of ['○ WAIT', '◑ HOLD', ' DONE', '× FAIL', '– STOP']) {
606606
expect(output).toContain(token);
607607
}
608608
// Running is the one animated phase, so its symbol varies by frame.
@@ -644,12 +644,12 @@ describe('DynamicWorkflowMissionControlComponent', () => {
644644
const running = renderText(component, 100);
645645
expect(running).toContain('PROGRESS');
646646
expect(running).not.toContain('WORK IDLE');
647-
expect(memberLine(running, 1)).toMatch(/\s+RUN\s+Live work/u);
647+
expect(memberLine(running, 1)).toMatch(/\s+RUN\s+Live work/u);
648648
expect(memberLine(running, 2)).toMatch(/\s+WAIT\s+Queued work/u);
649649
expect(running).not.toMatch(/\b\d+%||/u);
650650
});
651651

652-
it('fills the running circle from the shared workflow clock and freezes completion', () => {
652+
it('rotates the running arc from the shared workflow clock and freezes completion', () => {
653653
vi.useFakeTimers();
654654
vi.setSystemTime(0);
655655
const previousLevel = chalk.level;
@@ -664,16 +664,17 @@ describe('DynamicWorkflowMissionControlComponent', () => {
664664
component.registerSubagent({ agentId: 'agent-1' });
665665
component.markStarted('agent-1');
666666

667-
for (const [time, glyph] of [[0, ''], [160, ''], [320, ''], [480, '']] as const) {
667+
for (const [time, glyph] of [[0, ''], [120, ''], [240, ''], [360, '']] as const) {
668668
vi.setSystemTime(time);
669-
expect(memberLine(renderText(component, 100), 1)).toContain(glyph);
669+
const line = component.render(100).find((candidate) => strip(candidate).includes('001'));
670+
expect(line).toContain(chalk.hex(darkColors.primary)(glyph));
670671
}
671672

672673
component.markCompleted('agent-1', 'Done');
673674
const completed = component.render(100).find((line) => strip(line).includes('001'));
674-
expect(completed).toContain(chalk.hex(darkColors.success)(''));
675+
expect(completed).toContain(chalk.hex(darkColors.success)(''));
675676
vi.setSystemTime(10_000);
676-
expect(memberLine(renderText(component, 100), 1)).toMatch(/\s+DONE/u);
677+
expect(memberLine(renderText(component, 100), 1)).toMatch(/\s+DONE/u);
677678
} finally {
678679
chalk.level = previousLevel;
679680
currentTheme.setPalette(previousPalette);
@@ -697,8 +698,8 @@ describe('DynamicWorkflowMissionControlComponent', () => {
697698

698699
const output = renderText(component, 100);
699700
expect(memberLine(output, 1)).toMatch(/\s+WAIT/u);
700-
expect(memberLine(output, 2)).toMatch(/[]\s+RUN/u);
701-
expect(memberLine(output, 3)).toMatch(/\s+DONE/u);
701+
expect(memberLine(output, 2)).toMatch(/[]\s+RUN/u);
702+
expect(memberLine(output, 3)).toMatch(/\s+DONE/u);
702703
} finally {
703704
chalk.level = previousLevel;
704705
}
@@ -716,8 +717,8 @@ describe('DynamicWorkflowMissionControlComponent', () => {
716717
const output = renderText(component, 100);
717718
const glyphColumns = [
718719
memberLine(output, 1).indexOf('○'),
719-
memberLine(output, 2).search(/[]/u),
720-
memberLine(output, 3).indexOf(''),
720+
memberLine(output, 2).search(/[]/u),
721+
memberLine(output, 3).indexOf(''),
721722
];
722723
expect(glyphColumns[0]).toBeGreaterThan(0);
723724
expect(new Set(glyphColumns).size).toBe(1);
@@ -882,7 +883,7 @@ describe('DynamicWorkflowMissionControlComponent', () => {
882883
const output = strip(rendered.join('\n'));
883884

884885
expect(rendered.every((line) => visibleWidth(line) <= width)).toBe(true);
885-
expect(memberLine(output, 1)).toMatch(/[]\s+RUN/u);
886+
expect(memberLine(output, 1)).toMatch(/[]\s+RUN/u);
886887
expect(output.includes('PROGRESS')).toBe(expectedProgress);
887888
expect(output.includes('STATUS')).toBe(expectedStatus);
888889
expect(output).not.toContain('WORK IDLE');
@@ -899,15 +900,15 @@ describe('DynamicWorkflowMissionControlComponent', () => {
899900
component.markStarted('agent-1');
900901

901902
vi.setSystemTime(30_000);
902-
const before = memberLine(renderText(component, 100), 1).match(/[]/u)?.[0];
903+
const before = memberLine(renderText(component, 100), 1).match(/[]/u)?.[0];
903904
component.recordToolCall({ agentId: 'agent-1', name: 'Read' });
904905
for (let index = 0; index < 200; index += 1) {
905906
component.appendModelDelta({ agentId: 'agent-1', delta: `chunk ${String(index)} ` });
906907
}
907908
component.recordToolCall({ agentId: 'agent-1', name: 'Bash' });
908909

909910
const output = renderText(component, 100);
910-
const after = memberLine(output, 1).match(/[]/u)?.[0];
911+
const after = memberLine(output, 1).match(/[]/u)?.[0];
911912
expect(before).toBeDefined();
912913
expect(after).toBe(before);
913914
expect(output).not.toMatch(/\b\d+%|/u);

apps/pythinker-code/test/tui/pythinker-tui-message-flow.test.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3944,8 +3944,8 @@ command = "vim"
39443944
const transcript = stripSgr(renderTranscript(driver));
39453945
expect(transcript).toContain('Dynamic Workflow');
39463946
// The running row advances through the approved progress-glyph frames.
3947-
expect(transcript).toMatch(/001\s+[]\s+RUN\s+src\/a.ts/u);
3948-
expect(transcript).toMatch(/002\s+\s+DONE\s+src\/b.ts/u);
3947+
expect(transcript).toMatch(/001\s+[]\s+RUN\s+src\/a.ts/u);
3948+
expect(transcript).toMatch(/002\s+\s+DONE\s+src\/b.ts/u);
39493949
expect(transcript).toMatch(/Orchestrating\s+1\/2 complete/u);
39503950
expect(transcript).not.toContain('━');
39513951
expect(transcript).toContain('Completed before spawn');
@@ -4057,7 +4057,7 @@ command = "vim"
40574057

40584058
const transcript = stripSgr(renderTranscript(driver));
40594059
expect(transcript).toContain('✓ Completed');
4060-
expect(transcript).toMatch(/001\s+\s+DONE\s+src\/a.ts/u);
4060+
expect(transcript).toMatch(/001\s+\s+DONE\s+src\/a.ts/u);
40614061
expect(transcript).toMatch(/002\s+×\s+FAIL\s+src\/b.ts/u);
40624062
expect(transcript).toContain('Agent timed out after 30s.');
40634063
expect(transcript).not.toContain('⠋ Orchestrating');
@@ -4218,7 +4218,7 @@ command = "vim"
42184218

42194219
const transcript = stripSgr(renderTranscript(driver));
42204220
expect(transcript).toContain('× Failed');
4221-
expect(transcript).toMatch(/001\s+\s+DONE\s+src\/a.ts/u);
4221+
expect(transcript).toMatch(/001\s+\s+DONE\s+src\/a.ts/u);
42224222
expect(transcript).toContain('Child completed before request error');
42234223
});
42244224

0 commit comments

Comments
 (0)