Skip to content
Merged
5 changes: 5 additions & 0 deletions .changeset/workflow-progress.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@pythoughts/pythinker-code": minor
---

Show indeterminate lifecycle progress for Dynamic Workflow rows in the TUI, and report schema-error outcomes as failed.
Original file line number Diff line number Diff line change
@@ -1,10 +1,6 @@
import { truncateToWidth, visibleWidth, type Component } from '@earendil-works/pi-tui';

import {
BRAILLE_SPINNER_FRAMES,
BRAILLE_SPINNER_INTERVAL_MS,
DYNAMIC_WORKFLOW_RENDERING,
} from '#/tui/constant/rendering';
import { DYNAMIC_WORKFLOW_RENDERING } from '#/tui/constant/rendering';
import { currentTheme } from '#/tui/theme';
import { shimmerText } from '#/tui/utils/shimmer';

Expand Down Expand Up @@ -55,16 +51,6 @@
statusDetail?: string;
startedAtMs?: number;
endedAtMs?: number;
/**
* Tool calls observed for this agent. Real work done, monotonic — unlike a
* percentage, which would need a total nobody can know in advance.
*/
toolCalls: number;
/**
* When this agent last produced any observed event. Its age is the liveness
* signal: a working agent stays near zero, a wedged one climbs without bound.
*/
lastEventAtMs: number;
}

export interface DynamicWorkflowActivity {
Expand Down Expand Up @@ -109,16 +95,23 @@
readonly availableRows?: () => number | undefined;
}

const PHASE_TOKENS: Record<DynamicWorkflowPhase, string> = {
pending: '◌ PEND',
queued: '◌ WAIT',
// Label only: a running row is the one phase that animates, so its symbol is
// a spinner supplied per frame by renderPhaseCell rather than a fixed glyph.
const PHASE_LABELS: Record<DynamicWorkflowPhase, string> = {
pending: 'PEND',
queued: 'WAIT',
running: 'RUN',
suspended: '! HOLD',
completed: '✓ DONE',
failed: '× FAIL',
cancelled: '– STOP',
suspended: 'HOLD',
completed: 'DONE',
failed: 'FAIL',
cancelled: 'STOP',
};

const PHASE_GLYPHS: Record<Exclude<DynamicWorkflowPhase, 'running'>, string> = {
pending: '○',
queued: '○',
suspended: '◑',
completed: '✓',
failed: '×',
cancelled: '–',
};

const PHASE_COLORS: Record<DynamicWorkflowPhase, 'textMuted' | 'primary' | 'success' | 'warning' | 'error'> = {
Expand Down Expand Up @@ -257,7 +250,6 @@
if (member.phase === 'running') return;
member.phase = 'running';
member.startedAtMs ??= Date.now();
member.lastEventAtMs = Date.now();
delete member.statusDetail;
this.recordActivity(member.index, 'Started');
}
Expand All @@ -268,9 +260,7 @@
}): void {
const member = this.findMemberByAgentId(input.agentId);
if (member === undefined || isTerminalPhase(member.phase)) return;
this.markStarted(input.agentId);
member.toolCalls += 1;
member.lastEventAtMs = Date.now();
if (member.phase === 'pending' || member.phase === 'queued') this.markStarted(input.agentId);
const latest = input.name === undefined ? 'Using a tool' : `Using ${input.name}`;
this.setLatest(member, latest, true);
// Streamed text that follows starts a new line, never continues this label.
Expand All @@ -280,8 +270,7 @@
appendModelDelta(input: { readonly agentId: string; readonly delta: string }): void {
const member = this.findMemberByAgentId(input.agentId);
if (member === undefined || isTerminalPhase(member.phase) || input.delta.length === 0) return;
this.markStarted(input.agentId);
member.lastEventAtMs = Date.now();
if (member.phase === 'pending' || member.phase === 'queued') this.markStarted(input.agentId);
const combined = `${member.carry}${input.delta}`;
// Only the text after the last newline is still being written. A delta that
// ends exactly at a newline leaves nothing pending, so carrying the closed
Expand Down Expand Up @@ -450,7 +439,9 @@
}

if (members.length > 0 && rowBudget - lines.length >= 2) {
lines.push(this.renderTableHeader(width));
if (width >= DYNAMIC_WORKFLOW_RENDERING.frameMinWidth) {
lines.push(this.renderTableHeader(width));
}
const slots = rowBudget - lines.length;
const needsMore = members.length > slots;
const memberSlots = needsMore && slots >= 2 ? slots - 1 : slots;
Expand Down Expand Up @@ -539,7 +530,8 @@
baseToken: 'primary',
shimmerToken: 'primaryShimmer',
frame: Math.floor(
Math.max(0, nowMs - this.model.startedAtMs) / BRAILLE_SPINNER_INTERVAL_MS,
Math.max(0, nowMs - this.model.startedAtMs) /
DYNAMIC_WORKFLOW_RENDERING.aggregateShimmerFrameMs,
),
windowSize: 4,
});
Expand Down Expand Up @@ -577,11 +569,11 @@
const header = width >= DYNAMIC_WORKFLOW_RENDERING.memberProgressMinWidth
? [
padToWidth('ID', 3),
padToWidth('WORK IDLE', DYNAMIC_WORKFLOW_RENDERING.memberProgressWidth),
padToWidth('STATE', 6),
padToWidth('PROGRESS', DYNAMIC_WORKFLOW_RENDERING.memberProgressWidth),
padToWidth('STATE', DYNAMIC_WORKFLOW_RENDERING.stateColumnWidth),
'TASK',
].join(' ')
: `${padToWidth('ID', 3)} ${padToWidth('STATE', 6)} TASK`;
: `${padToWidth('ID', 3)} ${padToWidth('STATUS', DYNAMIC_WORKFLOW_RENDERING.stateColumnWidth)} TASK`;
return truncateToWidth(currentTheme.fg('textDim', header), width);
}

