diff --git a/.changeset/workflow-progress.md b/.changeset/workflow-progress.md new file mode 100644 index 00000000..154169da --- /dev/null +++ b/.changeset/workflow-progress.md @@ -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. 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..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,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'; @@ -55,16 +51,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 +95,23 @@ 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 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 +250,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'); } @@ -268,9 +260,7 @@ export class DynamicWorkflowMissionControlComponent implements Component { }): 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. @@ -280,8 +270,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); - 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 @@ -450,7 +439,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; @@ -539,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, }); @@ -577,11 +569,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', 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); } @@ -594,19 +586,18 @@ 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 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. @@ -624,7 +615,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 +631,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 +712,6 @@ export class DynamicWorkflowMissionControlComponent implements Component { phase: this.model.inputComplete ? 'queued' : 'pending', latest: '', carry: '', - toolCalls: 0, - lastEventAtMs: Date.now(), }); } } @@ -747,7 +736,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); @@ -931,7 +919,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. @@ -953,7 +942,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)), }); } @@ -1132,61 +1125,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_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 { diff --git a/apps/pythinker-code/src/tui/constant/rendering.ts b/apps/pythinker-code/src/tui/constant/rendering.ts index 532ef28f..a4c304d9 100644 --- a/apps/pythinker-code/src/tui/constant/rendering.ts +++ b/apps/pythinker-code/src/tui/constant/rendering.ts @@ -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. */ @@ -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. */ 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..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 @@ -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 = '…'; @@ -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 @@ -138,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'); @@ -170,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'); }); @@ -221,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'); @@ -299,10 +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( - /[⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏] 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 +338,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 +351,7 @@ describe('DynamicWorkflowMissionControlComponent', () => { component.markInputComplete(); register(component, 'agent-1'); component.markStarted('agent-1'); + component.render(100); expect(vi.getTimerCount()).toBe(timerCount); }); @@ -362,7 +383,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 +399,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 +408,14 @@ describe('DynamicWorkflowMissionControlComponent', () => { }; const first = colouredMemberLine(); - vi.setSystemTime(BRAILLE_SPINNER_INTERVAL_MS); + vi.setSystemTime(120); 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 +439,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 +454,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 +474,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,12 +495,29 @@ 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); }); + 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'] }); @@ -495,7 +528,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 +543,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 +585,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 +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. @@ -599,40 +632,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('rotates the running arc from the shared workflow clock and freezes completion', () => { vi.useFakeTimers(); vi.setSystemTime(0); const previousLevel = chalk.level; @@ -646,99 +663,65 @@ 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, '◜'], [120, '◝'], [240, '◞'], [360, '◟']] as const) { + vi.setSystemTime(time); + const line = component.render(100).find((candidate) => strip(candidate).includes('001')); + expect(line).toContain(chalk.hex(darkColors.primary)(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 +753,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'); }); @@ -884,49 +867,51 @@ describe('DynamicWorkflowMissionControlComponent', () => { expect(output).not.toContain('001 +1s Agent spawned'); }); - it.each([20, 40, 63, 64, 79, 80, 100])( - 'keeps identity before current work and time at width %i without overflow', - (width) => { + 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, expectedProgress, expectedStatus) => { 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(expectedProgress); + expect(output.includes('STATUS')).toBe(expectedStatus); + 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]; + 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); + const after = memberLine(output, 1).match(/[◜◝◞◟]/u)?.[0]; + expect(before).toBeDefined(); + expect(after).toBe(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 +1089,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); 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..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 @@ -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'); }); diff --git a/docs/reference/tools.md b/docs/reference/tools.md index 78de4798..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. 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 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.