pi-workflow-engine 0.12.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.
- package/.pi/extensions/pi-workflow-engine/index.ts +106 -391
- package/.pi/extensions/pi-workflow-engine/src/advisory-challenge.ts +75 -0
- package/.pi/extensions/pi-workflow-engine/src/advisory-evidence.ts +90 -0
- package/.pi/extensions/pi-workflow-engine/src/advisory-schema.ts +20 -2
- package/.pi/extensions/pi-workflow-engine/src/agent-attempt.ts +48 -62
- package/.pi/extensions/pi-workflow-engine/src/agent-replay.ts +46 -67
- package/.pi/extensions/pi-workflow-engine/src/agent-retry.ts +14 -37
- package/.pi/extensions/pi-workflow-engine/src/agent-runner-types.ts +5 -11
- package/.pi/extensions/pi-workflow-engine/src/agent-runner.ts +15 -21
- package/.pi/extensions/pi-workflow-engine/src/agent-session-identity.ts +13 -43
- package/.pi/extensions/pi-workflow-engine/src/agent-session-providers.ts +17 -5
- package/.pi/extensions/pi-workflow-engine/src/agent-session.ts +93 -100
- package/.pi/extensions/pi-workflow-engine/src/agent-skills.ts +11 -43
- package/.pi/extensions/pi-workflow-engine/src/agent-workspace.ts +18 -18
- package/.pi/extensions/pi-workflow-engine/src/background-workflows.ts +33 -86
- package/.pi/extensions/pi-workflow-engine/src/cancellation.ts +1 -5
- package/.pi/extensions/pi-workflow-engine/src/concurrency.ts +37 -86
- package/.pi/extensions/pi-workflow-engine/src/discovery.ts +16 -23
- package/.pi/extensions/pi-workflow-engine/src/dynamax-shortcuts.ts +1 -4
- package/.pi/extensions/pi-workflow-engine/src/dynamax.ts +39 -98
- package/.pi/extensions/pi-workflow-engine/src/engine.ts +37 -80
- package/.pi/extensions/pi-workflow-engine/src/finalizers.ts +2 -10
- package/.pi/extensions/pi-workflow-engine/src/git.ts +55 -0
- package/.pi/extensions/pi-workflow-engine/src/guards.ts +8 -0
- package/.pi/extensions/pi-workflow-engine/src/identity-canonicalization.ts +7 -19
- package/.pi/extensions/pi-workflow-engine/src/inline-workflow.ts +171 -106
- package/.pi/extensions/pi-workflow-engine/src/journal.ts +1 -12
- package/.pi/extensions/pi-workflow-engine/src/model-profile-command.ts +37 -8
- package/.pi/extensions/pi-workflow-engine/src/model-profiles.ts +15 -66
- package/.pi/extensions/pi-workflow-engine/src/options.ts +97 -16
- package/.pi/extensions/pi-workflow-engine/src/perf.ts +43 -72
- package/.pi/extensions/pi-workflow-engine/src/process-runner.ts +14 -4
- package/.pi/extensions/pi-workflow-engine/src/progress-types.ts +2 -0
- package/.pi/extensions/pi-workflow-engine/src/progress.ts +79 -80
- package/.pi/extensions/pi-workflow-engine/src/provider-usage-limit.ts +28 -20
- package/.pi/extensions/pi-workflow-engine/src/replay-path-identity.ts +2 -7
- package/.pi/extensions/pi-workflow-engine/src/research-contract.ts +0 -1
- package/.pi/extensions/pi-workflow-engine/src/research-evidence.ts +27 -31
- package/.pi/extensions/pi-workflow-engine/src/resume-context.ts +128 -142
- package/.pi/extensions/pi-workflow-engine/src/review/github-pr-comments.ts +14 -31
- package/.pi/extensions/pi-workflow-engine/src/review/patch-validation.ts +119 -0
- package/.pi/extensions/pi-workflow-engine/src/review/review-actions.ts +1 -1
- package/.pi/extensions/pi-workflow-engine/src/review/review-diff-lines.ts +54 -0
- package/.pi/extensions/pi-workflow-engine/src/review/review-fix-workflow.ts +41 -7
- package/.pi/extensions/pi-workflow-engine/src/review/review-format.ts +51 -116
- package/.pi/extensions/pi-workflow-engine/src/review/review-handoff.ts +1 -1
- package/.pi/extensions/pi-workflow-engine/src/review/review-issues.ts +9 -14
- package/.pi/extensions/pi-workflow-engine/src/review/review-report.ts +3 -7
- package/.pi/extensions/pi-workflow-engine/src/review/review-results-viewer.ts +158 -241
- package/.pi/extensions/pi-workflow-engine/src/review/review-session-coordinator.ts +28 -38
- package/.pi/extensions/pi-workflow-engine/src/review/review-snapshot.ts +35 -44
- package/.pi/extensions/pi-workflow-engine/src/review-diff-target.ts +13 -14
- package/.pi/extensions/pi-workflow-engine/src/text.ts +29 -0
- package/.pi/extensions/pi-workflow-engine/src/tool-capabilities.ts +2 -1
- package/.pi/extensions/pi-workflow-engine/src/tool-source-identity.ts +16 -28
- package/.pi/extensions/pi-workflow-engine/src/tree-fingerprint.ts +34 -14
- package/.pi/extensions/pi-workflow-engine/src/types.ts +9 -0
- package/.pi/extensions/pi-workflow-engine/src/ui/kit.ts +193 -0
- package/.pi/extensions/pi-workflow-engine/src/ui/research-result-renderer.ts +177 -0
- package/.pi/extensions/pi-workflow-engine/src/ui/result-layout.ts +209 -0
- package/.pi/extensions/pi-workflow-engine/src/ui/workflow-format.ts +17 -44
- package/.pi/extensions/pi-workflow-engine/src/ui/workflow-inspector.ts +703 -235
- package/.pi/extensions/pi-workflow-engine/src/ui/workflow-picker.ts +48 -0
- package/.pi/extensions/pi-workflow-engine/src/ui/workflow-result-renderer.ts +239 -126
- package/.pi/extensions/pi-workflow-engine/src/ui/workflow-runs-browser.ts +461 -0
- package/.pi/extensions/pi-workflow-engine/src/ui/workflow-tool-renderer.ts +91 -0
- package/.pi/extensions/pi-workflow-engine/src/ui/workflow-widget.ts +316 -98
- package/.pi/extensions/pi-workflow-engine/src/usage.ts +21 -32
- package/.pi/extensions/pi-workflow-engine/src/workflow-advisory-utils.ts +178 -188
- package/.pi/extensions/pi-workflow-engine/src/workflow-execution.ts +19 -24
- package/.pi/extensions/pi-workflow-engine/src/workflow-module.ts +17 -37
- package/.pi/extensions/pi-workflow-engine/src/workflow-result-context.ts +79 -0
- package/.pi/extensions/pi-workflow-engine/src/workflow-run-background.ts +4 -14
- package/.pi/extensions/pi-workflow-engine/src/workflow-run-controller.ts +77 -63
- package/.pi/extensions/pi-workflow-engine/src/workflow-run-history.ts +42 -29
- package/.pi/extensions/pi-workflow-engine/src/workflow-run-record.ts +15 -14
- package/.pi/extensions/pi-workflow-engine/src/workflow-run-store.ts +27 -31
- package/.pi/extensions/pi-workflow-engine/src/workflow-usage-limit-scheduler.ts +1 -5
- package/.pi/extensions/pi-workflow-engine/src/workflows.ts +3 -6
- package/.pi/extensions/pi-workflow-engine/src/worktree.ts +55 -110
- package/.pi/extensions/pi-workflow-engine/workflows/code-review.ts +101 -203
- package/.pi/extensions/pi-workflow-engine/workflows/diagnose.ts +51 -187
- package/.pi/extensions/pi-workflow-engine/workflows/perf-review.ts +33 -109
- package/.pi/extensions/pi-workflow-engine/workflows/refactor-scout.ts +36 -106
- package/.pi/extensions/pi-workflow-engine/workflows/research.ts +74 -66
- package/USAGE.md +90 -17
- package/package.json +1 -2
- package/skills/workflow-code-review-actions/SKILL.md +12 -23
- package/.pi/extensions/pi-workflow-engine/src/diff-capture.ts +0 -81
- package/.pi/extensions/pi-workflow-engine/src/review/code-review-orchestration.ts +0 -49
- package/.pi/extensions/pi-workflow-engine/src/review/review-results-flow.ts +0 -52
- package/.pi/settings.json +0 -3
|
@@ -1,310 +1,778 @@
|
|
|
1
|
-
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
2
|
-
import { matchesKey,
|
|
3
|
-
import type {
|
|
4
|
-
|
|
5
|
-
|
|
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
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
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
|
-
|
|
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
|
-
|
|
27
|
-
|
|
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
|
|
33
|
-
private
|
|
34
|
-
private
|
|
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
|
|
123
|
+
private readonly outcomeSource?: OutcomeSource,
|
|
43
124
|
) {}
|
|
44
125
|
|
|
45
126
|
handleInput(data: string): void {
|
|
46
|
-
if (matchesKey(data, "escape") || data
|
|
127
|
+
if (matchesKey(data, "escape") || matchesKey(data, "q")) {
|
|
47
128
|
this.close();
|
|
48
129
|
return;
|
|
49
130
|
}
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
if (matchesKey(data, "
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
const
|
|
98
|
-
|
|
99
|
-
const
|
|
100
|
-
const
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
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
|
-
|
|
121
|
-
|
|
188
|
+
dispose(): void {
|
|
189
|
+
this.syncRefresh(false);
|
|
122
190
|
}
|
|
123
191
|
|
|
124
|
-
private
|
|
125
|
-
return this.
|
|
192
|
+
private outcome(): WorkflowInspectorOutcome | undefined {
|
|
193
|
+
return typeof this.outcomeSource === "function" ? this.outcomeSource() : this.outcomeSource;
|
|
126
194
|
}
|
|
127
195
|
|
|
128
|
-
private
|
|
129
|
-
|
|
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
|
|
133
|
-
return this.
|
|
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
|
-
|
|
139
|
-
|
|
140
|
-
|
|
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
|
|
144
|
-
return
|
|
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
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
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
|
-
|
|
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
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
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
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
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
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
];
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
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
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
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
|
|
437
|
+
return stats;
|
|
203
438
|
}
|
|
204
439
|
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
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
|
-
|
|
213
|
-
const
|
|
214
|
-
const
|
|
215
|
-
|
|
216
|
-
|
|
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
|
|
220
|
-
}
|
|
221
|
-
|
|
222
|
-
private
|
|
223
|
-
const
|
|
224
|
-
const
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
if (
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
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
|
-
|
|
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
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
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):
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
...
|
|
266
|
-
...
|
|
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
|
-
|
|
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
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
const
|
|
284
|
-
const
|
|
285
|
-
|
|
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
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
const
|
|
293
|
-
const
|
|
294
|
-
for (const sourceLine of
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
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 (
|
|
306
|
-
|
|
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
|
|
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
|
+
}
|