Expand All @@ -594,19 +586,18 @@
const id = currentTheme.fg('primary', String(member.index).padStart(3, '0'));
// All running rows share the workflow's clock, so they spin in step instead
// of drifting apart by whenever each agent happened to start.
const state = renderPhaseCell(
member.phase,
Math.floor(Math.max(0, nowMs - this.model.startedAtMs) / BRAILLE_SPINNER_INTERVAL_MS),
);
const showWork = width >= DYNAMIC_WORKFLOW_RENDERING.memberProgressMinWidth;
const workColumn = padToWidth(
renderWorkCell(member, nowMs),
DYNAMIC_WORKFLOW_RENDERING.memberProgressWidth,
const frame = Math.floor(
Math.max(0, nowMs - this.model.startedAtMs) / DYNAMIC_WORKFLOW_RENDERING.progressFrameMs,
);
const stateColumn = padToWidth(state, 6);
const prefix = showWork
? `${id} ${workColumn} ${stateColumn} `
: `${id} ${padToWidth(state, 6)} `;
const showProgress = width >= DYNAMIC_WORKFLOW_RENDERING.memberProgressMinWidth;
const prefix = showProgress
? `${id} ${
centerToWidth(
renderProgressGlyph(member.phase, frame),
DYNAMIC_WORKFLOW_RENDERING.memberProgressWidth,
)
} ${padToWidth(renderStateLabel(member.phase), DYNAMIC_WORKFLOW_RENDERING.stateColumnWidth)} `
: `${id} ${padToWidth(renderCompactStatus(member.phase, frame), DYNAMIC_WORKFLOW_RENDERING.stateColumnWidth)} `;
const task = member.item || 'Delegated agent';
// The elision is display-only: the dedup below still compares whole items,
// so a streamed line that merely repeats the task is still suppressed.
Expand All @@ -624,7 +615,7 @@

// The elapsed cell is short and fixed, so it is reserved first — but only
// while the task still keeps its floor.
const elapsedPart = showWork && elapsed !== undefined
const elapsedPart = showProgress && elapsed !== undefined
? `${MEMBER_SEPARATOR}${currentTheme.fg('textMuted', elapsed)}`
: '';
const elapsedWidth = visibleWidth(elapsedPart);
Expand All @@ -640,7 +631,7 @@
DYNAMIC_WORKFLOW_RENDERING.memberTaskMinWidth,
Math.floor(rest * DYNAMIC_WORKFLOW_RENDERING.memberTaskShare),
);
const detailBudget = showWork && detail !== undefined && detail.length > 0
const detailBudget = showProgress && detail !== undefined && detail.length > 0
? rest - Math.min(visibleWidth(shownTask), taskCap) - MEMBER_SEPARATOR.length
: 0;
const detailPart = detailBudget >= DYNAMIC_WORKFLOW_RENDERING.memberDetailMinWidth
Expand Down Expand Up @@ -721,8 +712,6 @@
phase: this.model.inputComplete ? 'queued' : 'pending',
latest: '',
carry: '',
toolCalls: 0,
lastEventAtMs: Date.now(),
});
}
}
Expand All @@ -747,7 +736,6 @@
const normalizedDetail = normalizeText(detail);
member.phase = phase;
member.endedAtMs = Date.now();
member.lastEventAtMs = Date.now();
member.statusDetail = normalizedDetail.length > 0 ? normalizedDetail : undefined;
const label = phase === 'completed' ? 'Completed' : phase === 'failed' ? 'Failed' : 'Cancelled';
this.recordActivity(member.index, normalizedDetail.length > 0 ? `${label}: ${normalizedDetail}` : label);
Expand Down Expand Up @@ -841,7 +829,7 @@

