pi-workflow-engine 0.13.0 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (92) hide show
  1. package/.pi/extensions/pi-workflow-engine/index.ts +106 -391
  2. package/.pi/extensions/pi-workflow-engine/src/advisory-challenge.ts +1 -1
  3. package/.pi/extensions/pi-workflow-engine/src/advisory-evidence.ts +8 -8
  4. package/.pi/extensions/pi-workflow-engine/src/advisory-schema.ts +7 -1
  5. package/.pi/extensions/pi-workflow-engine/src/agent-attempt.ts +48 -62
  6. package/.pi/extensions/pi-workflow-engine/src/agent-replay.ts +46 -67
  7. package/.pi/extensions/pi-workflow-engine/src/agent-retry.ts +14 -37
  8. package/.pi/extensions/pi-workflow-engine/src/agent-runner-types.ts +5 -11
  9. package/.pi/extensions/pi-workflow-engine/src/agent-runner.ts +15 -21
  10. package/.pi/extensions/pi-workflow-engine/src/agent-session-identity.ts +13 -43
  11. package/.pi/extensions/pi-workflow-engine/src/agent-session-providers.ts +17 -5
  12. package/.pi/extensions/pi-workflow-engine/src/agent-session.ts +93 -100
  13. package/.pi/extensions/pi-workflow-engine/src/agent-skills.ts +11 -43
  14. package/.pi/extensions/pi-workflow-engine/src/agent-workspace.ts +4 -18
  15. package/.pi/extensions/pi-workflow-engine/src/background-workflows.ts +33 -86
  16. package/.pi/extensions/pi-workflow-engine/src/cancellation.ts +1 -5
  17. package/.pi/extensions/pi-workflow-engine/src/concurrency.ts +25 -80
  18. package/.pi/extensions/pi-workflow-engine/src/discovery.ts +16 -23
  19. package/.pi/extensions/pi-workflow-engine/src/dynamax-shortcuts.ts +1 -4
  20. package/.pi/extensions/pi-workflow-engine/src/dynamax.ts +39 -98
  21. package/.pi/extensions/pi-workflow-engine/src/engine.ts +37 -80
  22. package/.pi/extensions/pi-workflow-engine/src/finalizers.ts +2 -10
  23. package/.pi/extensions/pi-workflow-engine/src/git.ts +55 -0
  24. package/.pi/extensions/pi-workflow-engine/src/guards.ts +8 -0
  25. package/.pi/extensions/pi-workflow-engine/src/identity-canonicalization.ts +7 -19
  26. package/.pi/extensions/pi-workflow-engine/src/inline-workflow.ts +171 -106
  27. package/.pi/extensions/pi-workflow-engine/src/journal.ts +0 -12
  28. package/.pi/extensions/pi-workflow-engine/src/model-profile-command.ts +37 -8
  29. package/.pi/extensions/pi-workflow-engine/src/model-profiles.ts +15 -66
  30. package/.pi/extensions/pi-workflow-engine/src/options.ts +97 -16
  31. package/.pi/extensions/pi-workflow-engine/src/perf.ts +43 -72
  32. package/.pi/extensions/pi-workflow-engine/src/process-runner.ts +14 -4
  33. package/.pi/extensions/pi-workflow-engine/src/progress-types.ts +2 -0
  34. package/.pi/extensions/pi-workflow-engine/src/progress.ts +79 -80
  35. package/.pi/extensions/pi-workflow-engine/src/provider-usage-limit.ts +28 -20
  36. package/.pi/extensions/pi-workflow-engine/src/replay-path-identity.ts +2 -7
  37. package/.pi/extensions/pi-workflow-engine/src/research-contract.ts +0 -1
  38. package/.pi/extensions/pi-workflow-engine/src/research-evidence.ts +27 -31
  39. package/.pi/extensions/pi-workflow-engine/src/resume-context.ts +75 -139
  40. package/.pi/extensions/pi-workflow-engine/src/review/github-pr-comments.ts +14 -31
  41. package/.pi/extensions/pi-workflow-engine/src/review/patch-validation.ts +3 -4
  42. package/.pi/extensions/pi-workflow-engine/src/review/review-actions.ts +1 -1
  43. package/.pi/extensions/pi-workflow-engine/src/review/review-diff-lines.ts +54 -0
  44. package/.pi/extensions/pi-workflow-engine/src/review/review-fix-workflow.ts +7 -9
  45. package/.pi/extensions/pi-workflow-engine/src/review/review-format.ts +51 -117
  46. package/.pi/extensions/pi-workflow-engine/src/review/review-handoff.ts +1 -1
  47. package/.pi/extensions/pi-workflow-engine/src/review/review-issues.ts +8 -13
  48. package/.pi/extensions/pi-workflow-engine/src/review/review-report.ts +3 -7
  49. package/.pi/extensions/pi-workflow-engine/src/review/review-results-viewer.ts +158 -241
  50. package/.pi/extensions/pi-workflow-engine/src/review/review-session-coordinator.ts +28 -38
  51. package/.pi/extensions/pi-workflow-engine/src/review/review-snapshot.ts +35 -44
  52. package/.pi/extensions/pi-workflow-engine/src/review-diff-target.ts +13 -14
  53. package/.pi/extensions/pi-workflow-engine/src/text.ts +29 -0
  54. package/.pi/extensions/pi-workflow-engine/src/tool-capabilities.ts +2 -1
  55. package/.pi/extensions/pi-workflow-engine/src/tool-source-identity.ts +16 -28
  56. package/.pi/extensions/pi-workflow-engine/src/tree-fingerprint.ts +34 -14
  57. package/.pi/extensions/pi-workflow-engine/src/types.ts +6 -0
  58. package/.pi/extensions/pi-workflow-engine/src/ui/kit.ts +193 -0
  59. package/.pi/extensions/pi-workflow-engine/src/ui/research-result-renderer.ts +177 -0
  60. package/.pi/extensions/pi-workflow-engine/src/ui/result-layout.ts +209 -0
  61. package/.pi/extensions/pi-workflow-engine/src/ui/workflow-format.ts +17 -44
  62. package/.pi/extensions/pi-workflow-engine/src/ui/workflow-inspector.ts +703 -235
  63. package/.pi/extensions/pi-workflow-engine/src/ui/workflow-picker.ts +48 -0
  64. package/.pi/extensions/pi-workflow-engine/src/ui/workflow-result-renderer.ts +239 -129
  65. package/.pi/extensions/pi-workflow-engine/src/ui/workflow-runs-browser.ts +461 -0
  66. package/.pi/extensions/pi-workflow-engine/src/ui/workflow-tool-renderer.ts +91 -0
  67. package/.pi/extensions/pi-workflow-engine/src/ui/workflow-widget.ts +316 -98
  68. package/.pi/extensions/pi-workflow-engine/src/usage.ts +21 -32
  69. package/.pi/extensions/pi-workflow-engine/src/workflow-advisory-utils.ts +56 -19
  70. package/.pi/extensions/pi-workflow-engine/src/workflow-execution.ts +19 -24
  71. package/.pi/extensions/pi-workflow-engine/src/workflow-module.ts +17 -37
  72. package/.pi/extensions/pi-workflow-engine/src/workflow-result-context.ts +79 -0
  73. package/.pi/extensions/pi-workflow-engine/src/workflow-run-background.ts +4 -14
  74. package/.pi/extensions/pi-workflow-engine/src/workflow-run-controller.ts +77 -63
  75. package/.pi/extensions/pi-workflow-engine/src/workflow-run-history.ts +42 -29
  76. package/.pi/extensions/pi-workflow-engine/src/workflow-run-record.ts +15 -14
  77. package/.pi/extensions/pi-workflow-engine/src/workflow-run-store.ts +27 -31
  78. package/.pi/extensions/pi-workflow-engine/src/workflow-usage-limit-scheduler.ts +1 -5
  79. package/.pi/extensions/pi-workflow-engine/src/workflows.ts +3 -6
  80. package/.pi/extensions/pi-workflow-engine/src/worktree.ts +48 -110
  81. package/.pi/extensions/pi-workflow-engine/workflows/code-review.ts +64 -93
  82. package/.pi/extensions/pi-workflow-engine/workflows/diagnose.ts +25 -67
  83. package/.pi/extensions/pi-workflow-engine/workflows/perf-review.ts +19 -53
  84. package/.pi/extensions/pi-workflow-engine/workflows/refactor-scout.ts +21 -49
  85. package/.pi/extensions/pi-workflow-engine/workflows/research.ts +74 -66
  86. package/USAGE.md +31 -17
  87. package/package.json +1 -2
  88. package/skills/workflow-code-review-actions/SKILL.md +12 -23
  89. package/.pi/extensions/pi-workflow-engine/src/diff-capture.ts +0 -81
  90. package/.pi/extensions/pi-workflow-engine/src/review/code-review-orchestration.ts +0 -24
  91. package/.pi/extensions/pi-workflow-engine/src/review/review-results-flow.ts +0 -52
  92. package/.pi/settings.json +0 -3
