Skip to content

Commit d159963

Browse files
committed
feat(tui): replace workflow progress with work done and time since last event
The bar was a three-stage ratchet: 20% started, 50% model text, 75% any tool call, 100% finished. Because it only ever advanced, every agent that touched a tool sat at 75% until it ended -- so an agent ten seconds in, one twenty minutes in, and one wedged for good all rendered identically. The number implied a completion fraction nothing can know, since no one knows how many steps an agent will take before it takes them. Each row now shows its tool-call count and how long it has been silent. Both are observed facts rather than estimates, and together they answer the question the bar could not: is this agent still working? Silence past a minute turns amber and past three minutes red, so a stalled row separates itself from a busy one at a glance. No new events were needed -- every tool call and streamed delta already reached the component, which was discarding them into the ratchet.
1 parent 45e5802 commit d159963

5 files changed

Lines changed: 169 additions & 146 deletions

File tree

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+
Replace the Dynamic Workflow progress bar with the two things it can actually know: how many tool calls each agent has made, and how long it has been silent. The old bar pinned every tool-using agent at 75% until it finished, so an agent working hard and one wedged for ten minutes looked identical. A row that goes quiet now turns amber, then red.

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

Lines changed: 47 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -48,12 +48,15 @@ export interface DynamicWorkflowMember {
4848
startedAtMs?: number;
4949
endedAtMs?: number;
5050
/**
51-
* Observed-stage progress heuristic (0-100): the protocol emits no per-task
52-
* percentage, so stage floors map to observed events and streamed deltas
53-
* creep asymptotically toward a ceiling. May hold fractional values
54-
* internally; display floors it. Only a terminal event reaches 100.
51+
* Tool calls observed for this agent. Real work done, monotonic — unlike a
52+
* percentage, which would need a total nobody can know in advance.
5553
*/
56-
progressPercent: number;
54+
toolCalls: number;
55+
/**
56+
* When this agent last produced any observed event. Its age is the liveness
57+
* signal: a working agent stays near zero, a wedged one climbs without bound.
58+
*/
59+
lastEventAtMs: number;
5760
}
5861