/** Best-effort `description` read from a partially streamed JSON arguments string. */
export function dynamicWorkflowPartialDescriptionFromArguments(argumentsText: string): string {
const match = /"description"\s*:\s*"/.exec(argumentsText);

Check warning on line 832 in apps/pythinker-code/src/tui/components/messages/dynamic-workflow-mission-control.ts

View workflow job for this annotation

GitHub Actions / lint

eslint(require-unicode-regexp)

Use the 'u' flag.
if (match === null) return '';
return parsePartialJsonString(argumentsText, match.index + match[0].length).value;
}
Expand Down Expand Up @@ -890,7 +878,7 @@
}

function dynamicWorkflowPartialResumeItemsFromArguments(argumentsText: string): string[] {
const match = /"resume_agent_ids"\s*:\s*\{/.exec(argumentsText);

Check warning on line 881 in apps/pythinker-code/src/tui/components/messages/dynamic-workflow-mission-control.ts

View workflow job for this annotation

GitHub Actions / lint

eslint(require-unicode-regexp)

Use the 'u' flag.
if (match === null) return [];
return Array.from(
{ length: countPartialJsonObjectEntries(argumentsText, match.index + match[0].length) },
Expand All @@ -904,7 +892,7 @@
}

function dynamicWorkflowPartialPromptTemplateFromArguments(argumentsText: string): string {
const match = /"prompt_template"\s*:\s*"/.exec(argumentsText);

Check warning on line 895 in apps/pythinker-code/src/tui/components/messages/dynamic-workflow-mission-control.ts

View workflow job for this annotation

GitHub Actions / lint

eslint(require-unicode-regexp)

Use the 'u' flag.
if (match === null) return '';
return parsePartialJsonString(argumentsText, match.index + match[0].length).value;
}
Expand All @@ -916,7 +904,7 @@
// Indexes are validated and deduplicated: an explicit index is honored only
// once and within range; a duplicated one is dropped, not remapped.
const usedIndexes = new Set<number>();
const tagPattern = /<subagent\b([^>]*)>/g;

Check warning on line 907 in apps/pythinker-code/src/tui/components/messages/dynamic-workflow-mission-control.ts

View workflow job for this annotation

GitHub Actions / lint

eslint(require-unicode-regexp)

Use the 'u' flag.
let match: RegExpExecArray | null;
while (
statuses.length < MAX_DYNAMIC_WORKFLOW_MEMBERS &&
Expand All @@ -931,7 +919,8 @@
outcome === 'completed' ||
outcome === 'failed' ||
outcome === 'aborted' ||
outcome === 'cancelled'
outcome === 'cancelled' ||
outcome === 'schema_error'
) {
// Omitted `index` falls back to the lowest free slot so unordered tags
// still render in ascending row order.
Expand All @@ -953,7 +942,11 @@
index,
agentId: xmlAttribute(attrs, 'agent_id'),
item: xmlAttribute(attrs, 'item'),
status: outcome === 'aborted' || outcome === 'cancelled' ? 'cancelled' : outcome,
status: outcome === 'aborted' || outcome === 'cancelled'
? 'cancelled'
: outcome === 'schema_error'
? 'failed'
: outcome,
detail: normalizeText(decodeXmlEntities(body)),
});
}
Expand Down Expand Up @@ -1132,61 +1125,26 @@
return index;
}