@@ -1,310 +1,778 @@
1
- import type { Theme } from "@earendil-works/pi-coding-agent";
2
- import { matchesKey, type TUI, visibleWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
3
- import type { AgentRowSnapshot, WorkflowLaneItemSnapshot, WorkflowProgressSnapshot } from "../progress-types.ts";
4
- import { formatWorkflowUsageLine } from "../usage.ts";
5
- import { agentDetailParts, formatCount, formatDuration, statusIcon, truncateDisplay } from "./workflow-format.ts";
1
+ import type { ExtensionContext, Theme } from "@earendil-works/pi-coding-agent";
2
+ import { matchesKey, truncateToWidth, visibleWidth, type TUI } from "@earendil-works/pi-tui";
3
+ import type {
4
+ AgentRowSnapshot,
5
+ PhaseSnapshot,
6
+ WorkflowLaneItemSnapshot,
7
+ WorkflowLaneItemStatus,
8
+ WorkflowProgressSnapshot,
9
+ } from "../progress-types.ts";
10
+ import { formatCount } from "../text.ts";
11
+ import type { WorkflowAgentUsage, WorkflowUsageSnapshot } from "../usage.ts";
12
+ import type { WorkflowRunState } from "../workflow-run-record.ts";
6
13
  import {
7
- centerWorkflowViewerViewport,
8
- fitWorkflowViewerRow,
9
- fitWorkflowViewerRows,
10
- workflowViewerHeight,
11
- } from "./workflow-viewer-layout.ts";
12
-
13
- type Section = "Overview" | "Agents" | "Findings" | "Logs" | "Result";
14
-
15
- interface InspectorLine {
16
- text: string;
17
- selectable?: boolean;
18
- key?: string;
14
+ compactUsage,
15
+ fit,
16
+ frame,
17
+ GLYPH,
18
+ hangingWrap,
19
+ joinParts,
20
+ keyHints,
21
+ overlayHeight,
22
+ padStartVisible,
23
+ spread,
24
+ tabBar,
25
+ truncatePath,
26
+ type ThemeColor,
27
+ } from "./kit.ts";
28
+ import { formatDuration } from "./workflow-format.ts";
29
+ import { WORKFLOW_VIEWER_OVERLAY_OPTIONS } from "./workflow-viewer-layout.ts";
30
+
31
+ /** What the inspector knows about a run beyond its progress snapshot. */
32
+ export interface WorkflowInspectorOutcome {
33
+ /** Optional caption shown above the retained text. */
34
+ readonly label?: string;
35
+ /** Retained result (or failure message); its presence adds the Result tab. */
36
+ readonly text?: string;
37
+ /** Lifecycle state when known. Without it a finished snapshot reads as plain "done". */
38
+ readonly state?: WorkflowRunState;
19
39
  }
20
40
 
21
- const BASE_SECTIONS: readonly Section[] = ["Overview", "Agents", "Findings", "Logs"];
22
- const RESULT_SECTIONS: readonly Section[] = [...BASE_SECTIONS, "Result"];
23
- const MAX_RESULT_SOURCE_LINES = 200;
24
- const MAX_RESULT_RENDERED_LINES = 400;
41
+ type OutcomeSource = WorkflowInspectorOutcome | (() => WorkflowInspectorOutcome | undefined);
25
42
 
26
- export interface WorkflowInspectorOutcome {
27
- readonly label: string;
43
+ /** The tables need about 80 columns, so narrow terminals give the inspector nearly full width instead of 80%. */
44
+ export const WORKFLOW_INSPECTOR_OVERLAY_OPTIONS = {
45
+ overlay: true,
46
+ overlayOptions: { ...WORKFLOW_VIEWER_OVERLAY_OPTIONS.overlayOptions, minWidth: 80 },
47
+ } as const;
48
+
49
+ /** Opens the inspector overlay; resolves when the user closes it. */
50
+ export async function showWorkflowInspector(
51
+ ui: Pick<ExtensionContext["ui"], "custom">,
52
+ snapshot: () => WorkflowProgressSnapshot,
53
+ outcome?: OutcomeSource,
54
+ ): Promise<void> {
55
+ await ui.custom<void>(
56
+ (...[tui, theme, , done]) => new WorkflowInspector(snapshot, tui, theme, () => done(undefined), outcome),
57
+ WORKFLOW_INSPECTOR_OVERLAY_OPTIONS,
58
+ );
59
+ }
60
+
61
+ type TabId = "overview" | "agents" | "findings" | "logs" | "result";
62
+
63
+ interface Row {
28
64
  readonly text: string;
65
+ /** Present on rows the cursor can land on (Agents and Findings). */
66
+ readonly key?: string;
67
+ /** Group headings stay in view above the first selected row of their group. */
68
+ readonly heading?: boolean;
29
69
  }
30
70
 
71
+ interface RunStatus {
72
+ readonly glyph: string;
73
+ readonly color: ThemeColor;
74
+ readonly word: string;
75
+ readonly live: boolean;
76
+ }
77
+
78
+ interface FindingGroup {
79
+ readonly lane: string;
80
+ readonly items: readonly WorkflowLaneItemSnapshot[];
81
+ readonly hidden: number;
82
+ }
83
+
84
+ const TAB_LABELS: Record<TabId, string> = {
85
+ overview: "Overview",
86
+ agents: "Agents",
87
+ findings: "Findings",
88
+ logs: "Logs",
89
+ result: "Result",
90
+ };
91
+ const LIST_TABS: ReadonlySet<TabId> = new Set(["agents", "findings"]);
92
+ const TAB_DIGITS = ["1", "2", "3", "4", "5"] as const;
93
+ /** Top border, tab bar, spacer, bottom border. */
94
+ const CHROME_ROWS = 4;
95
+ const REFRESH_INTERVAL_MS = 1_000;
96
+ const MAX_RESULT_SOURCE_LINES = 200;
97
+ const MAX_RESULT_RENDERED_LINES = 400;
98
+ const MAX_OVERVIEW_FAILURES = 5;
99
+ const FUNNEL_KEYS = ["files", "candidates", "verified", "kept"] as const;
100
+ const LANE_STATUS_RANK: Record<WorkflowLaneItemStatus, number> = { success: 0, warning: 1, error: 2, running: 3, pending: 4 };
101
+ const UUID_PATTERN = /\b([0-9a-f]{8})-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\b/gi;
102
+
103
+ /**
104
+ * Framed, tabbed overlay over one workflow run: live while the run is active, then the completed
105
+ * (or retained) state. Overview, Agents, Findings and Logs read the progress snapshot; Result shows
106
+ * retained outcome text when the caller has it.
107
+ */
31
108
  export class WorkflowInspector {
32
- private sectionIndex = 0;
33
- private contentWidth = 80;
34
- private readonly selected: Record<Section, number> = { Overview: 0, Agents: 0, Findings: 0, Logs: 0, Result: 0 };
109
+ private tabIndex = 0;
110
+ private innerWidth = 76;
111
+ private stableBodyRows = 0;
112
+ private readonly cursorKeys = new Map<TabId, string>();
113
+ private readonly cursorIndexes = new Map<TabId, number>();
114
+ private readonly scrollOffsets = new Map<TabId, number>();
35
115
  private readonly expanded = new Set<string>();
116
+ private refreshTimer: ReturnType<typeof setInterval> | undefined;
36
117
 
37
118
  constructor(
38
119
  private readonly snapshotProvider: () => WorkflowProgressSnapshot,
39
120
  private readonly tui: Pick<TUI, "requestRender" | "terminal">,
40
121
  private readonly theme: Theme,
41
122
  private readonly close: () => void,
42
- private readonly outcome?: WorkflowInspectorOutcome,
123
+ private readonly outcomeSource?: OutcomeSource,
43
124
  ) {}
44
125
 
45
126
  handleInput(data: string): void {
46
- if (matchesKey(data, "escape") || data === "q") {
127
+ if (matchesKey(data, "escape") || matchesKey(data, "q")) {
47
128
  this.close();
48
129
  return;
49
130
  }
50
-
51
- if (matchesKey(data, "tab")) {
52
- this.sectionIndex = (this.sectionIndex + 1) % this.sections().length;
53
- this.requestRender();
54
- return;
55
- }
56
-
57
- const section = this.currentSection();
58
- const count = this.itemCount(section);
59
- if (matchesKey(data, "up")) {
60
- this.selected[section] = Math.max(0, this.selected[section] - 1);
61
- this.requestRender();
62
- return;
63
- }
64
- if (matchesKey(data, "down")) {
65
- this.selected[section] = Math.min(Math.max(0, count - 1), this.selected[section] + 1);
66
- this.requestRender();
67
- return;
68
- }
69
- if (matchesKey(data, "return") || matchesKey(data, "enter")) {
70
- const key = this.selectedKey(section);
71
- if (key) {
72
- if (this.expanded.has(key)) this.expanded.delete(key);
73
- else this.expanded.add(key);
74
- this.requestRender();
75
- }
76
- }
131
+ const tabs = this.tabs(this.outcome());
132
+ const digit = TAB_DIGITS.findIndex((candidate) => matchesKey(data, candidate));
133
+ if (matchesKey(data, "tab") || matchesKey(data, "right")) this.tabIndex = (this.tabIndex + 1) % tabs.length;
134
+ else if (matchesKey(data, "shift+tab") || matchesKey(data, "left")) this.tabIndex = (this.tabIndex + tabs.length - 1) % tabs.length;
135
+ else if (digit >= 0) {
136
+ if (digit >= tabs.length) return;
137
+ this.tabIndex = digit;
138
+ } else if (matchesKey(data, "up") || matchesKey(data, "k")) this.move(-1);
139
+ else if (matchesKey(data, "down") || matchesKey(data, "j")) this.move(1);
140
+ else if (matchesKey(data, "pageUp")) this.move(-this.pageRows());
141
+ else if (matchesKey(data, "pageDown")) this.move(this.pageRows());
142
+ else if (matchesKey(data, "g") || matchesKey(data, "home")) this.move(-Infinity);
143
+ else if (data === "G" || matchesKey(data, "shift+g") || matchesKey(data, "end")) this.move(Infinity);
144
+ else if (matchesKey(data, "enter") || matchesKey(data, "return")) this.toggleExpanded();
145
+ else return;
146
+ this.tui.requestRender();
77
147
  }
78
148
 
79
149
  render(width: number): string[] {
80
- const w = Math.max(4, width);
81
- const inner = Math.max(1, w - 4);
82
- this.contentWidth = inner;
83
- const th = this.theme;
84
150
  const snapshot = this.snapshotProvider();
85
- const section = this.currentSection();
86
- let body = this.sectionLines(section, snapshot, inner);
87
- const selectableCount = body.filter((line) => line.selectable).length;
88
- const clampedSelection = Math.min(Math.max(0, selectableCount - 1), this.selected[section]);
89
- if (clampedSelection !== this.selected[section]) {
90
- this.selected[section] = clampedSelection;
91
- body = this.sectionLines(section, snapshot, inner);
92
- }
93
- const innerHeight = Math.max(1, workflowViewerHeight(this.tui.terminal.rows) - 2);
94
- const maxBody = Math.max(1, innerHeight - 5);
95
- const viewport = centerWorkflowViewerViewport(body, maxBody, this.selectedLineIndex(body));
96
- const pct = `${viewport.percentage}%`;
97
- const selected = this.selected[section] + 1;
98
- const count = Math.max(1, selectableCount);
99
- const content = fitWorkflowViewerRows(viewport.visible.map((line) => line.text), maxBody);
100
- const interior = fitWorkflowViewerRows(
101
- [
102
- ` ${th.fg("accent", th.bold("Workflow Inspector"))} ${th.fg("dim", snapshot.title)}`,
103
- ` ${this.tabs()}`,
104
- th.fg("dim", "─".repeat(inner)),
105
- ...content,
106
- th.fg("dim", "─".repeat(inner)),
107
- `${th.fg("dim", `${body.length} lines · ${pct} · ${selected}/${count}`)} ${th.fg("dim", "· tab sections · ↑↓ select · enter expand/collapse · q/esc close")}`,
108
- ],
109
- innerHeight,
151
+ const outcome = this.outcome();
152
+ const status = runStatus(snapshot, outcome?.state);
153
+ this.syncRefresh(status.live);
154
+
155
+ const tabs = this.tabs(outcome);
156
+ this.tabIndex = Math.min(this.tabIndex, tabs.length - 1);
157
+ const active = tabs[this.tabIndex] ?? "overview";
158
+ const inner = Math.max(4, width - 4);
159
+ this.innerWidth = inner;
160
+
161
+ // The overlay is sized by the structured tabs so switching tabs never changes its height;
162
+ // the free-form Result text scrolls inside that height.
163
+ const structured = (["overview", "agents", "findings", "logs"] as const).map((tab) => this.rows(tab, snapshot, outcome, status, inner));
164
+ this.stableBodyRows = Math.max(this.stableBodyRows, ...structured.map((rows) => rows.length));
165
+ const height = overlayHeight(this.tui.terminal.rows, this.stableBodyRows + CHROME_ROWS);
166
+ const viewRows = Math.max(1, height - CHROME_ROWS);
167
+
168
+ const rows = active === "result" ? this.rows(active, snapshot, outcome, status, inner) : structured[tabs.indexOf(active)] ?? [];
169
+ const offset = LIST_TABS.has(active) ? this.scrollToCursor(active, rows, viewRows) : this.clampScroll(active, rows.length, viewRows);
170
+ const visible = rows.slice(offset, offset + viewRows).map((row) => row.text);
171
+ const below = Math.max(0, rows.length - offset - viewRows);
172
+
173
+ const counts = this.tabCounts(snapshot);
174
+ const tabLine = tabBar(tabs.map((tab) => ({ label: TAB_LABELS[tab], count: counts[tab] || undefined })), this.tabIndex, this.theme);
175
+ const title = `${this.theme.fg("accent", GLYPH.workflow)} ${this.theme.bold(snapshot.title)}`;
176
+ // Border chrome around the two labels: `╭─ ` + title + ` ` … ` ` + right + ` ─╮`, plus one fill cell.
177
+ const rightRoom = Math.max(0, width - visibleWidth(title) - 9);
178
+ return frame(
179
+ [tabLine, "", ...visible, ...Array.from({ length: viewRows - visible.length }, () => "")],
180
+ width,
181
+ this.theme,
182
+ { title, right: this.headerStatus(snapshot, status, rightRoom), footer: this.footer(active, rows, viewRows, offset, below, width) },
110
183
  );
111
- return [
112
- th.fg("border", `╭${"─".repeat(Math.max(0, w - 2))}╮`),
113
- ...interior.map((line) => this.row(line, inner)),
114
- th.fg("border", `╰${"─".repeat(Math.max(0, w - 2))}╯`),
115
- ].map((line) => truncateDisplay(line, w));
116
184
  }
117
185
 
118
186
  invalidate(): void {}
119
187
 
120
- private currentSection(): Section {
121
- return this.sections()[this.sectionIndex] ?? "Overview";
188
+ dispose(): void {
189
+ this.syncRefresh(false);
122
190
  }
123
191
 
124
- private sections(): readonly Section[] {
125
- return this.outcome ? RESULT_SECTIONS : BASE_SECTIONS;
192
+ private outcome(): WorkflowInspectorOutcome | undefined {
193
+ return typeof this.outcomeSource === "function" ? this.outcomeSource() : this.outcomeSource;
126
194
  }
127
195
 
128
- private requestRender(): void {
129
- this.tui.requestRender();
196
+ private tabs(outcome: WorkflowInspectorOutcome | undefined): readonly TabId[] {
197
+ const base: TabId[] = ["overview", "agents", "findings", "logs"];
198
+ return outcome?.text !== undefined ? [...base, "result"] : base;
130
199
  }
131
200
 
132
- private tabs(): string {
133
- return this.sections().map((section, index) => {
134
- return index === this.sectionIndex ? this.theme.fg("accent", this.theme.bold(section)) : this.theme.fg("muted", section);
135
- }).join(this.theme.fg("dim", " | "));
201
+ private activeTab(): TabId {
202
+ return this.tabs(this.outcome())[this.tabIndex] ?? "overview";
136
203
  }
137
204
 
138
- private row(content: string, innerWidth: number): string {
139
- const fitted = fitWorkflowViewerRow(content, innerWidth);
140
- return this.theme.fg("border", "│") + " " + fitted + " " + this.theme.fg("border", "│");
205
+ /** Live runs tick (elapsed time, running agents) without waiting for other surfaces to repaint. */
206
+ private syncRefresh(live: boolean): void {
207
+ if (live && this.refreshTimer === undefined) {
208
+ this.refreshTimer = setInterval(() => this.tui.requestRender(), REFRESH_INTERVAL_MS);
209
+ this.refreshTimer.unref?.();
210
+ } else if (!live && this.refreshTimer !== undefined) {
211
+ clearInterval(this.refreshTimer);
212
+ this.refreshTimer = undefined;
213
+ }
141
214
  }
142
215
 
143
- private itemCount(section: Section): number {
144
- return this.sectionLines(section, this.snapshotProvider(), this.contentWidth).filter((line) => line.selectable).length;
216
+ private pageRows(): number {
217
+ return Math.max(1, overlayHeight(this.tui.terminal.rows, this.stableBodyRows + CHROME_ROWS) - CHROME_ROWS - 1);
145
218
  }
146
219
 
147
- private selectedKey(section: Section): string | undefined {
148
- let index = -1;
149
- for (const line of this.sectionLines(section, this.snapshotProvider(), this.contentWidth)) {
150
- if (!line.selectable) continue;
151
- index++;
152
- if (index === this.selected[section]) return line.key;
220
+ private currentRows(tab: TabId): Row[] {
221
+ const snapshot = this.snapshotProvider();
222
+ const outcome = this.outcome();
223
+ return this.rows(tab, snapshot, outcome, runStatus(snapshot, outcome?.state), this.innerWidth);
224
+ }
225
+
226
+ private move(delta: number): void {
227
+ const tab = this.activeTab();
228
+ if (!LIST_TABS.has(tab)) {
229
+ const current = this.scrollOffsets.get(tab) ?? 0;
230
+ // Clamped against the content on the next render.
231
+ this.scrollOffsets.set(tab, Number.isFinite(delta) ? Math.max(0, current + delta) : delta > 0 ? Number.MAX_SAFE_INTEGER : 0);
232
+ return;
153
233
  }
154
- return undefined;
234
+ const keys = selectableKeys(this.currentRows(tab));
235
+ const index = this.cursorIndex(tab, keys);
236
+ const next = Math.min(keys.length - 1, Math.max(0, Number.isFinite(delta) ? index + delta : delta > 0 ? keys.length - 1 : 0));
237
+ const key = keys[next];
238
+ if (key === undefined) return;
239
+ this.cursorKeys.set(tab, key);
240
+ this.cursorIndexes.set(tab, next);
241
+ }
242
+
243
+ private toggleExpanded(): void {
244
+ const tab = this.activeTab();
245
+ if (!LIST_TABS.has(tab)) return;
246
+ const keys = selectableKeys(this.currentRows(tab));
247
+ const key = keys[this.cursorIndex(tab, keys)];
248
+ if (!key) return;
249
+ if (this.expanded.has(key)) this.expanded.delete(key);
250
+ else this.expanded.add(key);
251
+ }
252
+
253
+ /** Resolves the cursor by key so live rows inserted above it do not move the selection. */
254
+ private cursorIndex(tab: TabId, keys: readonly string[]): number {
255
+ const byKey = keys.indexOf(this.cursorKeys.get(tab) ?? "");
256
+ if (byKey >= 0) return byKey;
257
+ return Math.min(Math.max(0, keys.length - 1), this.cursorIndexes.get(tab) ?? 0);
258
+ }
259
+
260
+ private selectedKey(tab: TabId, keys: readonly string[]): string | undefined {
261
+ return keys[this.cursorIndex(tab, keys)];
262
+ }
263
+
264
+ private scrollToCursor(tab: TabId, rows: readonly Row[], viewRows: number): number {
265
+ const keys = selectableKeys(rows);
266
+ const index = this.cursorIndex(tab, keys);
267
+ const key = keys[index];
268
+ if (key) {
269
+ this.cursorKeys.set(tab, key);
270
+ this.cursorIndexes.set(tab, index);
271
+ }
272
+ const line = key ? rows.findIndex((row) => row.key === key) : 0;
273
+ let end = line;
274
+ while (end + 1 < rows.length && rows[end + 1]?.key === undefined && !rows[end + 1]?.heading && end + 1 - line < viewRows) end++;
275
+ // Keep a heading directly above the cursor in view, and the cursor's expanded details below it.
276
+ const start = line > 0 && rows[line - 1]?.heading ? line - 1 : line;
277
+ let offset = this.scrollOffsets.get(tab) ?? 0;
278
+ if (start < offset) offset = start;
279
+ if (end >= offset + viewRows) offset = Math.min(line, end - viewRows + 1);
280
+ offset = Math.max(0, Math.min(offset, rows.length - viewRows));
281
+ this.scrollOffsets.set(tab, offset);
282
+ return offset;
283
+ }
284
+
285
+ private clampScroll(tab: TabId, rowCount: number, viewRows: number): number {
286
+ const offset = Math.max(0, Math.min(this.scrollOffsets.get(tab) ?? 0, rowCount - viewRows));
287
+ this.scrollOffsets.set(tab, offset);
288
+ return offset;
289
+ }
290
+
291
+ private tabCounts(snapshot: WorkflowProgressSnapshot): Partial<Record<TabId, number>> {
292
+ return {
293
+ agents: snapshot.phases.reduce((sum, phase) => sum + phase.agents.length, 0),
294
+ findings: findingGroups(snapshot).reduce((sum, group) => sum + group.items.length, 0),
295
+ logs: snapshot.logs.length,
296
+ };
155
297
  }
156
298
 
157
- private selectedLineIndex(lines: readonly InspectorLine[]): number {
158
- let index = -1;
159
- for (let i = 0; i < lines.length; i++) {
160
- if (!lines[i].selectable) continue;
161
- index++;
162
- if (index === this.selected[this.currentSection()]) return i;
299
+ /** State first; lower-priority parts (run id, then elapsed) drop whole rather than truncating. */
300
+ private headerStatus(snapshot: WorkflowProgressSnapshot, status: RunStatus, room: number): string {
301
+ const th = this.theme;
302
+ const agents = snapshot.phases.flatMap((phase) => phase.agents);
303
+ const failed = agents.filter((agent) => agent.status === "failed").length;
304
+ // A run that finished around failed agents is complete but not clean.
305
+ const degraded = !status.live && failed > 0 && status.color === "success";
306
+ const state = degraded ? th.fg("warning", `${GLYPH.warning} ${status.word}`) : th.fg(status.color, `${status.glyph} ${status.word}`);
307
+ const failures = failed > 0 ? th.fg("error", `${failed} failed`) : undefined;
308
+ const runId = th.fg("dim", `run ${snapshot.runId.slice(0, 8)}`);
309
+ if (status.live) {
310
+ const finished = agents.filter((agent) => agent.status === "done" || agent.status === "failed").length;
311
+ const progress = agents.length > 0 ? `${snapshot.currentPhase} ${finished}/${agents.length}` : snapshot.currentPhase;
312
+ return fitParts([state, th.fg("muted", progress), failures, th.fg("muted", formatDuration(Date.now() - snapshot.startedAt)), runId], room, th);
163
313
  }
164
- return 0;
165
- }
166
-
167
- private sectionLines(section: Section, snapshot: WorkflowProgressSnapshot, width: number): InspectorLine[] {
168
- switch (section) {
169
- case "Overview":
170
- return this.overviewLines(snapshot, width);
171
- case "Agents":
172
- return this.agentLines(snapshot, width);
173
- case "Findings":
174
- return this.findingLines(snapshot, width);
175
- case "Logs":
176
- return this.logLines(snapshot);
177
- case "Result":
178
- return this.resultLines(width);
314
+ const elapsed = snapshot.doneAt !== undefined ? th.fg("muted", formatDuration(snapshot.doneAt - snapshot.startedAt)) : undefined;
315
+ return fitParts([state, failures, elapsed, runId], room, th);
316
+ }
317
+
318
+ private footer(tab: TabId, rows: readonly Row[], viewRows: number, offset: number, below: number, width: number): string {
319
+ const clipped = rows.length > viewRows;
320
+ const pairs: (readonly [string, string])[] = [["tab", "sections"]];
321
+ if (LIST_TABS.has(tab) && selectableKeys(rows).length > 0) pairs.push(["↑↓", "move"], ["enter", "expand"]);
322
+ else if (clipped) pairs.push(["↑↓", "scroll"]);
323
+ pairs.push(["q", "close"]);
324
+ const hints = keyHints(pairs, this.theme);
325
+ const scroll = [offset > 0 ? `↑ ${offset} more` : undefined, below > 0 ? `↓ ${below} more` : undefined].filter(Boolean).join(" ");
326
+ if (!scroll) return hints;
327
+ // The frame sets the footer into the bottom border; keep the scroll position at its right end.
328
+ const room = Math.max(0, width - 6);
329
+ const right = this.theme.fg("dim", scroll);
330
+ const left = truncateToWidth(hints, Math.max(0, room - visibleWidth(right) - 3), "…");
331
+ const gap = Math.max(1, room - visibleWidth(left) - visibleWidth(right) - 2);
332
+ return `${left} ${this.theme.fg("borderAccent", "─".repeat(gap))} ${right}`;
333
+ }
334
+
335
+ private rows(tab: TabId, snapshot: WorkflowProgressSnapshot, outcome: WorkflowInspectorOutcome | undefined, status: RunStatus, width: number): Row[] {
336
+ switch (tab) {
337
+ case "overview":
338
+ return this.overviewRows(snapshot, status, width);
339
+ case "agents":
340
+ return this.agentRows(snapshot, status, width);
341
+ case "findings":
342
+ return this.findingRows(snapshot, status, width);
343
+ case "logs":
344
+ return this.logRows(snapshot, width);
345
+ case "result":
346
+ return this.resultRows(outcome, width);
179
347
  }
180
348
  }
181
349
 
182
- private overviewLines(snapshot: WorkflowProgressSnapshot, width: number): InspectorLine[] {
183
- const elapsed = formatDuration((snapshot.doneAt ?? Date.now()) - snapshot.startedAt);
184
- const lines: InspectorLine[] = [
185
- { text: `${this.theme.fg("muted", "Phase")} ${this.theme.fg("accent", snapshot.currentPhase)}`, selectable: true, key: "overview:phase" },
186
- { text: `${this.theme.fg("muted", "Elapsed")} ${elapsed}`, selectable: true, key: "overview:elapsed" },
187
- ];
188
- const usage = formatWorkflowUsageLine(snapshot.usage);
189
- if (usage) lines.push({ text: usage, selectable: true, key: "overview:usage" });
190
- if (snapshot.counters.length > 0) {
191
- lines.push({ text: this.theme.fg("dim", "Counters") });
192
- for (const counter of snapshot.counters) {
193
- lines.push({ text: ` ${counter.label}: ${formatCount(counter.value)}`, selectable: true, key: `counter:${counter.key}` });
350
+ // ── Overview ──────────────────────────────────────────────────────────────────────────────────
351
+
352
+ private overviewRows(snapshot: WorkflowProgressSnapshot, status: RunStatus, width: number): Row[] {
353
+ const th = this.theme;
354
+ const phases = visiblePhases(snapshot);
355
+ const rows: Row[] = [];
356
+ if (phases.length === 0) rows.push({ text: th.fg("dim", status.live ? "Starting…" : "No phases recorded.") });
357
+ const titleWidth = Math.min(24, Math.max(8, ...phases.map((phase) => visibleWidth(phase.title))));
358
+ const now = Date.now();
359
+ phases.forEach((phase, index) => {
360
+ const isCurrent = phase.title === snapshot.currentPhase && index === phases.length - 1;
361
+ const { glyph, color } = phaseGlyph(phase, isCurrent && status.live);
362
+ const agents = phase.agents.length;
363
+ const count = agents === 0 ? th.fg("dim", "no agents") : th.fg("muted", `${agents} agent${agents === 1 ? "" : "s"}`);
364
+ const duration = phaseDuration(phase, status.live ? now : undefined);
365
+ const notes = joinParts(
366
+ [
367
+ countNote(phase.agents, "running", "accent", th),
368
+ countNote(phase.agents, "queued", "dim", th),
369
+ countNote(phase.agents, "failed", "error", th),
370
+ ],
371
+ th,
372
+ );
373
+ const line = `${th.fg(color, glyph)} ${fit(phase.title, titleWidth)} ${fit(count, 10)} ${padStartVisible(th.fg("muted", duration), 6)} ${notes}`;
374
+ rows.push({ text: line.trimEnd() });
375
+ });
376
+
377
+ const stats = this.overviewStats(snapshot);
378
+ if (stats.length > 0) {
379
+ rows.push({ text: "" });
380
+ const labelWidth = Math.max(...stats.map(([label]) => visibleWidth(label))) + 2;
381
+ for (const [label, value] of stats) {
382
+ for (const line of hangingWrap(value, width, fit(th.fg("muted", label), labelWidth))) rows.push({ text: line });
194
383
  }
195
384
  }
196
- if (snapshot.summary.length > 0) {
197
- lines.push({ text: this.theme.fg("dim", "Summary") });
198
- for (const [key, value] of snapshot.summary) {
199
- lines.push({ text: truncateDisplay(` ${key}: ${value}`, width), selectable: true, key: `summary:${key}` });
385
+
386
+ const failed = snapshot.phases.flatMap((phase) => phase.agents.filter((agent) => agent.status === "failed").map((agent) => ({ agent, phase })));
387
+ if (failed.length > 0) {
388
+ rows.push({ text: "" });
389
+ const labelWidth = Math.min(24, Math.max(...failed.map(({ agent, phase }) => visibleWidth(displayLabel(agent, phase)))));
390
+ for (const { agent, phase } of failed.slice(0, MAX_OVERVIEW_FAILURES)) {
391
+ // One line per failure; the Agents tab expands the full message.
392
+ const message = firstLine(agent.error ?? "error not retained");
393
+ rows.push({ text: `${th.fg("error", `${GLYPH.failed} failed`)} ${fit(displayLabel(agent, phase), labelWidth)} ${th.fg("error", message)}` });
200
394
  }
395
+ if (failed.length > MAX_OVERVIEW_FAILURES) {
396
+ rows.push({ text: th.fg("dim", `+${failed.length - MAX_OVERVIEW_FAILURES} more failed · see Agents`) });
397
+ }
398
+ }
399
+ return rows;
400
+ }
401
+
402
+ private overviewStats(snapshot: WorkflowProgressSnapshot): [string, string][] {
403
+ const th = this.theme;
404
+ const stats: [string, string][] = [];
405
+ const usage = compactUsage(snapshot.usage, th);
406
+ if (usage) stats.push(["Usage", usage]);
407
+
408
+ const counters = new Map(snapshot.counters.map((counter) => [counter.key, counter]));
409
+ const funnel = FUNNEL_KEYS.flatMap((key) => {
410
+ const counter = counters.get(key);
411
+ return counter ? [counter] : [];
412
+ });
413
+ const funnelKeys = new Set<string>(funnel.map((counter) => counter.key));
414
+ if (funnelKeys.size > 0) {
415
+ stats.push(["Funnel", funnel.map((counter) => `${formatCount(counter.value)} ${counter.label}`).join(th.fg("dim", ` ${GLYPH.arrow} `))]);
416
+ }
417
+ const verdicts = snapshot.counters.filter((counter) => counter.key.startsWith("verdict.") && counter.value > 0);
418
+ const verdictLabels = new Set(verdicts.map((counter) => plainLabel(counter.label)));
419
+ if (verdicts.length > 0) {
420
+ stats.push(["Verdicts", joinParts(verdicts.map((counter) => `${formatCount(counter.value)} ${plainLabel(counter.label)}`), th)]);
421
+ }
422
+ const others = snapshot.counters.filter(
423
+ (counter) =>
424
+ counter.value > 0 &&
425
+ !funnelKeys.has(counter.key) &&
426
+ !counter.key.startsWith("verdict.") &&
427
+ !verdictLabels.has(plainLabel(counter.label)),
428
+ );
429
+ if (others.length > 0) {
430
+ stats.push(["Counters", joinParts(others.map((counter) => `${formatCount(counter.value)} ${plainLabel(counter.label)}`), th)]);
431
+ }
432
+ for (const [key, value] of snapshot.summary) {
433
+ // Numeric summary entries that mirror a counter would repeat the funnel.
434
+ if (typeof value === "number" && counters.has(key)) continue;
435
+ stats.push([summaryLabel(key), typeof value === "number" ? formatCount(value) : value]);
201
436
  }
202
- return lines;
437
+ return stats;
203
438
  }
204
439
 
205
- private agentLines(snapshot: WorkflowProgressSnapshot, _width: number): InspectorLine[] {
206
- const lines: InspectorLine[] = [];
207
- let selectableIndex = -1;
208
- for (const phase of snapshot.phases) {
209
- if (phase.agents.length === 0) continue;
210
- lines.push({ text: this.theme.fg("dim", phase.title) });
440
+ // ── Agents ────────────────────────────────────────────────────────────────────────────────────
441
+
442
+ private agentRows(snapshot: WorkflowProgressSnapshot, status: RunStatus, width: number): Row[] {
443
+ const th = this.theme;
444
+ const phases = snapshot.phases.filter((phase) => phase.agents.length > 0);
445
+ if (phases.length === 0) return [{ text: th.fg("dim", status.live ? "No agents yet." : "No agents ran.") }];
446
+ const labels = phases.flatMap((phase) => phase.agents.map((agent) => displayLabel(agent, phase)));
447
+ const labelWidth = Math.min(Math.max(12, Math.floor(width * 0.35)), Math.max(8, ...labels.map((label) => visibleWidth(label))));
448
+ const selected = this.selectedKey("agents", phases.flatMap((phase) => phase.agents.map((agent) => agentKey(agent))));
449
+ const now = Date.now();
450
+ const rows: Row[] = [];
451
+ for (const phase of phases) {
452
+ const failed = phase.agents.filter((agent) => agent.status === "failed").length;
453
+ const note = failed > 0 ? ` ${th.fg("error", `${failed} failed`)}` : "";
454
+ rows.push({ text: ` ${th.bold(th.fg("muted", phase.title))}${note}`, heading: true });
211
455
  for (const agent of phase.agents) {
212
- selectableIndex++;
213
- const key = `agent:${agent.id}`;
214
- const selected = this.currentSection() === "Agents" && this.selected.Agents === selectableIndex;
215
- lines.push({ text: this.agentLine(agent, selected), selectable: true, key });
216
- if (this.expanded.has(key)) lines.push(...this.agentDetails(agent));
456
+ const key = agentKey(agent);
457
+ const cursor = key === selected ? th.fg("accent", GLYPH.cursor) : " ";
458
+ const label = fit(key === selected ? th.bold(displayLabel(agent, phase)) : agentLabelText(agent, phase, th), labelWidth);
459
+ const duration = agent.startedAt === undefined ? "" : shortDuration((agent.doneAt ?? now) - agent.startedAt);
460
+ const tools = agent.toolUses > 0 ? `${agent.toolUses} tool${agent.toolUses === 1 ? "" : "s"}` : "";
461
+ const expanded = this.expanded.has(key);
462
+ // Expanded rows carry the full error and tool below, so the inline note would repeat them.
463
+ const note = expanded ? "" : agentNote(agent, th);
464
+ const line = `${cursor} ${agentGlyph(agent, th)} ${label} ${padStartVisible(th.fg("muted", duration), 6)} ${fit(th.fg("dim", tools), 8)} ${note}`;
465
+ rows.push({ text: line.trimEnd(), key });
466
+ if (expanded) rows.push(...this.agentDetails(agent, phase, snapshot, width));
217
467
  }
218
468
  }
219
- return lines.length > 0 ? lines : [{ text: this.theme.fg("dim", "No agents yet.") }];
220
- }
221
-
222
- private agentLine(agent: AgentRowSnapshot, selected: boolean): string {
223
- const details = agentDetailParts(agent);
224
- const suffix = details.length > 0 ? ` ${this.theme.fg("dim", `· ${details.join(" · ")}`)}` : "";
225
- const text = `${statusIcon(agent.status, this.theme)} ${agent.label}${suffix}`;
226
- return selected ? this.theme.bg("selectedBg", text) : text;
227
- }
228
-
229
- private agentDetails(agent: AgentRowSnapshot): InspectorLine[] {
230
- const lines: InspectorLine[] = [];
231
- if (agent.error) lines.push({ text: ` ${this.theme.fg("error", `Error: ${agent.error}`)}` });
232
- if (agent.lastTool) lines.push({ text: ` ${this.theme.fg("dim", `Last tool: ${agent.lastTool}`)}` });
233
- return lines.length > 0 ? lines : [{ text: ` ${this.theme.fg("dim", "No details yet.")}` }];
234
- }
235
-
236
- private findingLines(snapshot: WorkflowProgressSnapshot, width: number): InspectorLine[] {
237
- const lines: InspectorLine[] = [];
238
- let selectableIndex = -1;
239
- const overflowByLane = new Map(snapshot.laneOverflow);
240
- for (const [lane, items] of snapshot.lanes) {
241
- const hidden = overflowByLane.get(lane) ?? 0;
242
- const countText = hidden > 0 ? `${items.length} shown, ${hidden} hidden` : `${items.length}`;
243
- lines.push({ text: this.theme.fg("dim", `${lane} (${countText})`) });
244
- items.forEach((item, index) => {
245
- selectableIndex++;
246
- const key = `finding:${lane}:${index}:${item.createdAt}`;
247
- const selected = this.currentSection() === "Findings" && this.selected.Findings === selectableIndex;
248
- lines.push({ text: this.findingLine(item, selected), selectable: true, key });
249
- if (this.expanded.has(key)) lines.push(...this.findingDetails(item, width));
250
- });
469
+ return rows;
470
+ }
471
+
472
+ private agentDetails(agent: AgentRowSnapshot, phase: PhaseSnapshot, snapshot: WorkflowProgressSnapshot, width: number): Row[] {
473
+ const th = this.theme;
474
+ const fields: [string, string][] = [];
475
+ if (agent.error) fields.push(["error", th.fg("error", agent.error)]);
476
+ if (agent.status === "queued") fields.push(["status", th.fg("muted", "queued, waiting for a free agent slot")]);
477
+ if (agent.lastTool) {
478
+ const calls = agent.toolUses > 1 ? th.fg("dim", ` · last of ${agent.toolUses} tool calls`) : "";
479
+ fields.push(["tool", `${agent.lastTool}${calls}`]);
480
+ }
481
+ const usage = agentUsage(snapshot, agent, phase);
482
+ if (usage.models.length > 0) fields.push(["model", usage.models.join(", ")]);
483
+ const tokens = compactUsage(usage.snapshot, th);
484
+ if (tokens) {
485
+ const shared = usage.entries > 1 ? th.fg("dim", ` · combined for ${usage.entries} agents labelled ${agent.label}`) : "";
486
+ fields.push(["usage", `${tokens}${shared}`]);
251
487
  }
252
- return lines.length > 0 ? lines : [{ text: this.theme.fg("dim", "No findings lanes yet.") }];
488
+ if (fields.length === 0) return [{ text: ` ${th.fg("dim", "No details yet.")}` }];
489
+ const labelWidth = Math.max(...fields.map(([label]) => label.length)) + 2;
490
+ return fields.flatMap(([label, value]) => hangingWrap(value, width, ` ${th.fg("dim", label.padEnd(labelWidth))}`).map((text) => ({ text })));
253
491
  }
254
492
 
255
- private findingLine(item: WorkflowLaneItemSnapshot, selected: boolean): string {
256
- const subtitle = item.subtitle ? ` ${this.theme.fg("accent", item.subtitle)}` : "";
257
- const text = `${statusIcon(item.status, this.theme)} ${item.title}${subtitle}`;
258
- return selected ? this.theme.bg("selectedBg", text) : text;
493
+ // ── Findings ──────────────────────────────────────────────────────────────────────────────────
494
+
495
+ private findingRows(snapshot: WorkflowProgressSnapshot, status: RunStatus, width: number): Row[] {
496
+ const th = this.theme;
497
+ const groups = findingGroups(snapshot);
498
+ if (groups.length === 0) return [{ text: th.fg("dim", status.live ? "No findings yet." : "No findings recorded.") }];
499
+ const selected = this.selectedKey("findings", groups.flatMap((group) => group.items.map(findingKey)));
500
+ const locationWidth = Math.max(12, Math.floor(width * 0.35));
501
+ const rows: Row[] = [];
502
+ for (const group of groups) {
503
+ const unverified = !status.live && group.items.every((item) => item.status === "pending");
504
+ const heading = joinParts(
505
+ [
506
+ th.fg("dim", `${group.items.length}`),
507
+ unverified ? th.fg("dim", "not verified") : undefined,
508
+ group.hidden > 0 ? th.fg("dim", `${group.hidden} earlier not shown`) : undefined,
509
+ ],
510
+ th,
511
+ );
512
+ rows.push({ text: ` ${th.bold(th.fg("muted", group.lane))} ${heading}`, heading: true });
513
+ for (const item of group.items) {
514
+ const key = findingKey(item);
515
+ const isSelected = key === selected;
516
+ const cursor = isSelected ? th.fg("accent", GLYPH.cursor) : " ";
517
+ const location = item.subtitle ? ` ${th.fg("muted", truncatePath(item.subtitle.replace(/\s+\([^)]*\)$/, ""), locationWidth))}` : "";
518
+ const title = isSelected ? th.bold(item.title) : item.title;
519
+ const titleRoom = width - 4 - visibleWidth(location) - 1;
520
+ rows.push({ text: `${cursor} ${laneItemGlyph(item.status, status.live, th)} ${spread(title, location, width - 4)}`, key });
521
+ if (this.expanded.has(key)) rows.push(...this.findingDetails(item, width, visibleWidth(item.title) > titleRoom));
522
+ }
523
+ }
524
+ return rows;
259
525
  }
260
526
 
261
- private findingDetails(item: WorkflowLaneItemSnapshot, width: number): InspectorLine[] {
262
- return [
263
- ...this.detailFieldLines("Title", item.title, width, "text"),
264
- ...this.detailFieldLines("Location", item.subtitle || "(unknown)", width, "accent"),
265
- ...this.detailFieldLines("Status", item.status, width, "muted"),
266
- ...this.detailFieldLines("Details", item.details || "(no details)", width, "muted"),
527
+ private findingDetails(item: WorkflowLaneItemSnapshot, width: number, titleClipped: boolean): Row[] {
528
+ const th = this.theme;
529
+ const indent = " ";
530
+ const lines = [
531
+ ...(titleClipped ? hangingWrap(item.title, width, indent) : []),
532
+ ...(item.subtitle ? hangingWrap(th.fg("accent", item.subtitle), width, indent) : []),
533
+ ...(item.details ? hangingWrap(th.fg("muted", item.details), width, indent) : []),
267
534
  ];
535
+ return lines.length > 0 ? lines.map((text) => ({ text })) : [{ text: `${indent}${th.fg("dim", "No details recorded.")}` }];
268
536
  }
269
537
 
270
- private detailFieldLines(label: string, value: string, width: number, color: Parameters<Theme["fg"]>[0]): InspectorLine[] {
271
- const plainPrefix = ` ${label}: `;
272
- const prefix = ` ${this.theme.fg("dim", `${label}:`)} `;
273
- const continuation = ` ${" ".repeat(visibleWidth(`${label}: `))}`;
274
- const wrapped = wrapTextWithAnsi(this.theme.fg(color, value), Math.max(10, width - visibleWidth(plainPrefix) - 4));
275
- if (wrapped.length === 0) return [{ text: prefix }];
276
- return wrapped.map((line, index) => ({ text: `${index === 0 ? prefix : continuation}${line}` }));
277
- }
538
+ // ── Logs and result ───────────────────────────────────────────────────────────────────────────
278
539
 
279
- private logLines(snapshot: WorkflowProgressSnapshot): InspectorLine[] {
280
- if (snapshot.logs.length === 0) return [{ text: this.theme.fg("dim", "No logs yet.") }];
281
- return snapshot.logs.map((log, index) => {
282
- const key = `log:${index}`;
283
- const selected = this.currentSection() === "Logs" && this.selected.Logs === index;
284
- const text = selected ? this.theme.bg("selectedBg", log) : log;
285
- return { text, selectable: true, key };
540
+ private logRows(snapshot: WorkflowProgressSnapshot, width: number): Row[] {
541
+ const th = this.theme;
542
+ if (snapshot.logs.length === 0) return [{ text: th.fg("dim", "No log entries.") }];
543
+ return snapshot.logs.flatMap((log) => {
544
+ const message = log.replace(UUID_PATTERN, "$1");
545
+ const tone = logTone(message);
546
+ const prefix = tone === "error" ? th.fg("error", GLYPH.failed) : tone === "warning" ? th.fg("warning", GLYPH.warning) : th.fg("dim", GLYPH.bullet);
547
+ const text = tone === "error" ? th.fg("error", message) : tone === "warning" ? th.fg("warning", message) : message;
548
+ return hangingWrap(text, width, `${prefix} `).map((line) => ({ text: line }));
286
549
  });
287
550
  }
288
551
 
289
- private resultLines(width: number): InspectorLine[] {
290
- if (!this.outcome) return [{ text: this.theme.fg("dim", "No retained outcome is available for this run.") }];
291
- const source = this.outcome.text.split("\n");
292
- const lines: InspectorLine[] = [{ text: this.theme.fg("dim", this.outcome.label) }];
293
- const shown = source.slice(0, MAX_RESULT_SOURCE_LINES);
294
- for (const sourceLine of shown) {
295
- const wrapped = wrapTextWithAnsi(sourceLine || " ", Math.max(10, width - 2));
296
- for (const line of wrapped) {
297
- if (lines.length >= MAX_RESULT_RENDERED_LINES) break;
298
- const index = lines.length;
299
- const selected = this.currentSection() === "Result" && this.selected.Result === index - 1;
300
- const text = selected ? this.theme.bg("selectedBg", line) : line;
301
- lines.push({ text, selectable: true, key: `result:${index}` });
552
+ private resultRows(outcome: WorkflowInspectorOutcome | undefined, width: number): Row[] {
553
+ const th = this.theme;
554
+ if (outcome?.text === undefined) return [{ text: th.fg("dim", "No retained result for this run.") }];
555
+ const rows: Row[] = outcome.label ? [{ text: th.fg("dim", outcome.label) }] : [];
556
+ const source = outcome.text.split("\n");
557
+ for (const sourceLine of source.slice(0, MAX_RESULT_SOURCE_LINES)) {
558
+ // Continuations keep the source indentation so wrapped JSON still reads as nested.
559
+ const indent = /^\s*/.exec(sourceLine)?.[0] ?? "";
560
+ for (const line of hangingWrap(sourceLine.slice(indent.length), width - 2, indent, `${indent} `)) {
561
+ if (rows.length >= MAX_RESULT_RENDERED_LINES) break;
562
+ rows.push({ text: line });
302
563
  }
303
- if (lines.length >= MAX_RESULT_RENDERED_LINES) break;
304
564
  }
305
- if (shown.length < source.length || lines.length >= MAX_RESULT_RENDERED_LINES) {
306
- lines.push({ text: this.theme.fg("dim", "… retained result truncated for display") });
565
+ if (source.length > MAX_RESULT_SOURCE_LINES || rows.length >= MAX_RESULT_RENDERED_LINES) {
566
+ rows.push({ text: th.fg("dim", "… retained result truncated for display") });
307
567
  }
308
- return lines;
568
+ return rows;
309
569
  }
310
570
  }
571
+
572
+ /** Joins parts with the kit separator, keeping the longest prefix that fits `room` (the first part always). */
573
+ function fitParts(parts: readonly (string | undefined)[], room: number, theme: Theme): string {
574
+ const present = parts.filter((part): part is string => part !== undefined);
575
+ for (let count = present.length; count > 1; count--) {
576
+ const joined = joinParts(present.slice(0, count), theme);
577
+ if (visibleWidth(joined) <= room) return joined;
578
+ }
579
+ return present[0] ?? "";
580
+ }
581
+
582
+ function runStatus(snapshot: WorkflowProgressSnapshot, state: WorkflowRunState | undefined): RunStatus {
583
+ switch (state ?? (snapshot.doneAt === undefined ? "running" : undefined)) {
584
+ case "queued":
585
+ return { glyph: GLYPH.queued, color: "dim", word: "queued", live: true };
586
+ case "running":
587
+ return { glyph: GLYPH.running, color: "accent", word: "running", live: snapshot.doneAt === undefined };
588
+ case "completed":
589
+ return { glyph: GLYPH.done, color: "success", word: "completed", live: false };
590
+ case "failed":
591
+ return { glyph: GLYPH.failed, color: "error", word: "failed", live: false };
592
+ case "stopped":
593
+ return { glyph: GLYPH.warning, color: "warning", word: "stopped", live: false };
594
+ case "paused":
595
+ return { glyph: GLYPH.warning, color: "warning", word: "paused", live: false };
596
+ case undefined:
597
+ return { glyph: GLYPH.done, color: "success", word: "done", live: false };
598
+ }
599
+ }
600
+
601
+ /** Phases in chronological order, without the tracker's implicit empty "Workflow" phase. */
602
+ function visiblePhases(snapshot: WorkflowProgressSnapshot): readonly PhaseSnapshot[] {
603
+ const [first, ...rest] = snapshot.phases;
604
+ if (first && first.agents.length === 0 && rest.length > 0) return rest;
605
+ return snapshot.phases;
606
+ }
607
+
608
+ function phaseGlyph(phase: PhaseSnapshot, activeAndLive: boolean): { glyph: string; color: ThemeColor } {
609
+ const agents = phase.agents;
610
+ if (agents.some((agent) => agent.status === "running") || (activeAndLive && agents.length === 0)) return { glyph: GLYPH.running, color: "accent" };
611
+ if (agents.length > 0 && agents.every((agent) => agent.status === "queued")) return { glyph: GLYPH.queued, color: "dim" };
612
+ if (agents.some((agent) => agent.status === "queued")) return { glyph: GLYPH.running, color: "accent" };
613
+ if (agents.length > 0 && agents.every((agent) => agent.status === "failed")) return { glyph: GLYPH.failed, color: "error" };
614
+ return { glyph: GLYPH.done, color: "success" };
615
+ }
616
+
617
+ function phaseDuration(phase: PhaseSnapshot, now: number | undefined): string {
618
+ const started = phase.agents.flatMap((agent) => (agent.startedAt === undefined ? [] : [agent.startedAt]));
619
+ if (started.length === 0) return "";
620
+ const ended = phase.agents.flatMap((agent) => {
621
+ if (agent.doneAt !== undefined) return [agent.doneAt];
622
+ return agent.status === "running" && now !== undefined ? [now] : [];
623
+ });
624
+ if (ended.length === 0) return "";
625
+ return shortDuration(Math.max(...ended) - Math.min(...started));
626
+ }
627
+
628
+ function countNote(agents: readonly AgentRowSnapshot[], status: AgentRowSnapshot["status"], color: ThemeColor, theme: Theme): string | undefined {
629
+ const count = agents.filter((agent) => agent.status === status).length;
630
+ return count > 0 ? theme.fg(color, `${count} ${status}`) : undefined;
631
+ }
632
+
633
+ /** `0.6s` / `12s` / `1m 4s`: one decimal below ten seconds so short agents stay distinguishable. */
634
+ function shortDuration(ms: number): string {
635
+ if (!Number.isFinite(ms) || ms <= 0) return "0s";
636
+ if (ms < 100) return `${Math.round(ms)}ms`;
637
+ if (ms < 10_000) return `${(ms / 1_000).toFixed(1)}s`;
638
+ return formatDuration(ms);
639
+ }
640
+
641
+ /** `find:logic-bugs` under the Find phase reads as `logic-bugs`. */
642
+ function displayLabel(agent: AgentRowSnapshot, phase: PhaseSnapshot): string {
643
+ const prefix = `${phase.title.toLowerCase()}:`;
644
+ return agent.label.toLowerCase().startsWith(prefix) && agent.label.length > prefix.length ? agent.label.slice(prefix.length) : agent.label;
645
+ }
646
+
647
+ function agentLabelText(agent: AgentRowSnapshot, phase: PhaseSnapshot, theme: Theme): string {
648
+ const label = displayLabel(agent, phase);
649
+ return agent.status === "running" ? label : theme.fg(agent.status === "queued" ? "dim" : "muted", label);
650
+ }
651
+
652
+ function agentGlyph(agent: AgentRowSnapshot, theme: Theme): string {
653
+ switch (agent.status) {
654
+ case "done":
655
+ return theme.fg("success", GLYPH.done);
656
+ case "failed":
657
+ return theme.fg("error", GLYPH.failed);
658
+ case "running":
659
+ return theme.fg("accent", GLYPH.running);
660
+ case "queued":
661
+ return theme.fg("dim", GLYPH.queued);
662
+ }
663
+ }
664
+
665
+ function agentNote(agent: AgentRowSnapshot, theme: Theme): string {
666
+ if (agent.status === "failed") return theme.fg("error", firstLine(agent.error ?? "failed"));
667
+ if (agent.status === "queued") return theme.fg("dim", "queued");
668
+ if (agent.status === "running" && agent.lastTool) return theme.fg("dim", agent.lastTool);
669
+ return "";
670
+ }
671
+
672
+ /**
673
+ * Usage recorded for an agent. Labels are display text rather than unique ids, so agents sharing a
674
+ * label (two `verify:cache.ts` runs) report their combined usage instead of a guessed pairing.
675
+ */
676
+ function agentUsage(
677
+ snapshot: WorkflowProgressSnapshot,
678
+ agent: AgentRowSnapshot,
679
+ phase: PhaseSnapshot,
680
+ ): { models: string[]; entries: number; snapshot: WorkflowUsageSnapshot | undefined } {
681
+ const matches = (snapshot.usage?.agents ?? []).filter(
682
+ (entry) => entry.label === agent.label && (entry.phase === undefined || entry.phase === phase.title),
683
+ );
684
+ const models = [...new Set(matches.flatMap((entry) => (entry.model ? [entry.provider ? `${entry.provider}/${entry.model}` : entry.model] : [])))];
685
+ return { models, entries: matches.length, snapshot: combinedUsage(matches) };
686
+ }
687
+
688
+ function combinedUsage(entries: readonly WorkflowAgentUsage[]): WorkflowUsageSnapshot | undefined {
689
+ const [first] = entries;
690
+ if (!first) return undefined;
691
+ const sum = (pick: (entry: WorkflowAgentUsage) => number) => entries.reduce((total, entry) => total + pick(entry), 0);
692
+ return {
693
+ agents: entries,
694
+ assistantMessages: sum((entry) => entry.assistantMessages),
695
+ totals: {
696
+ ...first.usage,
697
+ input: sum((entry) => entry.usage.input),
698
+ output: sum((entry) => entry.usage.output),
699
+ cacheRead: sum((entry) => entry.usage.cacheRead),
700
+ cacheWrite: sum((entry) => entry.usage.cacheWrite),
701
+ totalTokens: sum((entry) => entry.usage.totalTokens),
702
+ cost: { ...first.usage.cost, total: sum((entry) => entry.usage.cost.total) },
703
+ },
704
+ };
705
+ }
706
+
707
+ /**
708
+ * Lane items grouped for display. An item re-emitted into a later lane (a candidate that was
709
+ * verified) is shown only in its latest lane, so resolved candidates never linger as pending.
710
+ * Lanes are ordered by their best item status: confirmed work first, unverified last.
711
+ */
712
+ function findingGroups(snapshot: WorkflowProgressSnapshot): FindingGroup[] {
713
+ const latest = new Map<string, WorkflowLaneItemSnapshot>();
714
+ for (const [, items] of snapshot.lanes) {
715
+ for (const item of items) {
716
+ const key = findingKey(item);
717
+ const previous = latest.get(key);
718
+ if (!previous || item.createdAt >= previous.createdAt) latest.set(key, item);
719
+ }
720
+ }
721
+ const hidden = new Map(snapshot.laneOverflow);
722
+ const groups = snapshot.lanes.flatMap(([lane, items]) => {
723
+ const current = items.filter((item) => latest.get(findingKey(item)) === item);
724
+ return current.length > 0 ? [{ lane, items: current, hidden: hidden.get(lane) ?? 0 }] : [];
725
+ });
726
+ const rank = (group: FindingGroup) => Math.min(...group.items.map((item) => LANE_STATUS_RANK[item.status]));
727
+ return groups.map((group, index) => ({ group, index })).sort((a, b) => rank(a.group) - rank(b.group) || a.index - b.index).map(({ group }) => group);
728
+ }
729
+
730
+ function agentKey(agent: AgentRowSnapshot): string {
731
+ return `agent:${agent.id}`;
732
+ }
733
+
734
+ function firstLine(text: string): string {
735
+ return text.split("\n", 1)[0] ?? text;
736
+ }
737
+
738
+ function findingKey(item: WorkflowLaneItemSnapshot): string {
739
+ return `finding:${item.title}\u0000${item.subtitle ?? ""}`;
740
+ }
741
+
742
+ function laneItemGlyph(status: WorkflowLaneItemStatus, live: boolean, theme: Theme): string {
743
+ switch (status) {
744
+ case "success":
745
+ return theme.fg("success", GLYPH.done);
746
+ case "warning":
747
+ return theme.fg("warning", GLYPH.warning);
748
+ case "error":
749
+ return theme.fg("error", GLYPH.failed);
750
+ case "running":
751
+ return theme.fg("accent", GLYPH.running);
752
+ case "pending":
753
+ // A finished run will never verify it; an open circle would read as still waiting.
754
+ return theme.fg("dim", live ? GLYPH.queued : "–");
755
+ }
756
+ }
757
+
758
+ function selectableKeys(rows: readonly Row[]): string[] {
759
+ return rows.flatMap((row) => (row.key === undefined ? [] : [row.key]));
760
+ }
761
+
762
+ function logTone(message: string): "error" | "warning" | "info" {
763
+ if (/\b(fail(ed|ure|s)?|errors?|exception|crash(ed)?|abort(ed)?)\b/i.test(message)) return "error";
764
+ if (/\b(warn(ing)?|dropped|skipp(ed|ing)|retry(ing)?|retried|incomplete|time(d)? ?out|paused|stopped)\b/i.test(message)) return "warning";
765
+ return "info";
766
+ }
767
+
768
+ /** `NOT_SUBSTANTIATED` → `not substantiated`; already-plain labels pass through lower-cased. */
769
+ function plainLabel(label: string): string {
770
+ return label.replace(/_/g, " ").toLowerCase();
771
+ }
772
+
773
+ /** `diffCommand` → `Diff command`, `research.question` → `Question`. */
774
+ function summaryLabel(key: string): string {
775
+ const last = key.split(".").pop() || key;
776
+ const words = last.replace(/([a-z0-9])([A-Z])/g, "$1 $2").replace(/[_-]+/g, " ").toLowerCase();
777
+ return words.charAt(0).toUpperCase() + words.slice(1);
778
+ }