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}`}
))}