diff --git a/packages/cli/src/ui/components/background-view/BackgroundTasksDialog.test.tsx b/packages/cli/src/ui/components/background-view/BackgroundTasksDialog.test.tsx index 248a8d5ef61..cd0ea63f38b 100644 --- a/packages/cli/src/ui/components/background-view/BackgroundTasksDialog.test.tsx +++ b/packages/cli/src/ui/components/background-view/BackgroundTasksDialog.test.tsx @@ -120,6 +120,7 @@ interface Harness { function setup( initial: readonly DialogEntry[], availableTerminalHeight = 30, + terminalWidth = 80, ): Harness { const handlers: Array<(key: { name?: string; sequence?: string }) => void> = []; @@ -183,7 +184,7 @@ function setup( @@ -944,6 +945,215 @@ describe('BackgroundTasksDialog', () => { expect(f).toContain('(no phase)'); expect(f).toContain('420t'); }); + + it('renders a truthful execution rail from declared and observed phases', () => { + const wf = workflowEntry({ + status: 'running', + currentPhase: 'Search', + phases: ['Scope', 'Search'], + agentsDispatched: 5, + agentsCompleted: 3, + meta: { + name: 'deep-research', + description: 'Build a sourced engineering brief.', + phases: [ + { title: 'Scope', detail: 'Shape the research question' }, + { title: 'Search', detail: 'Gather independent sources' }, + { title: 'Verify', detail: 'Challenge every material claim' }, + { title: 'Synthesize', detail: 'Write the final brief' }, + ], + }, + }); + + const h = openWorkflowDetail([wf]); + const frame = h.lastFrame() ?? ''; + + expect(frame).toContain('◆ Running'); + expect(frame).toContain('Agents'); + expect(frame).toContain('3/5'); + expect(frame).toContain('● Scope'); + expect(frame).toContain('◆ Search'); + expect(frame).toContain('○ Verify'); + expect(frame).toContain('○ Synthesize'); + expect(frame).toContain('Gather independent sources'); + }); + + it('separates recent workflow signals from the phase rail', () => { + const wf = workflowEntry({ + status: 'running', + currentPhase: 'Build', + phases: ['Plan', 'Build'], + recentLogs: ['fan-out started', 'worker 2 returned'], + }); + + const h = openWorkflowDetail([wf]); + const frame = h.lastFrame() ?? ''; + + expect(frame).toContain('Execution'); + expect(frame).toContain('Recent signal'); + expect(frame.indexOf('Execution')).toBeLessThan( + frame.indexOf('Recent signal'), + ); + expect(frame).toContain('fan-out started'); + }); + + it('renders completed phases without inventing zero-agent progress', () => { + const wf = workflowEntry({ + status: 'completed', + currentPhase: 'Ship', + phases: ['Plan', 'Ship'], + agentsDispatched: 0, + agentsCompleted: 0, + meta: { + name: 'release', + description: 'Prepare the release.', + phases: [{ title: 'Plan' }, { title: 'Ship' }], + }, + }); + + const h = openWorkflowDetail([wf]); + const frame = h.lastFrame() ?? ''; + + expect(frame).toContain('✔ Completed'); + expect(frame).toContain('● Plan'); + expect(frame).toContain('● Ship'); + expect(frame).not.toContain('Agents'); + expect(frame).not.toContain('0/0'); + }); + + it('gives workflow failures stronger priority than recent signals', () => { + const wf = workflowEntry({ + status: 'failed', + currentPhase: 'Verify', + phases: ['Plan', 'Verify'], + recentLogs: ['verification started'], + error: 'Verifier contract failed', + }); + + const h = openWorkflowDetail([wf]); + const frame = h.lastFrame() ?? ''; + + expect(frame).toContain('✖ Failed'); + expect(frame).toContain('Recent signal'); + expect(frame).toContain('Error'); + expect(frame).toContain('Verifier contract failed'); + }); + + it('keeps the active phase visible when the declared rail is capped', () => { + const declaredPhases = Array.from({ length: 25 }, (_, index) => ({ + title: `Phase ${index + 1}`, + })); + const wf = workflowEntry({ + status: 'running', + currentPhase: 'Phase 23', + phases: declaredPhases.slice(0, 23).map((phase) => phase.title), + meta: { + name: 'wide-workflow', + description: 'Exercise a deep phase rail.', + phases: declaredPhases, + }, + }); + + const h = openWorkflowDetail([wf]); + const frame = h.lastFrame() ?? ''; + + expect(frame).toContain('◆ Phase 23'); + expect(frame).toContain('○ Phase 25'); + expect(frame).toContain('more above'); + expect(frame).not.toContain('● Phase 1'); + }); + + it('keeps the execution console inside a narrow terminal', () => { + const wf = workflowEntry({ + status: 'running', + currentPhase: 'Implementation', + phases: ['Plan', 'Implementation'], + agentsDispatched: 14, + agentsCompleted: 7, + meta: { + name: 'release-readiness', + description: + 'Inspect a large release candidate without hiding operational state.', + phases: [ + { title: 'Plan' }, + { + title: 'Implementation', + detail: 'Run the focused implementation and verification work', + }, + { title: 'Verification' }, + ], + }, + }); + + const h = setup([wf], 30, 44); + h.call(() => h.probe.current!.actions.openDialog()); + h.call(() => h.probe.current!.actions.enterDetail()); + const frame = h.lastFrame() ?? ''; + + expect(frame).toContain('7/14'); + expect(frame).toContain('◆ Implementation'); + expect(frame).toContain('━━━━━─────'); + const lines = frame.split('\n'); + const firstDescriptionLine = + lines.find((line) => + line.includes('Inspect a large release candidate'), + ) ?? ''; + expect(firstDescriptionLine).not.toContain('without hiding'); + expect( + lines.some((line) => line.includes('without hiding operational state')), + ).toBe(true); + }); + + it('appends runtime-discovered phases that are absent from meta.phases', () => { + const wf = workflowEntry({ + status: 'running', + currentPhase: 'RuntimePhase', + phases: ['Plan', 'RuntimePhase'], + meta: { + name: 'runtime-phases', + description: 'Discover phases at runtime.', + phases: [{ title: 'Plan' }], + }, + }); + + const h = openWorkflowDetail([wf]); + const frame = h.lastFrame() ?? ''; + + // 'RuntimePhase' is observed but not declared, so the rail must append + // it rather than drop it. + expect(frame).toContain('● Plan'); + expect(frame).toContain('◆ RuntimePhase'); + }); + + it('marks declared-but-unobserved phases of a terminal run as skipped', () => { + const wf = workflowEntry({ + status: 'failed', + currentPhase: 'Search', + phases: ['Scope', 'Search'], + error: 'Search blew up', + meta: { + name: 'deep-research', + description: 'Build a sourced engineering brief.', + phases: [ + { title: 'Scope' }, + { title: 'Search' }, + { title: 'Verify' }, + { title: 'Synthesize' }, + ], + }, + }); + + const h = openWorkflowDetail([wf]); + const frame = h.lastFrame() ?? ''; + + expect(frame).toContain('✖ Failed'); + expect(frame).toContain('● Scope'); + expect(frame).toContain('● Search'); + // Declared phases the failed run never reached are skipped, not pending. + expect(frame).toContain('⊘ Verify'); + expect(frame).toContain('⊘ Synthesize'); + expect(frame).not.toContain('○ Verify'); + }); }); describe('nested sub-agent display', () => { diff --git a/packages/cli/src/ui/components/background-view/BackgroundTasksDialog.tsx b/packages/cli/src/ui/components/background-view/BackgroundTasksDialog.tsx index a002f7367c6..7c90f2a8c89 100644 --- a/packages/cli/src/ui/components/background-view/BackgroundTasksDialog.tsx +++ b/packages/cli/src/ui/components/background-view/BackgroundTasksDialog.tsx @@ -1121,31 +1121,114 @@ const MonitorDetailBody: React.FC<{ // ─── Workflow detail body ────────────────────────────────── // -// Shows the workflow's declared meta (name + description + whenToUse), -// the phase tree with truncation, per-phase dispatch counts, and the -// log tail. Phase tree is capped at MAX_VISIBLE_PHASES with a "+N more -// above" header so deeply nested fan-outs don't blow the dialog body. -// Logs are the most recent tail; the registry caps at 100 lines but -// the body further truncates to fit the available height. +// Shows the workflow's declared meta, truthful agent progress, a bounded +// execution rail, per-phase token usage, and the recent signal tail. +// The registry caps logs at 100 lines; the body further truncates both +// phases and logs to fit the available height. const MAX_VISIBLE_PHASES = 20; const MAX_VISIBLE_LOG_LINES = 10; +type WorkflowPhaseState = 'completed' | 'active' | 'queued' | 'skipped'; + +interface WorkflowPhasePresentation { + title: string; + detail?: string; + state: WorkflowPhaseState; +} + +function workflowPhaseRail(entry: WorkflowTask): WorkflowPhasePresentation[] { + const declared = entry.meta?.phases ?? []; + const observed = new Set(entry.phases); + const declaredTitles = new Set(declared.map((phase) => phase.title)); + const phases = [ + ...declared, + ...entry.phases + .filter((title) => !declaredTitles.has(title)) + .map((title) => ({ title })), + ]; + const activeIndex = + entry.status === 'running' + ? phases.map((phase) => phase.title).lastIndexOf(entry.currentPhase ?? '') + : -1; + + return phases.map((phase, index) => { + const active = index === activeIndex; + return { + ...phase, + state: active + ? 'active' + : observed.has(phase.title) + ? 'completed' + : entry.status !== 'running' + ? 'skipped' + : 'queued', + }; + }); +} + +function workflowPhaseMarker(state: WorkflowPhaseState): string { + switch (state) { + case 'completed': + return '●'; + case 'active': + return '◆'; + case 'queued': + return '○'; + case 'skipped': + return '⊘'; + default: { + const _exhaustive: never = state; + throw new Error(`Unknown workflow phase state: ${String(_exhaustive)}`); + } + } +} + +function agentMeter( + completed: number, + dispatched: number, + maxWidth: number, +): { complete: string; remaining: string } | null { + if (dispatched <= 0) return null; + const cells = Math.min(10, dispatched, Math.max(3, maxWidth - 24)); + const rawCompletedCells = + dispatched <= cells + ? Math.min(completed, cells) + : Math.round((Math.min(completed, dispatched) / dispatched) * cells); + const completedCells = + completed > 0 ? Math.max(1, rawCompletedCells) : rawCompletedCells; + return { + complete: '━'.repeat(completedCells), + remaining: '─'.repeat(cells - completedCells), + }; +} + const WorkflowDetailBody: React.FC<{ entry: WorkflowTask; maxHeight: number; maxWidth: number; }> = ({ entry, maxHeight, maxWidth }) => { const title = `${t('Workflow')} › ${entry.meta?.name ?? entry.runId}`; - const terminal = terminalStatusPresentation(entry.status); + const status = + terminalStatusPresentation(entry.status) ?? + ({ + icon: '◆', + color: theme.text.accent, + labelColor: theme.text.accent, + } satisfies StatusPresentation); const dimSubtitleParts: string[] = [elapsedFor(entry)]; - if (entry.agentsDispatched > 0) { - dimSubtitleParts.push( - `${entry.agentsCompleted}/${entry.agentsDispatched} ${t('agents')}`, - ); - } + const phaseRail = workflowPhaseRail(entry); + const completedPhaseCount = phaseRail.filter( + (phase) => phase.state === 'completed', + ).length; + const activePhaseCount = phaseRail.some((phase) => phase.state === 'active') + ? 1 + : 0; + const settledPhaseCount = completedPhaseCount + activePhaseCount; dimSubtitleParts.push( - `${entry.phases.length} ${entry.phases.length === 1 ? t('phase') : t('phases')}`, + phaseRail.length > 0 + ? `${settledPhaseCount}/${phaseRail.length} ${phaseRail.length === 1 ? t('phase') : t('phases')}` + : `0 ${t('phases')}`, ); // P5: surface the per-run token usage when there's anything to report // (cap set OR tokens spent). Skipped when both are absent so legacy @@ -1160,11 +1243,32 @@ const WorkflowDetailBody: React.FC<{ ); } - // Phase tree: collapse the head when over the visible cap, keeping - // the most recent N entries (the user almost always wants to see the - // current state, not the launch sequence). - const phaseOverflow = Math.max(0, entry.phases.length - MAX_VISIBLE_PHASES); - const visiblePhases = entry.phases.slice(-MAX_VISIBLE_PHASES); + // Keep the active phase in the bounded window when metadata declares + // more phases than the dialog can show. Terminal runs retain the old + // tail-biased behavior so their most recent observed phases stay visible. + const activePhaseIndex = phaseRail.findIndex( + (phase) => phase.state === 'active', + ); + const phaseWindowStart = + phaseRail.length <= MAX_VISIBLE_PHASES + ? 0 + : activePhaseIndex >= 0 + ? Math.max(0, activePhaseIndex - 2) + : phaseRail.length - MAX_VISIBLE_PHASES; + const visiblePhases = phaseRail.slice( + phaseWindowStart, + phaseWindowStart + MAX_VISIBLE_PHASES, + ); + const phaseOverflowAbove = phaseWindowStart; + const phaseOverflowBelow = Math.max( + 0, + phaseRail.length - phaseWindowStart - visiblePhases.length, + ); + const meter = agentMeter( + entry.agentsCompleted, + entry.agentsDispatched, + maxWidth, + ); // Log tail: similar truncation logic; show "+N more above" header if // the registry has more than the visible window. @@ -1188,14 +1292,28 @@ const WorkflowDetailBody: React.FC<{ - {terminal && ( - - {`${terminal.icon} ${statusVerb(entry.status)} · `} - - )} - {dimSubtitleParts.join(' · ')} + + {`${status.icon} ${statusVerb(entry.status)}`} + + + {` ${dimSubtitleParts.join(' · ')}`} + + {meter && ( + + + {t('Agents')} + + {' '} + {meter.complete} + {meter.remaining} + + {` ${entry.agentsCompleted}/${entry.agentsDispatched}`} + + + )} + {entry.meta?.description && ( @@ -1207,43 +1325,69 @@ const WorkflowDetailBody: React.FC<{ - - {t('Phases')} + + {t('Execution')} - {entry.phases.length === 0 ? ( + {phaseRail.length === 0 ? ( {t('(no phase recorded yet)')} ) : ( - {phaseOverflow > 0 && ( + {phaseOverflowAbove > 0 && ( - {`+${phaseOverflow} ${t('more above')}`} + + {` +${t('{{count}} more above', { + count: String(phaseOverflowAbove), + })}`} + )} - {visiblePhases.map((phaseTitle, i) => { - const isCurrent = - entry.status === 'running' && - i === visiblePhases.length - 1 && - entry.currentPhase === phaseTitle; - const marker = isCurrent ? '▸' : '·'; + {visiblePhases.map((phase, i) => { // P5: per-phase token tally appended to the phase row. // Skipped when no tokens attributed yet so empty phases // (early register, schema-mode-pending) don't render a // misleading `· 0` chip. // P5 R1 (#7): apply `formatTokenCount` for consistency. - const phaseTokens = entry.perPhaseTokens.get(phaseTitle) ?? 0; + const phaseTokens = entry.perPhaseTokens.get(phase.title) ?? 0; const tokenChip = phaseTokens > 0 ? ` · ${formatTokenCount(phaseTokens)}t` : ''; + const color = + phase.state === 'active' + ? theme.text.accent + : phase.state === 'completed' + ? theme.status.success + : theme.text.secondary; return ( - - - {` ${marker} ${phaseTitle}${tokenChip}`} - - + + + + {`${workflowPhaseMarker(phase.state)} `} + + + {`${phase.title}${tokenChip}`} + + + {phase.state === 'active' && phase.detail && ( + + + {`│ ${phase.detail}`} + + + )} + ); })} + {phaseOverflowBelow > 0 && ( + + + {` +${t('{{count}} more below', { + count: String(phaseOverflowBelow), + })}`} + + + )} {/* P5 R1 (#6): surface null-sentinel attribution — tokens spent BEFORE the first `phase()` call accumulate under the `null` key. Without this row the entire pre-phase spend is @@ -1251,7 +1395,7 @@ const WorkflowDetailBody: React.FC<{ {(entry.perPhaseTokens.get(null) ?? 0) > 0 && ( - {` · ${t('(no phase)')} · ${formatTokenCount( + {`· ${t('(no phase)')} · ${formatTokenCount( entry.perPhaseTokens.get(null) ?? 0, )}t`} @@ -1264,19 +1408,23 @@ const WorkflowDetailBody: React.FC<{ - - {t('Logs')} + + {t('Recent signal')} {logOverflow > 0 && ( - {`+${logOverflow} ${t('more above')}`} + + {`+${t('{{count}} more above', { + count: String(logOverflow), + })}`} + )} {visibleLogs.map((line, i) => ( - {line} + {`› ${line}`} ))}