/**
* The WORK cell: tool calls done, and how long this agent has been silent.
*
* There is deliberately no percentage. Nothing knows how many steps an agent
* will take, so any percent is invented — the old one pinned every tool-using
* agent at 75% until it finished, which made a wedged agent look identical to a
* busy one. A count and an idle age are both real and answer the actual
* question: is this thing still working?
*/
function renderWorkCell(member: DynamicWorkflowMember, nowMs: number): string {
const tools = currentTheme.fg('textDim', `${String(member.toolCalls).padStart(3, ' ')}⚒`);
// A row that has not started has no silence to measure: its clock would run
// from the launch of the whole workflow, so a queue that is simply long would
// paint every waiting row red. Only a finished row and an unstarted one share
// the placeholder; the reason differs, but neither has an idle age.
if (isTerminalPhase(member.phase) || member.phase === 'pending' || member.phase === 'queued') {
return `${tools} ${currentTheme.fg('textMuted', ' –')}`;
}
const idleMs = Math.max(0, nowMs - member.lastEventAtMs);
const idleSeconds = Math.floor(idleMs / 1000);
const token = idleColor(member.phase, idleMs);
return `${tools} ${currentTheme.fg(token, `${String(idleSeconds)}s`.padStart(4, ' '))}`;
function renderProgressGlyph(phase: DynamicWorkflowPhase, frame: number): string {
const glyph = phase === 'running'
? DYNAMIC_WORKFLOW_RENDERING.progressFrames[frame % DYNAMIC_WORKFLOW_RENDERING.progressFrames.length] ??
DYNAMIC_WORKFLOW_RENDERING.progressFrames[0]
: PHASE_GLYPHS[phase];
return currentTheme.fg(PHASE_COLORS[phase], glyph);
}

/**
* How loud an idle age reads.
*
* Only a running row can stall. A suspended one is waiting on the user by
* design, so it keeps the count without the alarm colours.
*/
function idleColor(
phase: DynamicWorkflowPhase,
idleMs: number,
): 'textMuted' | 'warning' | 'error' {
if (phase === 'running') {
if (idleMs >= DYNAMIC_WORKFLOW_RENDERING.stalledIdleMs) return 'error';
if (idleMs >= DYNAMIC_WORKFLOW_RENDERING.quietIdleMs) return 'warning';
}
return 'textMuted';
function renderStateLabel(phase: DynamicWorkflowPhase): string {
return currentTheme.fg(PHASE_COLORS[phase], PHASE_LABELS[phase]);
}

/**
* The STATE cell for one row.
*
* Every phase but `running` is a fixed symbol plus its label. A running row
* spins a dim grey braille dot instead, so "this agent is working" reads as
* motion rather than as another coloured dot competing with the periwinkle the
* panel already uses for identity.
*/
function renderPhaseCell(phase: DynamicWorkflowPhase, frame: number): string {
const label = currentTheme.fg(PHASE_COLORS[phase], PHASE_TOKENS[phase]);
if (phase !== 'running') return label;
const spinner =
BRAILLE_SPINNER_FRAMES[frame % BRAILLE_SPINNER_FRAMES.length] ?? BRAILLE_SPINNER_FRAMES[0] ?? '';
return `${currentTheme.fg('textDim', spinner)} ${label}`;
function renderCompactStatus(phase: DynamicWorkflowPhase, frame: number): string {
return `${renderProgressGlyph(phase, frame)} ${renderStateLabel(phase)}`;
}

function centerToWidth(text: string, width: number): string {
const paddingWidth = Math.max(0, width - visibleWidth(text));
const left = Math.floor(paddingWidth / 2);
return `${' '.repeat(left)}${text}${' '.repeat(paddingWidth - left)}`;
}

function padToWidth(text: string, width: number): string {
Expand Down
14 changes: 9 additions & 5 deletions apps/pythinker-code/src/tui/constant/rendering.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,15 @@ export const DYNAMIC_WORKFLOW_RENDERING = {
frameMinWidth: 21,
frameHorizontalInset: 4,
memberProgressMinWidth: 60,
memberProgressWidth: 9,
memberProgressWidth: 8,
/** Least width of the lifecycle STATE column in member rows. */
stateColumnWidth: 6,
/** Cadence for the live aggregate-label shimmer. */
aggregateShimmerFrameMs: BRAILLE_SPINNER_INTERVAL_MS,
/** Thin-arc frames for a running row; all rows share one clock. */
progressFrames: ['◜', '◝', '◞', '◟'],
/** Arc cadence in milliseconds. */
progressFrameMs: 120,
/** Least room the task keeps before the detail may claim any of the row. */
memberTaskMinWidth: 12,
/** Share of the free row the task may take before the detail gets the rest. */
Expand All @@ -46,10 +54,6 @@ export const DYNAMIC_WORKFLOW_RENDERING = {
* buffered text from growing for as long as the agent runs.
*/
memberLatestMaxChars: 512,
/** Idle age at which a row's silence is worth noticing. */
quietIdleMs: 60_000,
/** Idle age at which a row has almost certainly stalled. */
stalledIdleMs: 180_000,
} as const;

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