5962
export interface DynamicWorkflowActivity {
@@ -246,7 +249,7 @@ export class DynamicWorkflowMissionControlComponent implements Component {
246249
if (member.phase === 'running') return;
247250
member.phase = 'running';
248251
member.startedAtMs ??= Date.now();
249-
this.advanceMemberProgress(member, DYNAMIC_WORKFLOW_RENDERING.startedProgress);
252+
member.lastEventAtMs = Date.now();
250253
delete member.statusDetail;
251254
this.recordActivity(member.index, 'Started');
252255
}
@@ -258,7 +261,8 @@ export class DynamicWorkflowMissionControlComponent implements Component {
258261
const member = this.findMemberByAgentId(input.agentId);
259262
if (member === undefined || isTerminalPhase(member.phase)) return;
260263
this.markStarted(input.agentId);
261-
this.advanceMemberProgress(member, DYNAMIC_WORKFLOW_RENDERING.toolActivityProgress);
264+
member.toolCalls += 1;
265+
member.lastEventAtMs = Date.now();
262266
const latest = input.name === undefined ? 'Using a tool' : `Using ${input.name}`;
263267
this.setLatest(member, latest, true);
264268
// Streamed text that follows starts a new line, never continues this label.
@@ -270,10 +274,7 @@ export class DynamicWorkflowMissionControlComponent implements Component {
270274
if (member === undefined || isTerminalPhase(member.phase) || input.delta.length === 0) return;
271275
this.markStarted(input.agentId);
272276
const recordActivity = input.delta.includes('\n') || member.latest.length === 0;
273-
// Progress reflects the observed stage only. The protocol emits no per-task
274-
// completion signal, so streamed text never advances past its stage floor —
275-
// elapsed time and the latest line carry liveness instead.
276-
this.advanceMemberProgress(member, DYNAMIC_WORKFLOW_RENDERING.modelActivityProgress);
277+
member.lastEventAtMs = Date.now();
277278
const carried = member.latestFromTool === true ? '' : member.latest;
278279
const latest = latestNonEmptyLine(`${carried}${input.delta}`);
279280
member.latestFromTool = false;
@@ -547,7 +548,7 @@ export class DynamicWorkflowMissionControlComponent implements Component {
547548
const header = width >= DYNAMIC_WORKFLOW_RENDERING.memberProgressMinWidth
548549
? [
549550
padToWidth('ID', 3),
550-
padToWidth('PROGRESS', DYNAMIC_WORKFLOW_RENDERING.memberProgressWidth),
551+
padToWidth('WORK IDLE', DYNAMIC_WORKFLOW_RENDERING.memberProgressWidth),
551552
padToWidth('STATE', 6),
552553
'TASK',
553554
].join(' ')
@@ -563,19 +564,14 @@ export class DynamicWorkflowMissionControlComponent implements Component {
563564
member.phase,
564565
Math.floor(Math.max(0, nowMs - this.model.startedAtMs) / BRAILLE_SPINNER_INTERVAL_MS),
565566
);
566-
const progressPercent = member.progressPercent;
567-
const showProgress = width >= DYNAMIC_WORKFLOW_RENDERING.memberProgressMinWidth;
568-
const progress = `${renderProgressCube(progressPercent)} ${currentTheme.fg(
569-
'textMuted',
570-
`${String(Math.floor(progressPercent)).padStart(3, ' ')}%`,
571-
)}`;
572-
const progressColumn = padToWidth(
573-
progress,
567+
const showWork = width >= DYNAMIC_WORKFLOW_RENDERING.memberProgressMinWidth;
568+
const workColumn = padToWidth(
569+
renderWorkCell(member, nowMs),
574570
DYNAMIC_WORKFLOW_RENDERING.memberProgressWidth,
575571
);
576572
const stateColumn = padToWidth(state, 6);
577-
const prefix = showProgress
578-
? `${id} ${progressColumn} ${stateColumn} `
573+
const prefix = showWork
574+
? `${id} ${workColumn} ${stateColumn} `
579575
: `${id} ${padToWidth(state, 6)} `;
580576
const task = member.item || 'Delegated agent';
581577
const latest = member.latest.length > 0 && member.latest !== task ? member.latest : undefined;
@@ -585,8 +581,8 @@ export class DynamicWorkflowMissionControlComponent implements Component {
585581
const elapsed = member.startedAtMs === undefined
586582
? undefined
587583
: `${String(elapsedSeconds(member.startedAtMs, member.endedAtMs ?? nowMs))}s`;
588-
const showDetail = showProgress && detail !== undefined && detail.length > 0;
589-
const showElapsed = showProgress && elapsed !== undefined;
584+
const showDetail = showWork && detail !== undefined && detail.length > 0;
585+
const showElapsed = showWork && elapsed !== undefined;
590586
const tail = [
591587
showDetail ? currentTheme.fg('textDim', detail) : '',
592588
showElapsed ? currentTheme.fg('textMuted', elapsed) : '',
@@ -659,7 +655,8 @@ export class DynamicWorkflowMissionControlComponent implements Component {
659655
item: '',
660656
phase: this.model.inputComplete ? 'queued' : 'pending',
661657
latest: '',
662-
progressPercent: 0,
658+
toolCalls: 0,
659+
lastEventAtMs: Date.now(),
663660
});
664661
}
665662
}
@@ -684,20 +681,12 @@ export class DynamicWorkflowMissionControlComponent implements Component {
684681
const normalizedDetail = normalizeText(detail);
685682
member.phase = phase;
686683
member.endedAtMs = Date.now();
687-
member.progressPercent = 100;
684+
member.lastEventAtMs = Date.now();
688685
member.statusDetail = normalizedDetail.length > 0 ? normalizedDetail : undefined;
689686
const label = phase === 'completed' ? 'Completed' : phase === 'failed' ? 'Failed' : 'Cancelled';
690687
this.recordActivity(member.index, normalizedDetail.length > 0 ? `${label}: ${normalizedDetail}` : label);
691688
}
692689

693-
/**
694-
* Progress only ever advances; stages map to observed events, never to time.
695-
* Creep is per observed event too (each streamed delta), so no timers exist.
696-
*/
697-
private advanceMemberProgress(member: DynamicWorkflowMember, targetPercent: number): void {
698-
member.progressPercent = Math.max(member.progressPercent, targetPercent);
699-
}
700-
701690
private setLatest(member: DynamicWorkflowMember, latest: string, recordActivity: boolean): void {
702691
const normalized = normalizeText(latest);
703692
if (normalized.length === 0 || member.latest === normalized) return;
@@ -972,23 +961,6 @@ function decodeXmlEntities(value: string): string {
972961
}
973962

974963
/** Maps a percent to one of the dotted cube levels; the cube fills bottom-up. */
975-
function renderProgressCube(percent: number): string {
976-
const bounded = Math.min(100, Math.max(0, percent));
977-
const levels = DYNAMIC_WORKFLOW_RENDERING.cubeFillLevels;
978-
const level = bounded <= 0
979-
? 0
980-
: bounded >= 100
981-
? levels.length - 1
982-
: Math.min(
983-
levels.length - 2,
984-
Math.ceil(bounded * (levels.length - 2) / 100),
985-
);
986-
const fill = levels[level]!;
987-
const fillToken = bounded >= 100 ? 'progressHead' : 'progressFill';
988-
return currentTheme.fg('progressEmpty', '▏') +
989-
currentTheme.fg(fillToken, fill.repeat(2)) +
990-
currentTheme.fg('progressEmpty', '▕');
991-
}
992964

993965
function requestPhaseLabel(phase: DynamicWorkflowRequestPhase): string {
994966
const labels: Record<DynamicWorkflowRequestPhase, string> = {
@@ -1047,6 +1019,30 @@ function normalizeText(text: string | undefined): string {
10471019
return text?.replaceAll(/\s+/g, ' ').trim() ?? '';
10481020
}
10491021

1022+
/**
1023+
* The WORK cell: tool calls done, and how long this agent has been silent.
1024+
*
1025+
* There is deliberately no percentage. Nothing knows how many steps an agent
1026+
* will take, so any percent is invented — the old one pinned every tool-using
1027+
* agent at 75% until it finished, which made a wedged agent look identical to a
1028+
* busy one. A count and an idle age are both real and answer the actual
1029+
* question: is this thing still working?
1030+
*/
1031+
function renderWorkCell(member: DynamicWorkflowMember, nowMs: number): string {
1032+
const tools = currentTheme.fg('textDim', `${String(member.toolCalls).padStart(3, ' ')}⚒`);
1033+
if (isTerminalPhase(member.phase)) {
1034+
return `${tools} ${currentTheme.fg('textMuted', ' –')}`;
1035+
}
1036+
const idleMs = Math.max(0, nowMs - member.lastEventAtMs);
1037+
const idleSeconds = Math.floor(idleMs / 1000);
1038+
const token = idleMs >= DYNAMIC_WORKFLOW_RENDERING.stalledIdleMs
1039+
? 'error'
1040+
: idleMs >= DYNAMIC_WORKFLOW_RENDERING.quietIdleMs
1041+
? 'warning'
1042+
: 'textMuted';
1043+
return `${tools} ${currentTheme.fg(token, `${String(idleSeconds)}s`.padStart(4, ' '))}`;
1044+
}
1045+
10501046
/**
10511047
* The STATE cell for one row.
10521048
*

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

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -28,11 +28,10 @@ export const DYNAMIC_WORKFLOW_RENDERING = {
2828
frameHorizontalInset: 4,
2929
memberProgressMinWidth: 60,
3030
memberProgressWidth: 9,
31-
startedProgress: 20,
32-
modelActivityProgress: 50,
33-
toolActivityProgress: 75,
34-
// Two 2×4 Braille cells form a compact 4×4 dotted cube that fills bottom-up.
35-
cubeFillLevels: [' ', '⡀', '⣀', '⣄', '⣤', '⣦', '⣶', '⣷', '⣿'],
31+
/** Idle age at which a row's silence is worth noticing. */
32+
quietIdleMs: 60_000,
33+
/** Idle age at which a row has almost certainly stalled. */
34+
stalledIdleMs: 180_000,
3635
} as const;
3736

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

0 commit comments

Comments
 (0)