From ef9b53dc75446cb3dc7bfe92336ca471bc06b67c Mon Sep 17 00:00:00 2001 From: elkaix Date: Tue, 11 Aug 2026 14:13:20 -0400 Subject: [PATCH 01/10] feat(tui): show dynamic workflow progress rings --- .../dynamic-workflow-mission-control.ts | 148 ++++------ .../src/tui/constant/rendering.ts | 6 +- .../dynamic-workflow-mission-control.test.ts | 267 +++++++----------- 3 files changed, 157 insertions(+), 264 deletions(-) diff --git a/apps/pythinker-code/src/tui/components/messages/dynamic-workflow-mission-control.ts b/apps/pythinker-code/src/tui/components/messages/dynamic-workflow-mission-control.ts index 497bd0e5..bcca97dd 100644 --- a/apps/pythinker-code/src/tui/components/messages/dynamic-workflow-mission-control.ts +++ b/apps/pythinker-code/src/tui/components/messages/dynamic-workflow-mission-control.ts @@ -1,7 +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'; @@ -55,16 +54,6 @@ export interface DynamicWorkflowMember { 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 { @@ -109,16 +98,27 @@ export interface DynamicWorkflowMissionControlOptions { readonly availableRows?: () => number | undefined; } -const PHASE_TOKENS: Record = { - 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 DYNAMIC_WORKFLOW_PROGRESS_FRAMES = ['○', '◔', '◑', '◕'] as const; +const DYNAMIC_WORKFLOW_PROGRESS_FRAME_MS = BRAILLE_SPINNER_INTERVAL_MS * 2; +const STATE_COLUMN_WIDTH = 6; + +const PHASE_LABELS: Record = { + 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, string> = { + pending: '○', + queued: '○', + suspended: '◑', + completed: '●', + failed: '×', + cancelled: '–', }; const PHASE_COLORS: Record = { @@ -257,7 +257,6 @@ export class DynamicWorkflowMissionControlComponent implements Component { if (member.phase === 'running') return; member.phase = 'running'; member.startedAtMs ??= Date.now(); - member.lastEventAtMs = Date.now(); delete member.statusDetail; this.recordActivity(member.index, 'Started'); } @@ -269,8 +268,6 @@ export class DynamicWorkflowMissionControlComponent implements Component { const member = this.findMemberByAgentId(input.agentId); if (member === undefined || isTerminalPhase(member.phase)) return; this.markStarted(input.agentId); - member.toolCalls += 1; - member.lastEventAtMs = Date.now(); 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. @@ -281,7 +278,6 @@ export class DynamicWorkflowMissionControlComponent implements Component { 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(); 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 @@ -450,7 +446,9 @@ export class DynamicWorkflowMissionControlComponent implements Component { } 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; @@ -577,11 +575,11 @@ export class DynamicWorkflowMissionControlComponent implements Component { 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', STATE_COLUMN_WIDTH), 'TASK', ].join(' ') - : `${padToWidth('ID', 3)} ${padToWidth('STATE', 6)} TASK`; + : `${padToWidth('ID', 3)} ${padToWidth('STATUS', STATE_COLUMN_WIDTH)} TASK`; return truncateToWidth(currentTheme.fg('textDim', header), width); } @@ -594,19 +592,19 @@ export class DynamicWorkflowMissionControlComponent implements Component { 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 frame = Math.floor( + Math.max(0, nowMs - this.model.startedAtMs) / DYNAMIC_WORKFLOW_PROGRESS_FRAME_MS, ); - const showWork = width >= DYNAMIC_WORKFLOW_RENDERING.memberProgressMinWidth; - const workColumn = padToWidth( - renderWorkCell(member, nowMs), + const showProgress = width >= DYNAMIC_WORKFLOW_RENDERING.memberProgressMinWidth; + const progressColumn = centerToWidth( + renderProgressGlyph(member.phase, frame), DYNAMIC_WORKFLOW_RENDERING.memberProgressWidth, ); - const stateColumn = padToWidth(state, 6); - const prefix = showWork - ? `${id} ${workColumn} ${stateColumn} ` - : `${id} ${padToWidth(state, 6)} `; + const stateColumn = padToWidth(renderStateLabel(member.phase), STATE_COLUMN_WIDTH); + const compactStatus = padToWidth(renderCompactStatus(member.phase, frame), STATE_COLUMN_WIDTH); + const prefix = showProgress + ? `${id} ${progressColumn} ${stateColumn} ` + : `${id} ${compactStatus} `; 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. @@ -624,7 +622,7 @@ export class DynamicWorkflowMissionControlComponent implements Component { // 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); @@ -640,7 +638,7 @@ export class DynamicWorkflowMissionControlComponent implements Component { 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 @@ -721,8 +719,6 @@ export class DynamicWorkflowMissionControlComponent implements Component { phase: this.model.inputComplete ? 'queued' : 'pending', latest: '', carry: '', - toolCalls: 0, - lastEventAtMs: Date.now(), }); } } @@ -747,7 +743,6 @@ export class DynamicWorkflowMissionControlComponent implements Component { 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); @@ -1132,61 +1127,26 @@ function commonPrefixLength(left: string, right: string, limit: number): number 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_PROGRESS_FRAMES[frame % DYNAMIC_WORKFLOW_PROGRESS_FRAMES.length] ?? + DYNAMIC_WORKFLOW_PROGRESS_FRAMES[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 { diff --git a/apps/pythinker-code/src/tui/constant/rendering.ts b/apps/pythinker-code/src/tui/constant/rendering.ts index 532ef28f..f012d4a8 100644 --- a/apps/pythinker-code/src/tui/constant/rendering.ts +++ b/apps/pythinker-code/src/tui/constant/rendering.ts @@ -27,7 +27,7 @@ export const DYNAMIC_WORKFLOW_RENDERING = { frameMinWidth: 21, frameHorizontalInset: 4, memberProgressMinWidth: 60, - memberProgressWidth: 9, + memberProgressWidth: 8, /** 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. */ @@ -46,10 +46,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. */ diff --git a/apps/pythinker-code/test/tui/components/messages/dynamic-workflow-mission-control.test.ts b/apps/pythinker-code/test/tui/components/messages/dynamic-workflow-mission-control.test.ts index 82c8dc49..5609516a 100644 --- a/apps/pythinker-code/test/tui/components/messages/dynamic-workflow-mission-control.test.ts +++ b/apps/pythinker-code/test/tui/components/messages/dynamic-workflow-mission-control.test.ts @@ -7,7 +7,7 @@ import { type DynamicWorkflowMissionControlOptions, dynamicWorkflowResultSummaryFromOutput, } from '#/tui/components/messages/dynamic-workflow-mission-control'; -import { BRAILLE_SPINNER_INTERVAL_MS, DYNAMIC_WORKFLOW_RENDERING } from '#/tui/constant/rendering'; +import { BRAILLE_SPINNER_INTERVAL_MS } from '#/tui/constant/rendering'; import { currentTheme, darkColors } from '#/tui/theme'; const DESCRIPTION = 'Review the interface'; @@ -20,8 +20,8 @@ function renderText(component: DynamicWorkflowMissionControlComponent, width = 1 return strip(component.render(width).join('\n')); } -/** The STATE cell of a running row: a grey braille spinner frame, then the label. */ -const RUNNING_CELL = /[⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏] RUN/u; +/** Lifecycle progress glyph and label for a running row. */ +const RUNNING_CELL = /[○◔◑◕]\s+RUN/u; /** Head of a task cell that lost the preamble every row shared. */ const TASK_ELISION_MARK = '…'; @@ -138,8 +138,8 @@ describe('DynamicWorkflowMissionControlComponent', () => { component.markCompleted('agent-2', 'Done two'); const output = renderText(component, 120); - expect(memberLine(output, 1)).toContain('✓ DONE'); - expect(memberLine(output, 2)).toContain('✓ DONE'); + expect(memberLine(output, 1)).toContain('● DONE'); + expect(memberLine(output, 2)).toContain('● DONE'); // memberRowCount also counts activity lines, so assert the row's absence. expect(() => memberLine(output, 3)).toThrow(/Missing Dynamic Workflow member 003/u); expect(aggregateLine(output)).toContain('2/2 complete'); @@ -170,7 +170,7 @@ describe('DynamicWorkflowMissionControlComponent', () => { expect(component.applyResult(result)).toBe(true); const output = renderText(component, 120); - expect(memberLine(output, 1)).toContain('✓ DONE'); + expect(memberLine(output, 1)).toContain('● DONE'); expect(output).not.toContain('Unsupported'); }); @@ -221,7 +221,7 @@ describe('DynamicWorkflowMissionControlComponent', () => { expect(component.applyResult(result)).toBe(true); const output = renderText(component, 120); - expect(memberLine(output, 1)).toContain('✓ DONE'); + expect(memberLine(output, 1)).toContain('● DONE'); expect(output).toContain('Accepted result'); expect(output).not.toContain('Out-of-range result'); expect(output).not.toContain('Duplicate result'); @@ -299,10 +299,8 @@ describe('DynamicWorkflowMissionControlComponent', () => { expect(aggregateLine(output)).toContain('2/3 complete'); expect(aggregateLine(output)).not.toMatch(/\b\d+%/u); expect(aggregateLine(output)).not.toContain('━'); - expect(memberLine(output, 1)).toMatch( - /[⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏] RUN\s+Layout hierarchy/u, - ); - expect(memberLine(output, 2)).toMatch(/–\s+✓ DONE\s+Interaction audit/u); + expect(memberLine(output, 1)).toMatch(/[○◔◑◕]\s+RUN\s+Layout hierarchy/u); + expect(memberLine(output, 2)).toMatch(/●\s+DONE\s+Interaction audit/u); expect(output).not.toMatch(/[⣿⣷⣯⣟⡿⢿⣻⣽]{4,}/u); }); @@ -318,7 +316,7 @@ describe('DynamicWorkflowMissionControlComponent', () => { expect(output).toContain('Waiting for delegated agents'); expect(aggregateLine(output)).toMatch(/\b\d+s elapsed\b/); expect(aggregateLine(output)).not.toMatch(/\b\d+%/); - expect(memberLine(output, 1)).toContain('0⚒'); + expect(memberLine(output, 1)).toMatch(/○\s+PEND/u); expect(output).not.toMatch(/[⣿⣷⣯⣟⡿⢿⣻⣽]{4,}/u); expect(aggregateLine(output)).not.toContain('━'); }); @@ -331,6 +329,7 @@ describe('DynamicWorkflowMissionControlComponent', () => { component.markInputComplete(); register(component, 'agent-1'); component.markStarted('agent-1'); + component.render(100); expect(vi.getTimerCount()).toBe(timerCount); }); @@ -362,7 +361,7 @@ describe('DynamicWorkflowMissionControlComponent', () => { } }); - it('spins a grey dot on running rows and shimmers Orchestrating in periwinkle', () => { + it('colours running progress and Orchestrating in periwinkle', () => { vi.useFakeTimers(); vi.setSystemTime(0); const previousLevel = chalk.level; @@ -378,8 +377,6 @@ describe('DynamicWorkflowMissionControlComponent', () => { register(component, 'agent-1'); component.markStarted('agent-1'); - // memberLine expects stripped text; these assertions need the escapes, so - // the row is located by its stripped form and returned coloured. const colouredMemberLine = (): string => { const line = component.render(100).find( (candidate) => strip(candidate).replace(/^│\s*/u, '').startsWith('001'), @@ -389,17 +386,14 @@ describe('DynamicWorkflowMissionControlComponent', () => { }; const first = colouredMemberLine(); - vi.setSystemTime(BRAILLE_SPINNER_INTERVAL_MS); + vi.setSystemTime(BRAILLE_SPINNER_INTERVAL_MS * 2); const second = colouredMemberLine(); - // The dot is grey and it moves; the label keeps the panel's periwinkle. - expect(first).toContain(chalk.hex(darkColors.textDim)('⠋')); - expect(second).toContain(chalk.hex(darkColors.textDim)('⠙')); + expect(first).toContain(chalk.hex(darkColors.primary)('○')); + expect(second).toContain(chalk.hex(darkColors.primary)('◔')); expect(first).toContain(chalk.hex(darkColors.primary)('RUN')); - // The periwinkle it must NOT be: the old dot took the label's colour. - expect(first).not.toContain(chalk.hex(darkColors.primary)('●')); + vi.setSystemTime(BRAILLE_SPINNER_INTERVAL_MS); - // Orchestrating shimmers periwinkle-on-periwinkle, not periwinkle-on-grey. const aggregate = aggregateLine(component.render(100).join('\n')); expect(aggregate).toContain(chalk.hex(darkColors.primary)('rchestrating')); expect(aggregate).not.toContain(chalk.hex(darkColors.text)('rchestrating')); @@ -423,8 +417,8 @@ describe('DynamicWorkflowMissionControlComponent', () => { const output = renderText(component, 120); expect(output).toContain('– Cancelled'); - expect(memberLine(output, 1)).toMatch(/[⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏] RUN\s+Running work/u); - expect(memberLine(output, 2)).toMatch(/◌ WAIT\s+Queued work/); + expect(memberLine(output, 1)).toMatch(/[○◔◑◕]\s+RUN\s+Running work/u); + expect(memberLine(output, 2)).toMatch(/○\s+WAIT\s+Queued work/u); expect(output).not.toContain('– STOP'); expect(output).not.toContain('⠋ Orchestrating'); }); @@ -438,7 +432,7 @@ describe('DynamicWorkflowMissionControlComponent', () => { component.markFailed('agent-1', 'Late failure'); const output = renderText(component, 100); - expect(memberLine(output, 1)).toMatch(/✓ DONE\s+Layout hierarchy/u); + expect(memberLine(output, 1)).toMatch(/●\s+DONE\s+Layout hierarchy/u); expect(output).toContain('Finished first'); expect(output).not.toContain('Late failure'); }); @@ -458,10 +452,10 @@ describe('DynamicWorkflowMissionControlComponent', () => { ].join('\n')); const output = renderText(component, 120); - expect(memberLine(output, 1)).toMatch(/✓ DONE\s+Observed first/u); + expect(memberLine(output, 1)).toMatch(/●\s+DONE\s+Observed first/u); expect(output).toContain('Observed completion'); expect(output).not.toContain('Late result failure'); - expect(memberLine(output, 2)).toMatch(/× FAIL\s+Result-only second/u); + expect(memberLine(output, 2)).toMatch(/×\s+FAIL\s+Result-only second/u); expect(output).toContain('Result failure'); }); @@ -479,7 +473,7 @@ describe('DynamicWorkflowMissionControlComponent', () => { const output = renderText(component, 120); expect(aggregateLine(output)).toContain('1/1 complete'); - expect(memberLine(output, 1)).toContain('✓ DONE'); + expect(memberLine(output, 1)).toContain('● DONE'); expect(output).not.toContain('002'); expect(output).not.toContain('Phantom failure'); expect(output).not.toMatch(/\d+%/u); @@ -495,7 +489,7 @@ describe('DynamicWorkflowMissionControlComponent', () => { component.markSuspended({ agentId: 'agent-1', reason: 'Rate limited' }); const output = renderText(component, 120); - expect(memberLine(output, 1)).toMatch(/! HOLD\s+Throttle-sensitive work/); + expect(memberLine(output, 1)).toMatch(/◑\s+HOLD\s+Throttle-sensitive work/); expect(output).toContain('Rate limited'); expect(output).not.toContain('Stale model progress'); }); @@ -510,7 +504,7 @@ describe('DynamicWorkflowMissionControlComponent', () => { component.markFailed('agent-1', 'Provider exhausted'); const output = renderText(component, 120); - expect(memberLine(output, 1)).toMatch(/× FAIL\s+Failure-sensitive work/); + expect(memberLine(output, 1)).toMatch(/×\s+FAIL\s+Failure-sensitive work/); expect(output).toContain('Provider exhausted'); expect(output).not.toContain('Stale model progress'); }); @@ -552,7 +546,7 @@ describe('DynamicWorkflowMissionControlComponent', () => { it('renders every observed member and request phase without inventing lifecycle events', () => { const pending = createComponent(); pending.updateArgs({}, { streamingArguments: '{"items":["Pending work"' }); - expect(memberLine(renderText(pending, 100), 1)).toMatch(/◌ PEND\s+Pending work/); + expect(memberLine(renderText(pending, 100), 1)).toMatch(/○\s+PEND\s+Pending work/); const component = createComponent(); component.updateArgs({ @@ -569,7 +563,7 @@ describe('DynamicWorkflowMissionControlComponent', () => { component.markCancelled('agent-6'); const output = renderText(component, 140); - for (const token of ['◌ WAIT', '! HOLD', '✓ DONE', '× FAIL', '– STOP']) { + for (const token of ['○ WAIT', '◑ HOLD', '● DONE', '× FAIL', '– STOP']) { expect(output).toContain(token); } // Running is the one animated phase, so its symbol varies by frame. @@ -599,40 +593,24 @@ describe('DynamicWorkflowMissionControlComponent', () => { }, ); - it('counts real work and shows how long a row has been silent', () => { + it('renders lifecycle progress without a percentage, work count, or idle age', () => { vi.useFakeTimers(); vi.setSystemTime(0); - try { - const component = createComponent(); - component.updateArgs({ items: ['Live work'] }); - component.markInputComplete(); - component.registerSubagent({ agentId: 'agent-1' }); - component.markStarted('agent-1'); - - // No percentage anywhere: nothing knows how many steps an agent will take. - expect(renderText(component, 100)).not.toMatch(/\b\d+%/u); - - component.recordToolCall({ agentId: 'agent-1', name: 'Read' }); - component.recordToolCall({ agentId: 'agent-1', name: 'Bash' }); - expect(memberLine(renderText(component, 100), 1)).toMatch(/2⚒\s+0s/u); - - // The old bar froze at 75% here; the idle age keeps moving instead. - vi.setSystemTime(45_000); - expect(memberLine(renderText(component, 100), 1)).toMatch(/2⚒\s+45s/u); - - // Any observed event resets the silence, tool call or streamed text. - component.appendModelDelta({ agentId: 'agent-1', delta: 'Summarizing' }); - expect(memberLine(renderText(component, 100), 1)).toMatch(/2⚒\s+0s/u); + const component = createComponent(); + component.updateArgs({ items: ['Live work', 'Queued work'] }); + component.markInputComplete(); + component.registerSubagent({ agentId: 'agent-1', dynamicWorkflowIndex: 1 }); + component.markStarted('agent-1'); - // A finished row has no idle age to report. - component.markCompleted('agent-1', 'Done'); - expect(memberLine(renderText(component, 100), 1)).toMatch(/2⚒\s+–\s+✓ DONE/u); - } finally { - vi.useRealTimers(); - } + const running = renderText(component, 100); + expect(running).toContain('PROGRESS'); + expect(running).not.toContain('WORK IDLE'); + expect(memberLine(running, 1)).toMatch(/○\s+RUN\s+Live work/u); + expect(memberLine(running, 2)).toMatch(/○\s+WAIT\s+Queued work/u); + expect(running).not.toMatch(/\b\d+%|⚒|━/u); }); - it('colours a silent row amber, then red once it has almost certainly stalled', () => { + it('fills the running circle from the shared workflow clock and freezes completion', () => { vi.useFakeTimers(); vi.setSystemTime(0); const previousLevel = chalk.level; @@ -646,99 +624,64 @@ describe('DynamicWorkflowMissionControlComponent', () => { component.markInputComplete(); component.registerSubagent({ agentId: 'agent-1' }); component.markStarted('agent-1'); - component.recordToolCall({ agentId: 'agent-1', name: 'Bash' }); - const workCell = (): string => { - const line = component.render(100).find( - (candidate) => strip(candidate).replace(/^│\s*/u, '').startsWith('001'), - ); - if (line === undefined) throw new Error('Missing Dynamic Workflow member 001'); - return line; - }; + for (const [time, glyph] of [[0, '○'], [160, '◔'], [320, '◑'], [480, '◕']] as const) { + vi.setSystemTime(time); + expect(memberLine(renderText(component, 100), 1)).toContain(glyph); + } - expect(workCell()).toContain(chalk.hex(darkColors.textMuted)(' 0s')); - vi.setSystemTime(DYNAMIC_WORKFLOW_RENDERING.quietIdleMs); - expect(workCell()).toContain(chalk.hex(darkColors.warning)(' 60s')); - vi.setSystemTime(DYNAMIC_WORKFLOW_RENDERING.stalledIdleMs); - expect(workCell()).toContain(chalk.hex(darkColors.error)('180s')); + component.markCompleted('agent-1', 'Done'); + const completed = component.render(100).find((line) => strip(line).includes('001')); + expect(completed).toContain(chalk.hex(darkColors.success)('●')); + vi.setSystemTime(10_000); + expect(memberLine(renderText(component, 100), 1)).toMatch(/●\s+DONE/u); } finally { - vi.useRealTimers(); chalk.level = previousLevel; currentTheme.setPalette(previousPalette); } }); - it('keeps a suspended row muted however long it stays silent', () => { + it('renders lifecycle states without colour support', () => { vi.useFakeTimers(); - vi.setSystemTime(0); + vi.setSystemTime(160); const previousLevel = chalk.level; - const previousPalette = currentTheme.palette; - chalk.level = 3; - currentTheme.setPalette(darkColors); + chalk.level = 0; try { const component = createComponent(); - component.updateArgs({ items: ['Held work'] }); + component.updateArgs({ items: ['Queued work', 'Live work', 'Done work'] }); component.markInputComplete(); - component.registerSubagent({ agentId: 'agent-1' }); - component.markStarted('agent-1'); - // The last event lands a minute in, so the idle age and the elapsed age - // read as different numbers and the assertion cannot match the wrong cell. - vi.setSystemTime(60_000); - component.recordToolCall({ agentId: 'agent-1', name: 'Bash' }); - component.markSuspended({ agentId: 'agent-1', reason: 'Waiting for approval' }); - - // A suspended agent waits on the user by design, so its silence is not a - // stall and must never borrow the alarm colours. - vi.setSystemTime(60_000 + DYNAMIC_WORKFLOW_RENDERING.stalledIdleMs * 2); - const line = component.render(100).find( - (candidate) => strip(candidate).replace(/^│\s*/u, '').startsWith('001'), - ); - if (line === undefined) throw new Error('Missing Dynamic Workflow member 001'); - expect(strip(line)).toContain('1⚒ 360s'); - expect(line).toContain(chalk.hex(darkColors.textMuted)('360s')); + component.registerSubagent({ agentId: 'agent-live', dynamicWorkflowIndex: 2 }); + component.markStarted('agent-live'); + component.registerSubagent({ agentId: 'agent-done', dynamicWorkflowIndex: 3 }); + component.markCompleted('agent-done', 'Done'); + + const output = renderText(component, 100); + expect(memberLine(output, 1)).toMatch(/○\s+WAIT/u); + expect(memberLine(output, 2)).toMatch(/[○◔◑◕]\s+RUN/u); + expect(memberLine(output, 3)).toMatch(/●\s+DONE/u); } finally { - vi.useRealTimers(); chalk.level = previousLevel; - currentTheme.setPalette(previousPalette); } }); - it('never marks a row that has not started as stalled', () => { - vi.useFakeTimers(); - vi.setSystemTime(0); - const previousLevel = chalk.level; - const previousPalette = currentTheme.palette; - chalk.level = 3; - currentTheme.setPalette(darkColors); - - try { - const component = createComponent(); - component.updateArgs({ items: ['First', 'Second'] }); - component.markInputComplete(); - component.registerSubagent({ agentId: 'agent-1' }); - component.registerSubagent({ agentId: 'agent-2' }); - component.markStarted('agent-1'); - - // A queued row waits behind the concurrency limit; its clock would run - // from the launch of the workflow, so a long queue used to paint every - // waiting row red while nothing was wrong. - vi.setSystemTime(DYNAMIC_WORKFLOW_RENDERING.stalledIdleMs * 2); - const queued = memberLine(renderText(component, 100), 2); - expect(queued).toMatch(/0⚒\s+–\s+◌ WAIT/u); - expect(queued).not.toMatch(/\d+s/u); + it('centres lifecycle glyphs in one fixed progress column', () => { + const component = createComponent(); + component.updateArgs({ items: ['Queued work', 'Live work', 'Done work'] }); + component.markInputComplete(); + component.registerSubagent({ agentId: 'agent-live', dynamicWorkflowIndex: 2 }); + component.markStarted('agent-live'); + component.registerSubagent({ agentId: 'agent-done', dynamicWorkflowIndex: 3 }); + component.markCompleted('agent-done', 'Done'); - const queuedRaw = component.render(100).find( - (candidate) => strip(candidate).replace(/^│\s*/u, '').startsWith('002'), - ); - expect(queuedRaw).toContain(chalk.hex(darkColors.textMuted)(' –')); - // The running row still reports its silence, so the alarm is not simply gone. - expect(memberLine(renderText(component, 100), 1)).toMatch(/0⚒\s+\d+s/u); - } finally { - vi.useRealTimers(); - chalk.level = previousLevel; - currentTheme.setPalette(previousPalette); - } + const output = renderText(component, 100); + const glyphColumns = [ + memberLine(output, 1).indexOf('○'), + memberLine(output, 2).search(/[○◔◑◕]/u), + memberLine(output, 3).indexOf('●'), + ]; + expect(glyphColumns[0]).toBeGreaterThan(0); + expect(new Set(glyphColumns).size).toBe(1); }); it('reports aggregate completion counts without estimating overall progress', () => { @@ -770,7 +713,7 @@ describe('DynamicWorkflowMissionControlComponent', () => { const lines = renderText(component, 100).split('\n'); expect(lines).toHaveLength(6); - expect(memberLine(lines.join('\n'), 1)).toMatch(/◌ WAIT\s+One/u); + expect(memberLine(lines.join('\n'), 1)).toMatch(/○\s+WAIT\s+One/u); expect(lines.join('\n')).toContain('+ 4 more agents'); expect(lines.join('\n')).not.toContain('Recent activity'); }); @@ -885,48 +828,42 @@ describe('DynamicWorkflowMissionControlComponent', () => { }); it.each([20, 40, 63, 64, 79, 80, 100])( - 'keeps identity before current work and time at width %i without overflow', + 'keeps progress and task columns aligned at width %i', (width) => { const component = prepareObservedWorkflow(); const rendered = component.render(width); const output = strip(rendered.join('\n')); - const showsWork = width >= 64; expect(rendered.every((line) => visibleWidth(line) <= width)).toBe(true); - expect(memberLine(output, 1)).toMatch(RUNNING_CELL); - // The work cell is the first thing dropped when the frame gets narrow. - expect(/\d⚒/u.test(memberLine(output, 1))).toBe(showsWork); - expect(output.includes('WORK IDLE')).toBe(showsWork); + expect(memberLine(output, 1)).toMatch(/[○◔◑◕]\s+RUN/u); + expect(output.includes('PROGRESS')).toBe(width >= 64); + expect(output.includes('STATUS')).toBe(width >= 21 && width < 64); + expect(output).not.toContain('WORK IDLE'); }, ); - it('counts tool calls as work and streamed text only as liveness', () => { + it('keeps progress independent of tool calls and streamed text', () => { vi.useFakeTimers(); vi.setSystemTime(0); - try { - const component = createComponent(); - component.updateArgs({ items: ['Long streaming work'] }); - component.markInputComplete(); - register(component, 'agent-1'); - component.markStarted('agent-1'); - component.recordToolCall({ agentId: 'agent-1', name: 'Read' }); - - vi.setSystemTime(30_000); - for (let index = 0; index < 200; index += 1) { - component.appendModelDelta({ agentId: 'agent-1', delta: `chunk ${String(index)} ` }); - } - - // 200 deltas are not 200 units of work — the count tracks tool calls only. - // But they prove the agent is alive, so the idle age resets. - const line = memberLine(renderText(component, 100), 1); - expect(line).toMatch(/1⚒\s+0s/u); - expect(renderText(component, 100)).not.toMatch(/\b\d+%/u); + const component = createComponent(); + component.updateArgs({ items: ['Long streaming work'] }); + component.markInputComplete(); + register(component, 'agent-1'); + component.markStarted('agent-1'); - component.recordToolCall({ agentId: 'agent-1', name: 'Bash' }); - expect(memberLine(renderText(component, 100), 1)).toMatch(/2⚒/u); - } finally { - vi.useRealTimers(); + vi.setSystemTime(30_000); + const before = memberLine(renderText(component, 100), 1).match(/[○◔◑◕]/u)?.[0]; + if (before === undefined) throw new Error('Missing lifecycle progress glyph'); + component.recordToolCall({ agentId: 'agent-1', name: 'Read' }); + for (let index = 0; index < 200; index += 1) { + component.appendModelDelta({ agentId: 'agent-1', delta: `chunk ${String(index)} ` }); } + component.recordToolCall({ agentId: 'agent-1', name: 'Bash' }); + + const output = renderText(component, 100); + expect(before).toMatch(/[○◔◑◕]/u); + expect(memberLine(output, 1)).toContain(before); + expect(output).not.toMatch(/\b\d+%|⚒/u); }); it('starts a new line for model text after a tool label instead of fusing them', () => { @@ -1104,7 +1041,7 @@ describe('DynamicWorkflowMissionControlComponent', () => { const unframe = (line: string) => line.replace(/^│ /u, ''); const running = unframe(memberLine(output, 1)); const completed = unframe(memberLine(output, 2)); - const headerLine = output.split('\n').find((line) => line.includes('STATE')); + const headerLine = output.split('\n').find((line) => line.includes('STATUS')); if (headerLine === undefined) throw new Error('Missing Dynamic Workflow table header'); const header = unframe(headerLine); From e12c56266959deb2aa0443a01e902f80b8e791c5 Mon Sep 17 00:00:00 2001 From: elkaix Date: Tue, 11 Aug 2026 14:19:47 -0400 Subject: [PATCH 02/10] fix(tui): preserve workflow lifecycle progress --- .../dynamic-workflow-mission-control.ts | 21 ++++++++-- .../dynamic-workflow-mission-control.test.ts | 39 +++++++++++++++++++ 2 files changed, 56 insertions(+), 4 deletions(-) diff --git a/apps/pythinker-code/src/tui/components/messages/dynamic-workflow-mission-control.ts b/apps/pythinker-code/src/tui/components/messages/dynamic-workflow-mission-control.ts index bcca97dd..12a831ef 100644 --- a/apps/pythinker-code/src/tui/components/messages/dynamic-workflow-mission-control.ts +++ b/apps/pythinker-code/src/tui/components/messages/dynamic-workflow-mission-control.ts @@ -261,13 +261,21 @@ export class DynamicWorkflowMissionControlComponent implements Component { this.recordActivity(member.index, 'Started'); } + private markStartedFromActivity(member: DynamicWorkflowMember): void { + if (member.phase !== 'pending' && member.phase !== 'queued') return; + member.phase = 'running'; + member.startedAtMs ??= Date.now(); + delete member.statusDetail; + this.recordActivity(member.index, 'Started'); + } + recordToolCall(input: { readonly agentId: string; readonly name?: string; }): void { const member = this.findMemberByAgentId(input.agentId); if (member === undefined || isTerminalPhase(member.phase)) return; - this.markStarted(input.agentId); + this.markStartedFromActivity(member); 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. @@ -277,7 +285,7 @@ export class DynamicWorkflowMissionControlComponent implements Component { 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); + this.markStartedFromActivity(member); 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 @@ -926,7 +934,8 @@ function parseDynamicWorkflowResultStatuses(output: string): DynamicWorkflowResu 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. @@ -948,7 +957,11 @@ function parseDynamicWorkflowResultStatuses(output: string): DynamicWorkflowResu 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)), }); } diff --git a/apps/pythinker-code/test/tui/components/messages/dynamic-workflow-mission-control.test.ts b/apps/pythinker-code/test/tui/components/messages/dynamic-workflow-mission-control.test.ts index 5609516a..ac7b097c 100644 --- a/apps/pythinker-code/test/tui/components/messages/dynamic-workflow-mission-control.test.ts +++ b/apps/pythinker-code/test/tui/components/messages/dynamic-workflow-mission-control.test.ts @@ -124,6 +124,28 @@ describe('DynamicWorkflowMissionControlComponent', () => { } }); + it('maps schema errors to failed rows without shifting later results', () => { + const result = [ + '', + 'Invalid structured output', + 'Valid result', + '', + ].join('\n'); + const component = createComponent(); + component.updateArgs({ items: ['Schema work', 'Normal work'] }); + component.markInputComplete(); + + expect(dynamicWorkflowResultSummaryFromOutput(result)).toEqual({ + completed: 1, + failed: 1, + aborted: 0, + parsed: true, + }); + expect(component.applyResult(result)).toBe(true); + expect(memberLine(renderText(component, 120), 1)).toMatch(/×\s+FAIL\s+Schema work/u); + expect(memberLine(renderText(component, 120), 2)).toMatch(/●\s+DONE\s+Normal work/u); + }); + it('ignores blank items so no phantom row waits forever', () => { const component = createComponent(); // The engine drops the blank before launching anything, so counting it here @@ -479,6 +501,23 @@ describe('DynamicWorkflowMissionControlComponent', () => { expect(output).not.toMatch(/\d+%/u); }); + it('keeps late activity suspended until a lifecycle start resumes it', () => { + const component = createComponent(); + component.updateArgs({ items: ['Rate-limited work'] }); + component.markInputComplete(); + component.registerSubagent({ agentId: 'agent-1' }); + component.markStarted('agent-1'); + component.markSuspended({ agentId: 'agent-1', reason: 'Rate limited' }); + + component.appendModelDelta({ agentId: 'agent-1', delta: 'Late output' }); + component.recordToolCall({ agentId: 'agent-1', name: 'Read' }); + expect(memberLine(renderText(component, 100), 1)).toMatch(/◑\s+HOLD/u); + expect(renderText(component, 100)).toContain('Rate limited'); + + component.markStarted('agent-1'); + expect(memberLine(renderText(component, 100), 1)).toMatch(/[○◔◑◕]\s+RUN/u); + }); + it('prefers a suspension detail over stale model progress in the member row', () => { const component = createComponent({ availableRows: () => 5 }); component.updateArgs({ items: ['Throttle-sensitive work'] }); From 0c14710078a40fea0e9ab38a5bbbfb8428bd49d8 Mon Sep 17 00:00:00 2001 From: elkaix Date: Tue, 11 Aug 2026 14:27:36 -0400 Subject: [PATCH 03/10] docs: describe dynamic workflow progress --- .changeset/workflow-progress.md | 5 +++++ docs/reference/tools.md | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) create mode 100644 .changeset/workflow-progress.md diff --git a/.changeset/workflow-progress.md b/.changeset/workflow-progress.md new file mode 100644 index 00000000..a244a33d --- /dev/null +++ b/.changeset/workflow-progress.md @@ -0,0 +1,5 @@ +--- +"@pythoughts/pythinker-code": patch +--- + +Show indeterminate lifecycle progress for Dynamic Workflow rows in the TUI. diff --git a/docs/reference/tools.md b/docs/reference/tools.md index 78de4798..d6f0151e 100644 --- a/docs/reference/tools.md +++ b/docs/reference/tools.md @@ -103,7 +103,7 @@ If a model response calls `DynamicWorkflow`, that call must be the only tool cal In `manual` and `auto` permission modes a `DynamicWorkflow` call requests approval, and that approval shows the plan it is about to run — the description, the subagent type, the prompt template, and every item. Approving for the session is keyed to that exact plan, so a later call that swaps in a different item list asks again; `yolo` approves without asking. Permission rules match `DynamicWorkflow` on the plan, or on `model:` for the model a call asks its subagents to run on, so `DynamicWorkflow(model:some-model)` gates the model a fan-out may use. -In the TUI, a foreground workflow shows a live framed mission-control panel with a coral title. The panel lists one row per subagent with its work count, idle age, state, task, current work, and elapsed time, followed by a recent-activity log. The work count is the number of tool calls the subagent has made and the idle age is how long it has been silent, turning amber after 60 seconds and red after 180; neither predicts time remaining, because nothing knows how many steps a subagent will take. The summary reports only factual completion, failure, and cancellation counts plus elapsed time, without an estimated aggregate percentage or progress bar. When every task starts with the same preamble — which happens when `prompt_template` is left empty and each item carries a whole prompt — the shared opening is dropped from every row and replaced by a leading `…`, so the part that names the row is what stays on screen. In a narrow terminal the work and idle columns are dropped before subagent identity or state; when vertical space runs out, rows are clipped in workflow-index order and the remainder is summarized as `+ N more agents`. +In the TUI, a foreground workflow shows a live framed mission-control panel with a coral title. Each subagent row shows an indeterminate circular progress glyph, its lifecycle state, task, current work, and elapsed time. Running rows cycle through partial circles; a completed row becomes a fixed solid green circle. Pending, held, failed, and cancelled rows keep distinct glyphs and text states. The animation reports observed lifecycle activity, not percent complete or time remaining. The summary reports factual completion, failure, and cancellation counts plus elapsed time, without an estimated aggregate percentage or progress bar. When every task starts with the same preamble — which happens when `prompt_template` is left empty and each item carries a whole prompt — the shared opening is dropped from every row and replaced by a leading `…`, so the part that names the row is what stays on screen. In a narrow terminal the work and idle columns are dropped before subagent identity or state; when vertical space runs out, rows are clipped in workflow-index order and the remainder is summarized as `+ N more agents`. **`AskUserQuestion`** asks the user a structured multiple-choice question — useful for disambiguation or option selection. The `questions` parameter accepts 1–4 questions; each question requires `question` (ending with `?`), `options` (2–4 choices, each with a `label` and `description`), and optional `header` (max 12 characters) and `multi_select` (defaults to false). An "Other" option is appended automatically. Setting `background` to true starts a background question task and returns a task ID immediately. When the host does not support interactive questioning, a failure message is returned and the Agent should ask the user directly in a text reply instead. From 287bb2357c2a343aa79c448a08cc26d5cb401a33 Mon Sep 17 00:00:00 2001 From: elkaix Date: Tue, 11 Aug 2026 14:39:37 -0400 Subject: [PATCH 04/10] docs: clarify workflow progress columns --- docs/reference/tools.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/reference/tools.md b/docs/reference/tools.md index d6f0151e..d6c38949 100644 --- a/docs/reference/tools.md +++ b/docs/reference/tools.md @@ -103,7 +103,7 @@ If a model response calls `DynamicWorkflow`, that call must be the only tool cal In `manual` and `auto` permission modes a `DynamicWorkflow` call requests approval, and that approval shows the plan it is about to run — the description, the subagent type, the prompt template, and every item. Approving for the session is keyed to that exact plan, so a later call that swaps in a different item list asks again; `yolo` approves without asking. Permission rules match `DynamicWorkflow` on the plan, or on `model:` for the model a call asks its subagents to run on, so `DynamicWorkflow(model:some-model)` gates the model a fan-out may use. -In the TUI, a foreground workflow shows a live framed mission-control panel with a coral title. Each subagent row shows an indeterminate circular progress glyph, its lifecycle state, task, current work, and elapsed time. Running rows cycle through partial circles; a completed row becomes a fixed solid green circle. Pending, held, failed, and cancelled rows keep distinct glyphs and text states. The animation reports observed lifecycle activity, not percent complete or time remaining. The summary reports factual completion, failure, and cancellation counts plus elapsed time, without an estimated aggregate percentage or progress bar. When every task starts with the same preamble — which happens when `prompt_template` is left empty and each item carries a whole prompt — the shared opening is dropped from every row and replaced by a leading `…`, so the part that names the row is what stays on screen. In a narrow terminal the work and idle columns are dropped before subagent identity or state; when vertical space runs out, rows are clipped in workflow-index order and the remainder is summarized as `+ N more agents`. +In the TUI, a foreground workflow shows a live framed mission-control panel with a coral title. Each subagent row shows an indeterminate circular progress glyph, its lifecycle state, task, current work, and elapsed time. Running rows cycle through partial circles; a completed row becomes a fixed solid green circle. Pending, held, failed, and cancelled rows keep distinct glyphs and text states. The animation reports observed lifecycle activity, not percent complete or time remaining. The summary reports factual completion, failure, and cancellation counts plus elapsed time, without an estimated aggregate percentage or progress bar. When every task starts with the same preamble — which happens when `prompt_template` is left empty and each item carries a whole prompt — the shared opening is dropped from every row and replaced by a leading `…`, so the part that names the row is what stays on screen. Compact terminals combine the glyph and lifecycle label under `STATUS`; wide terminals show separate `PROGRESS` and `STATE` columns. When vertical space runs out, rows are clipped in workflow-index order and the remainder is summarized as `+ N more agents`. **`AskUserQuestion`** asks the user a structured multiple-choice question — useful for disambiguation or option selection. The `questions` parameter accepts 1–4 questions; each question requires `question` (ending with `?`), `options` (2–4 choices, each with a `label` and `description`), and optional `header` (max 12 characters) and `multi_select` (defaults to false). An "Other" option is appended automatically. Setting `background` to true starts a background question task and returns a task ID immediately. When the host does not support interactive questioning, a failure message is returned and the Agent should ask the user directly in a text reply instead. From b400af691895746adf084932a09181bbbb26eaf0 Mon Sep 17 00:00:00 2001 From: elkaix Date: Tue, 11 Aug 2026 14:49:51 -0400 Subject: [PATCH 05/10] perf(tui): streamline workflow progress rendering --- .../dynamic-workflow-mission-control.ts | 26 +++++++------------ .../dynamic-workflow-mission-control.test.ts | 22 +++++++++++----- 2 files changed, 24 insertions(+), 24 deletions(-) diff --git a/apps/pythinker-code/src/tui/components/messages/dynamic-workflow-mission-control.ts b/apps/pythinker-code/src/tui/components/messages/dynamic-workflow-mission-control.ts index 12a831ef..c4b7b9bc 100644 --- a/apps/pythinker-code/src/tui/components/messages/dynamic-workflow-mission-control.ts +++ b/apps/pythinker-code/src/tui/components/messages/dynamic-workflow-mission-control.ts @@ -261,13 +261,6 @@ export class DynamicWorkflowMissionControlComponent implements Component { this.recordActivity(member.index, 'Started'); } - private markStartedFromActivity(member: DynamicWorkflowMember): void { - if (member.phase !== 'pending' && member.phase !== 'queued') return; - member.phase = 'running'; - member.startedAtMs ??= Date.now(); - delete member.statusDetail; - this.recordActivity(member.index, 'Started'); - } recordToolCall(input: { readonly agentId: string; @@ -275,7 +268,7 @@ export class DynamicWorkflowMissionControlComponent implements Component { }): void { const member = this.findMemberByAgentId(input.agentId); if (member === undefined || isTerminalPhase(member.phase)) return; - this.markStartedFromActivity(member); + 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. @@ -285,7 +278,7 @@ export class DynamicWorkflowMissionControlComponent implements Component { 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.markStartedFromActivity(member); + 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 @@ -604,15 +597,14 @@ export class DynamicWorkflowMissionControlComponent implements Component { Math.max(0, nowMs - this.model.startedAtMs) / DYNAMIC_WORKFLOW_PROGRESS_FRAME_MS, ); const showProgress = width >= DYNAMIC_WORKFLOW_RENDERING.memberProgressMinWidth; - const progressColumn = centerToWidth( - renderProgressGlyph(member.phase, frame), - DYNAMIC_WORKFLOW_RENDERING.memberProgressWidth, - ); - const stateColumn = padToWidth(renderStateLabel(member.phase), STATE_COLUMN_WIDTH); - const compactStatus = padToWidth(renderCompactStatus(member.phase, frame), STATE_COLUMN_WIDTH); const prefix = showProgress - ? `${id} ${progressColumn} ${stateColumn} ` - : `${id} ${compactStatus} `; + ? `${id} ${ + centerToWidth( + renderProgressGlyph(member.phase, frame), + DYNAMIC_WORKFLOW_RENDERING.memberProgressWidth, + ) + } ${padToWidth(renderStateLabel(member.phase), STATE_COLUMN_WIDTH)} ` + : `${id} ${padToWidth(renderCompactStatus(member.phase, frame), STATE_COLUMN_WIDTH)} `; 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. diff --git a/apps/pythinker-code/test/tui/components/messages/dynamic-workflow-mission-control.test.ts b/apps/pythinker-code/test/tui/components/messages/dynamic-workflow-mission-control.test.ts index ac7b097c..74e7f2c4 100644 --- a/apps/pythinker-code/test/tui/components/messages/dynamic-workflow-mission-control.test.ts +++ b/apps/pythinker-code/test/tui/components/messages/dynamic-workflow-mission-control.test.ts @@ -866,17 +866,25 @@ describe('DynamicWorkflowMissionControlComponent', () => { expect(output).not.toContain('001 +1s Agent spawned'); }); - it.each([20, 40, 63, 64, 79, 80, 100])( + it.each([ + [20, false, false], + [40, false, true], + [63, false, true], + [64, true, false], + [79, true, false], + [80, true, false], + [100, true, false], + ] as const)( 'keeps progress and task columns aligned at width %i', - (width) => { + (width, expectedProgress, expectedStatus) => { const component = prepareObservedWorkflow(); const rendered = component.render(width); const output = strip(rendered.join('\n')); expect(rendered.every((line) => visibleWidth(line) <= width)).toBe(true); expect(memberLine(output, 1)).toMatch(/[○◔◑◕]\s+RUN/u); - expect(output.includes('PROGRESS')).toBe(width >= 64); - expect(output.includes('STATUS')).toBe(width >= 21 && width < 64); + expect(output.includes('PROGRESS')).toBe(expectedProgress); + expect(output.includes('STATUS')).toBe(expectedStatus); expect(output).not.toContain('WORK IDLE'); }, ); @@ -892,7 +900,6 @@ describe('DynamicWorkflowMissionControlComponent', () => { vi.setSystemTime(30_000); const before = memberLine(renderText(component, 100), 1).match(/[○◔◑◕]/u)?.[0]; - if (before === undefined) throw new Error('Missing lifecycle progress glyph'); component.recordToolCall({ agentId: 'agent-1', name: 'Read' }); for (let index = 0; index < 200; index += 1) { component.appendModelDelta({ agentId: 'agent-1', delta: `chunk ${String(index)} ` }); @@ -900,8 +907,9 @@ describe('DynamicWorkflowMissionControlComponent', () => { component.recordToolCall({ agentId: 'agent-1', name: 'Bash' }); const output = renderText(component, 100); - expect(before).toMatch(/[○◔◑◕]/u); - expect(memberLine(output, 1)).toContain(before); + const after = memberLine(output, 1).match(/[○◔◑◕]/u)?.[0]; + expect(before).toBeDefined(); + expect(after).toBe(before); expect(output).not.toMatch(/\b\d+%|⚒/u); }); From c6235674ea896c5835287daec9376464d9d15759 Mon Sep 17 00:00:00 2001 From: elkaix Date: Tue, 11 Aug 2026 14:56:54 -0400 Subject: [PATCH 06/10] test(tui): update workflow progress assertions --- .../tui/pythinker-tui-message-flow.test.ts | 20 +++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/apps/pythinker-code/test/tui/pythinker-tui-message-flow.test.ts b/apps/pythinker-code/test/tui/pythinker-tui-message-flow.test.ts index af26177d..6f64fb42 100644 --- a/apps/pythinker-code/test/tui/pythinker-tui-message-flow.test.ts +++ b/apps/pythinker-code/test/tui/pythinker-tui-message-flow.test.ts @@ -1779,7 +1779,7 @@ command = "vim" ); transcript = stripSgr(renderTranscript(driver)); - expect(transcript).toMatch(/001\s+0⚒\s+–\s+◌ WAIT\s+Fresh work/u); + expect(transcript).toMatch(/001\s+○\s+WAIT\s+Fresh work/u); expect(transcript).not.toContain('Late completion from undone work'); }); @@ -3943,9 +3943,9 @@ command = "vim" const transcript = stripSgr(renderTranscript(driver)); expect(transcript).toContain('Dynamic Workflow'); - // The running row spins a grey braille dot, so its symbol varies by frame. - expect(transcript).toMatch(/001\s+\d+⚒\s+\d+s\s+[⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏] RUN\s+src\/a.ts/u); - expect(transcript).toMatch(/002\s+\d+⚒\s+–\s+✓ DONE\s+src\/b.ts/u); + // The running row advances through the approved progress-glyph frames. + expect(transcript).toMatch(/001\s+[○◔◑◕]\s+RUN\s+src\/a.ts/u); + expect(transcript).toMatch(/002\s+●\s+DONE\s+src\/b.ts/u); expect(transcript).toMatch(/Orchestrating\s+1\/2 complete/u); expect(transcript).not.toContain('━'); expect(transcript).toContain('Completed before spawn'); @@ -4030,7 +4030,7 @@ command = "vim" transcript = stripSgr(renderTranscript(driver)); expect(transcript).toContain('0/2 complete'); - expect(transcript).toMatch(/001\s+0⚒\s+–\s+◌ WAIT\s+src\/a.ts/u); + expect(transcript).toMatch(/001\s+○\s+WAIT\s+src\/a.ts/u); }); it('keeps terminal Dynamic Workflow results static and does not fabricate child failures', async () => { @@ -4057,8 +4057,8 @@ command = "vim" const transcript = stripSgr(renderTranscript(driver)); expect(transcript).toContain('✓ Completed'); - expect(transcript).toMatch(/001\s+\d+⚒\s+–\s+✓ DONE\s+src\/a.ts/u); - expect(transcript).toMatch(/002\s+\d+⚒\s+–\s+× FAIL\s+src\/b.ts/u); + expect(transcript).toMatch(/001\s+●\s+DONE\s+src\/a.ts/u); + expect(transcript).toMatch(/002\s+×\s+FAIL\s+src\/b.ts/u); expect(transcript).toContain('Agent timed out after 30s.'); expect(transcript).not.toContain('⠋ Orchestrating'); }); @@ -4099,7 +4099,7 @@ command = "vim" } as Event); const transcript = stripSgr(renderTranscript(driver)); - expect(transcript).toMatch(/001\s+0⚒\s+–\s+◌ WAIT\s+src\/fresh.ts/u); + expect(transcript).toMatch(/001\s+○\s+WAIT\s+src\/fresh.ts/u); expect(transcript).not.toContain('must not leak'); }, ); @@ -4192,7 +4192,7 @@ command = "vim" } as Event); const transcript = stripSgr(renderTranscript(driver)); - expect(transcript).toMatch(/001\s+\d+⚒\s+–\s+× FAIL\s+src\/generic.ts/u); + expect(transcript).toMatch(/001\s+×\s+FAIL\s+src\/generic.ts/u); expect(transcript).toContain('Early generic failure'); }); @@ -4218,7 +4218,7 @@ command = "vim" const transcript = stripSgr(renderTranscript(driver)); expect(transcript).toContain('× Failed'); - expect(transcript).toMatch(/001\s+\d+⚒\s+–\s+✓ DONE\s+src\/a.ts/u); + expect(transcript).toMatch(/001\s+●\s+DONE\s+src\/a.ts/u); expect(transcript).toContain('Child completed before request error'); }); From d8b38880e518cf04fea64f132e45f488c38277be Mon Sep 17 00:00:00 2001 From: elkaix Date: Tue, 11 Aug 2026 15:26:46 -0400 Subject: [PATCH 07/10] fix(tui): refine workflow progress animation --- .../dynamic-workflow-mission-control.ts | 21 +++---- .../src/tui/constant/rendering.ts | 6 ++ .../dynamic-workflow-mission-control.test.ts | 61 ++++++++++--------- .../tui/pythinker-tui-message-flow.test.ts | 8 +-- docs/reference/tools.md | 2 +- 5 files changed, 50 insertions(+), 48 deletions(-) diff --git a/apps/pythinker-code/src/tui/components/messages/dynamic-workflow-mission-control.ts b/apps/pythinker-code/src/tui/components/messages/dynamic-workflow-mission-control.ts index c4b7b9bc..447a2d58 100644 --- a/apps/pythinker-code/src/tui/components/messages/dynamic-workflow-mission-control.ts +++ b/apps/pythinker-code/src/tui/components/messages/dynamic-workflow-mission-control.ts @@ -98,10 +98,6 @@ export interface DynamicWorkflowMissionControlOptions { readonly availableRows?: () => number | undefined; } -const DYNAMIC_WORKFLOW_PROGRESS_FRAMES = ['○', '◔', '◑', '◕'] as const; -const DYNAMIC_WORKFLOW_PROGRESS_FRAME_MS = BRAILLE_SPINNER_INTERVAL_MS * 2; -const STATE_COLUMN_WIDTH = 6; - const PHASE_LABELS: Record = { pending: 'PEND', queued: 'WAIT', @@ -116,7 +112,7 @@ const PHASE_GLYPHS: Record, string> = { pending: '○', queued: '○', suspended: '◑', - completed: '●', + completed: '✓', failed: '×', cancelled: '–', }; @@ -261,7 +257,6 @@ export class DynamicWorkflowMissionControlComponent implements Component { this.recordActivity(member.index, 'Started'); } - recordToolCall(input: { readonly agentId: string; readonly name?: string; @@ -577,10 +572,10 @@ export class DynamicWorkflowMissionControlComponent implements Component { ? [ padToWidth('ID', 3), padToWidth('PROGRESS', DYNAMIC_WORKFLOW_RENDERING.memberProgressWidth), - padToWidth('STATE', STATE_COLUMN_WIDTH), + padToWidth('STATE', DYNAMIC_WORKFLOW_RENDERING.stateColumnWidth), 'TASK', ].join(' ') - : `${padToWidth('ID', 3)} ${padToWidth('STATUS', STATE_COLUMN_WIDTH)} TASK`; + : `${padToWidth('ID', 3)} ${padToWidth('STATUS', DYNAMIC_WORKFLOW_RENDERING.stateColumnWidth)} TASK`; return truncateToWidth(currentTheme.fg('textDim', header), width); } @@ -594,7 +589,7 @@ export class DynamicWorkflowMissionControlComponent implements Component { // 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 frame = Math.floor( - Math.max(0, nowMs - this.model.startedAtMs) / DYNAMIC_WORKFLOW_PROGRESS_FRAME_MS, + Math.max(0, nowMs - this.model.startedAtMs) / DYNAMIC_WORKFLOW_RENDERING.progressFrameMs, ); const showProgress = width >= DYNAMIC_WORKFLOW_RENDERING.memberProgressMinWidth; const prefix = showProgress @@ -603,8 +598,8 @@ export class DynamicWorkflowMissionControlComponent implements Component { renderProgressGlyph(member.phase, frame), DYNAMIC_WORKFLOW_RENDERING.memberProgressWidth, ) - } ${padToWidth(renderStateLabel(member.phase), STATE_COLUMN_WIDTH)} ` - : `${id} ${padToWidth(renderCompactStatus(member.phase, frame), STATE_COLUMN_WIDTH)} `; + } ${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. @@ -1134,8 +1129,8 @@ function commonPrefixLength(left: string, right: string, limit: number): number function renderProgressGlyph(phase: DynamicWorkflowPhase, frame: number): string { const glyph = phase === 'running' - ? DYNAMIC_WORKFLOW_PROGRESS_FRAMES[frame % DYNAMIC_WORKFLOW_PROGRESS_FRAMES.length] ?? - DYNAMIC_WORKFLOW_PROGRESS_FRAMES[0] + ? 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); } diff --git a/apps/pythinker-code/src/tui/constant/rendering.ts b/apps/pythinker-code/src/tui/constant/rendering.ts index f012d4a8..21dc2454 100644 --- a/apps/pythinker-code/src/tui/constant/rendering.ts +++ b/apps/pythinker-code/src/tui/constant/rendering.ts @@ -28,6 +28,12 @@ export const DYNAMIC_WORKFLOW_RENDERING = { frameHorizontalInset: 4, memberProgressMinWidth: 60, memberProgressWidth: 8, + /** Least width of the lifecycle STATE column in member rows. */ + stateColumnWidth: 6, + /** 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. */ diff --git a/apps/pythinker-code/test/tui/components/messages/dynamic-workflow-mission-control.test.ts b/apps/pythinker-code/test/tui/components/messages/dynamic-workflow-mission-control.test.ts index 74e7f2c4..be2764dc 100644 --- a/apps/pythinker-code/test/tui/components/messages/dynamic-workflow-mission-control.test.ts +++ b/apps/pythinker-code/test/tui/components/messages/dynamic-workflow-mission-control.test.ts @@ -21,7 +21,7 @@ function renderText(component: DynamicWorkflowMissionControlComponent, width = 1 } /** Lifecycle progress glyph and label for a running row. */ -const RUNNING_CELL = /[○◔◑◕]\s+RUN/u; +const RUNNING_CELL = /[◜◝◞◟]\s+RUN/u; /** Head of a task cell that lost the preamble every row shared. */ const TASK_ELISION_MARK = '…'; @@ -143,7 +143,7 @@ describe('DynamicWorkflowMissionControlComponent', () => { }); expect(component.applyResult(result)).toBe(true); expect(memberLine(renderText(component, 120), 1)).toMatch(/×\s+FAIL\s+Schema work/u); - expect(memberLine(renderText(component, 120), 2)).toMatch(/●\s+DONE\s+Normal work/u); + expect(memberLine(renderText(component, 120), 2)).toMatch(/✓\s+DONE\s+Normal work/u); }); it('ignores blank items so no phantom row waits forever', () => { @@ -160,8 +160,8 @@ describe('DynamicWorkflowMissionControlComponent', () => { component.markCompleted('agent-2', 'Done two'); const output = renderText(component, 120); - expect(memberLine(output, 1)).toContain('● DONE'); - expect(memberLine(output, 2)).toContain('● DONE'); + expect(memberLine(output, 1)).toContain('✓ DONE'); + expect(memberLine(output, 2)).toContain('✓ DONE'); // memberRowCount also counts activity lines, so assert the row's absence. expect(() => memberLine(output, 3)).toThrow(/Missing Dynamic Workflow member 003/u); expect(aggregateLine(output)).toContain('2/2 complete'); @@ -192,7 +192,7 @@ describe('DynamicWorkflowMissionControlComponent', () => { expect(component.applyResult(result)).toBe(true); const output = renderText(component, 120); - expect(memberLine(output, 1)).toContain('● DONE'); + expect(memberLine(output, 1)).toContain('✓ DONE'); expect(output).not.toContain('Unsupported'); }); @@ -243,7 +243,7 @@ describe('DynamicWorkflowMissionControlComponent', () => { expect(component.applyResult(result)).toBe(true); const output = renderText(component, 120); - expect(memberLine(output, 1)).toContain('● DONE'); + expect(memberLine(output, 1)).toContain('✓ DONE'); expect(output).toContain('Accepted result'); expect(output).not.toContain('Out-of-range result'); expect(output).not.toContain('Duplicate result'); @@ -321,8 +321,8 @@ describe('DynamicWorkflowMissionControlComponent', () => { expect(aggregateLine(output)).toContain('2/3 complete'); expect(aggregateLine(output)).not.toMatch(/\b\d+%/u); expect(aggregateLine(output)).not.toContain('━'); - expect(memberLine(output, 1)).toMatch(/[○◔◑◕]\s+RUN\s+Layout hierarchy/u); - expect(memberLine(output, 2)).toMatch(/●\s+DONE\s+Interaction audit/u); + expect(memberLine(output, 1)).toMatch(/[◜◝◞◟]\s+RUN\s+Layout hierarchy/u); + expect(memberLine(output, 2)).toMatch(/✓\s+DONE\s+Interaction audit/u); expect(output).not.toMatch(/[⣿⣷⣯⣟⡿⢿⣻⣽]{4,}/u); }); @@ -408,11 +408,11 @@ describe('DynamicWorkflowMissionControlComponent', () => { }; const first = colouredMemberLine(); - vi.setSystemTime(BRAILLE_SPINNER_INTERVAL_MS * 2); + vi.setSystemTime(120); const second = colouredMemberLine(); - expect(first).toContain(chalk.hex(darkColors.primary)('○')); - expect(second).toContain(chalk.hex(darkColors.primary)('◔')); + expect(first).toContain(chalk.hex(darkColors.primary)('◜')); + expect(second).toContain(chalk.hex(darkColors.primary)('◝')); expect(first).toContain(chalk.hex(darkColors.primary)('RUN')); vi.setSystemTime(BRAILLE_SPINNER_INTERVAL_MS); @@ -439,7 +439,7 @@ describe('DynamicWorkflowMissionControlComponent', () => { const output = renderText(component, 120); expect(output).toContain('– Cancelled'); - expect(memberLine(output, 1)).toMatch(/[○◔◑◕]\s+RUN\s+Running work/u); + expect(memberLine(output, 1)).toMatch(/[◜◝◞◟]\s+RUN\s+Running work/u); expect(memberLine(output, 2)).toMatch(/○\s+WAIT\s+Queued work/u); expect(output).not.toContain('– STOP'); expect(output).not.toContain('⠋ Orchestrating'); @@ -454,7 +454,7 @@ describe('DynamicWorkflowMissionControlComponent', () => { component.markFailed('agent-1', 'Late failure'); const output = renderText(component, 100); - expect(memberLine(output, 1)).toMatch(/●\s+DONE\s+Layout hierarchy/u); + expect(memberLine(output, 1)).toMatch(/✓\s+DONE\s+Layout hierarchy/u); expect(output).toContain('Finished first'); expect(output).not.toContain('Late failure'); }); @@ -474,7 +474,7 @@ describe('DynamicWorkflowMissionControlComponent', () => { ].join('\n')); const output = renderText(component, 120); - expect(memberLine(output, 1)).toMatch(/●\s+DONE\s+Observed first/u); + expect(memberLine(output, 1)).toMatch(/✓\s+DONE\s+Observed first/u); expect(output).toContain('Observed completion'); expect(output).not.toContain('Late result failure'); expect(memberLine(output, 2)).toMatch(/×\s+FAIL\s+Result-only second/u); @@ -495,7 +495,7 @@ describe('DynamicWorkflowMissionControlComponent', () => { const output = renderText(component, 120); expect(aggregateLine(output)).toContain('1/1 complete'); - expect(memberLine(output, 1)).toContain('● DONE'); + expect(memberLine(output, 1)).toContain('✓ DONE'); expect(output).not.toContain('002'); expect(output).not.toContain('Phantom failure'); expect(output).not.toMatch(/\d+%/u); @@ -515,7 +515,7 @@ describe('DynamicWorkflowMissionControlComponent', () => { expect(renderText(component, 100)).toContain('Rate limited'); component.markStarted('agent-1'); - expect(memberLine(renderText(component, 100), 1)).toMatch(/[○◔◑◕]\s+RUN/u); + expect(memberLine(renderText(component, 100), 1)).toMatch(/[◜◝◞◟]\s+RUN/u); }); it('prefers a suspension detail over stale model progress in the member row', () => { @@ -602,7 +602,7 @@ describe('DynamicWorkflowMissionControlComponent', () => { component.markCancelled('agent-6'); const output = renderText(component, 140); - for (const token of ['○ WAIT', '◑ HOLD', '● DONE', '× FAIL', '– STOP']) { + for (const token of ['○ WAIT', '◑ HOLD', '✓ DONE', '× FAIL', '– STOP']) { expect(output).toContain(token); } // Running is the one animated phase, so its symbol varies by frame. @@ -644,12 +644,12 @@ describe('DynamicWorkflowMissionControlComponent', () => { const running = renderText(component, 100); expect(running).toContain('PROGRESS'); expect(running).not.toContain('WORK IDLE'); - expect(memberLine(running, 1)).toMatch(/○\s+RUN\s+Live work/u); + expect(memberLine(running, 1)).toMatch(/◜\s+RUN\s+Live work/u); expect(memberLine(running, 2)).toMatch(/○\s+WAIT\s+Queued work/u); expect(running).not.toMatch(/\b\d+%|⚒|━/u); }); - it('fills the running circle from the shared workflow clock and freezes completion', () => { + it('rotates the running arc from the shared workflow clock and freezes completion', () => { vi.useFakeTimers(); vi.setSystemTime(0); const previousLevel = chalk.level; @@ -664,16 +664,17 @@ describe('DynamicWorkflowMissionControlComponent', () => { component.registerSubagent({ agentId: 'agent-1' }); component.markStarted('agent-1'); - for (const [time, glyph] of [[0, '○'], [160, '◔'], [320, '◑'], [480, '◕']] as const) { + for (const [time, glyph] of [[0, '◜'], [120, '◝'], [240, '◞'], [360, '◟']] as const) { vi.setSystemTime(time); - expect(memberLine(renderText(component, 100), 1)).toContain(glyph); + const line = component.render(100).find((candidate) => strip(candidate).includes('001')); + expect(line).toContain(chalk.hex(darkColors.primary)(glyph)); } component.markCompleted('agent-1', 'Done'); const completed = component.render(100).find((line) => strip(line).includes('001')); - expect(completed).toContain(chalk.hex(darkColors.success)('●')); + expect(completed).toContain(chalk.hex(darkColors.success)('✓')); vi.setSystemTime(10_000); - expect(memberLine(renderText(component, 100), 1)).toMatch(/●\s+DONE/u); + expect(memberLine(renderText(component, 100), 1)).toMatch(/✓\s+DONE/u); } finally { chalk.level = previousLevel; currentTheme.setPalette(previousPalette); @@ -697,8 +698,8 @@ describe('DynamicWorkflowMissionControlComponent', () => { const output = renderText(component, 100); expect(memberLine(output, 1)).toMatch(/○\s+WAIT/u); - expect(memberLine(output, 2)).toMatch(/[○◔◑◕]\s+RUN/u); - expect(memberLine(output, 3)).toMatch(/●\s+DONE/u); + expect(memberLine(output, 2)).toMatch(/[◜◝◞◟]\s+RUN/u); + expect(memberLine(output, 3)).toMatch(/✓\s+DONE/u); } finally { chalk.level = previousLevel; } @@ -716,8 +717,8 @@ describe('DynamicWorkflowMissionControlComponent', () => { const output = renderText(component, 100); const glyphColumns = [ memberLine(output, 1).indexOf('○'), - memberLine(output, 2).search(/[○◔◑◕]/u), - memberLine(output, 3).indexOf('●'), + memberLine(output, 2).search(/[◜◝◞◟]/u), + memberLine(output, 3).indexOf('✓'), ]; expect(glyphColumns[0]).toBeGreaterThan(0); expect(new Set(glyphColumns).size).toBe(1); @@ -882,7 +883,7 @@ describe('DynamicWorkflowMissionControlComponent', () => { const output = strip(rendered.join('\n')); expect(rendered.every((line) => visibleWidth(line) <= width)).toBe(true); - expect(memberLine(output, 1)).toMatch(/[○◔◑◕]\s+RUN/u); + expect(memberLine(output, 1)).toMatch(/[◜◝◞◟]\s+RUN/u); expect(output.includes('PROGRESS')).toBe(expectedProgress); expect(output.includes('STATUS')).toBe(expectedStatus); expect(output).not.toContain('WORK IDLE'); @@ -899,7 +900,7 @@ describe('DynamicWorkflowMissionControlComponent', () => { component.markStarted('agent-1'); vi.setSystemTime(30_000); - const before = memberLine(renderText(component, 100), 1).match(/[○◔◑◕]/u)?.[0]; + const before = memberLine(renderText(component, 100), 1).match(/[◜◝◞◟]/u)?.[0]; component.recordToolCall({ agentId: 'agent-1', name: 'Read' }); for (let index = 0; index < 200; index += 1) { component.appendModelDelta({ agentId: 'agent-1', delta: `chunk ${String(index)} ` }); @@ -907,7 +908,7 @@ describe('DynamicWorkflowMissionControlComponent', () => { component.recordToolCall({ agentId: 'agent-1', name: 'Bash' }); const output = renderText(component, 100); - const after = memberLine(output, 1).match(/[○◔◑◕]/u)?.[0]; + const after = memberLine(output, 1).match(/[◜◝◞◟]/u)?.[0]; expect(before).toBeDefined(); expect(after).toBe(before); expect(output).not.toMatch(/\b\d+%|⚒/u); diff --git a/apps/pythinker-code/test/tui/pythinker-tui-message-flow.test.ts b/apps/pythinker-code/test/tui/pythinker-tui-message-flow.test.ts index 6f64fb42..c3c35aac 100644 --- a/apps/pythinker-code/test/tui/pythinker-tui-message-flow.test.ts +++ b/apps/pythinker-code/test/tui/pythinker-tui-message-flow.test.ts @@ -3944,8 +3944,8 @@ command = "vim" const transcript = stripSgr(renderTranscript(driver)); expect(transcript).toContain('Dynamic Workflow'); // The running row advances through the approved progress-glyph frames. - expect(transcript).toMatch(/001\s+[○◔◑◕]\s+RUN\s+src\/a.ts/u); - expect(transcript).toMatch(/002\s+●\s+DONE\s+src\/b.ts/u); + expect(transcript).toMatch(/001\s+[◜◝◞◟]\s+RUN\s+src\/a.ts/u); + expect(transcript).toMatch(/002\s+✓\s+DONE\s+src\/b.ts/u); expect(transcript).toMatch(/Orchestrating\s+1\/2 complete/u); expect(transcript).not.toContain('━'); expect(transcript).toContain('Completed before spawn'); @@ -4057,7 +4057,7 @@ command = "vim" const transcript = stripSgr(renderTranscript(driver)); expect(transcript).toContain('✓ Completed'); - expect(transcript).toMatch(/001\s+●\s+DONE\s+src\/a.ts/u); + expect(transcript).toMatch(/001\s+✓\s+DONE\s+src\/a.ts/u); expect(transcript).toMatch(/002\s+×\s+FAIL\s+src\/b.ts/u); expect(transcript).toContain('Agent timed out after 30s.'); expect(transcript).not.toContain('⠋ Orchestrating'); @@ -4218,7 +4218,7 @@ command = "vim" const transcript = stripSgr(renderTranscript(driver)); expect(transcript).toContain('× Failed'); - expect(transcript).toMatch(/001\s+●\s+DONE\s+src\/a.ts/u); + expect(transcript).toMatch(/001\s+✓\s+DONE\s+src\/a.ts/u); expect(transcript).toContain('Child completed before request error'); }); diff --git a/docs/reference/tools.md b/docs/reference/tools.md index d6c38949..13c6a03e 100644 --- a/docs/reference/tools.md +++ b/docs/reference/tools.md @@ -103,7 +103,7 @@ If a model response calls `DynamicWorkflow`, that call must be the only tool cal In `manual` and `auto` permission modes a `DynamicWorkflow` call requests approval, and that approval shows the plan it is about to run — the description, the subagent type, the prompt template, and every item. Approving for the session is keyed to that exact plan, so a later call that swaps in a different item list asks again; `yolo` approves without asking. Permission rules match `DynamicWorkflow` on the plan, or on `model:` for the model a call asks its subagents to run on, so `DynamicWorkflow(model:some-model)` gates the model a fan-out may use. -In the TUI, a foreground workflow shows a live framed mission-control panel with a coral title. Each subagent row shows an indeterminate circular progress glyph, its lifecycle state, task, current work, and elapsed time. Running rows cycle through partial circles; a completed row becomes a fixed solid green circle. Pending, held, failed, and cancelled rows keep distinct glyphs and text states. The animation reports observed lifecycle activity, not percent complete or time remaining. The summary reports factual completion, failure, and cancellation counts plus elapsed time, without an estimated aggregate percentage or progress bar. When every task starts with the same preamble — which happens when `prompt_template` is left empty and each item carries a whole prompt — the shared opening is dropped from every row and replaced by a leading `…`, so the part that names the row is what stays on screen. Compact terminals combine the glyph and lifecycle label under `STATUS`; wide terminals show separate `PROGRESS` and `STATE` columns. When vertical space runs out, rows are clipped in workflow-index order and the remainder is summarized as `+ N more agents`. +In the TUI, a foreground workflow shows a live framed mission-control panel with a coral title. Each subagent row shows an indeterminate circular progress glyph, its lifecycle state, task, current work, and elapsed time. Running rows rotate a thin cyan arc, and a completed row becomes a fixed green check. Pending, held, failed, and cancelled rows keep distinct glyphs and text states. The animation reports observed lifecycle activity, not percent complete or time remaining. The summary reports factual completion, failure, and cancellation counts plus elapsed time, without an estimated aggregate percentage or progress bar. When every task starts with the same preamble — which happens when `prompt_template` is left empty and each item carries a whole prompt — the shared opening is dropped from every row and replaced by a leading `…`, so the part that names the row is what stays on screen. Compact terminals combine the glyph and lifecycle label under `STATUS`; wide terminals show separate `PROGRESS` and `STATE` columns. When vertical space runs out, rows are clipped in workflow-index order and the remainder is summarized as `+ N more agents`. **`AskUserQuestion`** asks the user a structured multiple-choice question — useful for disambiguation or option selection. The `questions` parameter accepts 1–4 questions; each question requires `question` (ending with `?`), `options` (2–4 choices, each with a `label` and `description`), and optional `header` (max 12 characters) and `multi_select` (defaults to false). An "Other" option is appended automatically. Setting `background` to true starts a background question task and returns a task ID immediately. When the host does not support interactive questioning, a failure message is returned and the Agent should ask the user directly in a text reply instead. From b7ddcbe5f8cd5cf0f8ad9203ef49324c4d0e10ea Mon Sep 17 00:00:00 2001 From: elkaix Date: Tue, 11 Aug 2026 15:32:22 -0400 Subject: [PATCH 08/10] fix(tui): remove stale workflow spinner import --- .../messages/dynamic-workflow-mission-control.ts | 8 +++----- apps/pythinker-code/src/tui/constant/rendering.ts | 2 ++ 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/apps/pythinker-code/src/tui/components/messages/dynamic-workflow-mission-control.ts b/apps/pythinker-code/src/tui/components/messages/dynamic-workflow-mission-control.ts index 447a2d58..2b8deeb8 100644 --- a/apps/pythinker-code/src/tui/components/messages/dynamic-workflow-mission-control.ts +++ b/apps/pythinker-code/src/tui/components/messages/dynamic-workflow-mission-control.ts @@ -1,9 +1,6 @@ import { truncateToWidth, visibleWidth, type Component } from '@earendil-works/pi-tui'; -import { - 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'; @@ -533,7 +530,8 @@ export class DynamicWorkflowMissionControlComponent implements Component { 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, }); diff --git a/apps/pythinker-code/src/tui/constant/rendering.ts b/apps/pythinker-code/src/tui/constant/rendering.ts index 21dc2454..a4c304d9 100644 --- a/apps/pythinker-code/src/tui/constant/rendering.ts +++ b/apps/pythinker-code/src/tui/constant/rendering.ts @@ -30,6 +30,8 @@ export const DYNAMIC_WORKFLOW_RENDERING = { 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. */ From 8253b173b3b138c86bb32b74560f6880d837f686 Mon Sep 17 00:00:00 2001 From: elkaix Date: Tue, 11 Aug 2026 15:35:31 -0400 Subject: [PATCH 09/10] chore: update workflow progress changeset --- .changeset/workflow-progress.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.changeset/workflow-progress.md b/.changeset/workflow-progress.md index a244a33d..154169da 100644 --- a/.changeset/workflow-progress.md +++ b/.changeset/workflow-progress.md @@ -1,5 +1,5 @@ --- -"@pythoughts/pythinker-code": patch +"@pythoughts/pythinker-code": minor --- -Show indeterminate lifecycle progress for Dynamic Workflow rows in the TUI. +Show indeterminate lifecycle progress for Dynamic Workflow rows in the TUI, and report schema-error outcomes as failed. From 07d73b257f42b5b409a5dd67547436d6b40d4fdb Mon Sep 17 00:00:00 2001 From: elkaix Date: Tue, 11 Aug 2026 15:55:56 -0400 Subject: [PATCH 10/10] docs: correct workflow progress color --- docs/reference/tools.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/reference/tools.md b/docs/reference/tools.md index 13c6a03e..291dd4f5 100644 --- a/docs/reference/tools.md +++ b/docs/reference/tools.md @@ -103,7 +103,7 @@ If a model response calls `DynamicWorkflow`, that call must be the only tool cal In `manual` and `auto` permission modes a `DynamicWorkflow` call requests approval, and that approval shows the plan it is about to run — the description, the subagent type, the prompt template, and every item. Approving for the session is keyed to that exact plan, so a later call that swaps in a different item list asks again; `yolo` approves without asking. Permission rules match `DynamicWorkflow` on the plan, or on `model:` for the model a call asks its subagents to run on, so `DynamicWorkflow(model:some-model)` gates the model a fan-out may use. -In the TUI, a foreground workflow shows a live framed mission-control panel with a coral title. Each subagent row shows an indeterminate circular progress glyph, its lifecycle state, task, current work, and elapsed time. Running rows rotate a thin cyan arc, and a completed row becomes a fixed green check. Pending, held, failed, and cancelled rows keep distinct glyphs and text states. The animation reports observed lifecycle activity, not percent complete or time remaining. The summary reports factual completion, failure, and cancellation counts plus elapsed time, without an estimated aggregate percentage or progress bar. When every task starts with the same preamble — which happens when `prompt_template` is left empty and each item carries a whole prompt — the shared opening is dropped from every row and replaced by a leading `…`, so the part that names the row is what stays on screen. Compact terminals combine the glyph and lifecycle label under `STATUS`; wide terminals show separate `PROGRESS` and `STATE` columns. When vertical space runs out, rows are clipped in workflow-index order and the remainder is summarized as `+ N more agents`. +In the TUI, a foreground workflow shows a live framed mission-control panel with a coral title. Each subagent row shows an indeterminate circular progress glyph, its lifecycle state, task, current work, and elapsed time. Running rows rotate a thin periwinkle arc, and a completed row becomes a fixed green check. Pending, held, failed, and cancelled rows keep distinct glyphs and text states. The animation reports observed lifecycle activity, not percent complete or time remaining. The summary reports factual completion, failure, and cancellation counts plus elapsed time, without an estimated aggregate percentage or progress bar. When every task starts with the same preamble — which happens when `prompt_template` is left empty and each item carries a whole prompt — the shared opening is dropped from every row and replaced by a leading `…`, so the part that names the row is what stays on screen. Compact terminals combine the glyph and lifecycle label under `STATUS`; wide terminals show separate `PROGRESS` and `STATE` columns. When vertical space runs out, rows are clipped in workflow-index order and the remainder is summarized as `+ N more agents`. **`AskUserQuestion`** asks the user a structured multiple-choice question — useful for disambiguation or option selection. The `questions` parameter accepts 1–4 questions; each question requires `question` (ending with `?`), `options` (2–4 choices, each with a `label` and `description`), and optional `header` (max 12 characters) and `multi_select` (defaults to false). An "Other" option is appended automatically. Setting `background` to true starts a background question task and returns a task ID immediately. When the host does not support interactive questioning, a failure message is returned and the Agent should ask the user directly in a text reply instead.