pi-workflow-engine 0.1.0 → 0.2.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/package.json +2 -1
- package/src/agent-runner.ts +8 -4
- package/src/engine.ts +19 -2
- package/src/index.ts +79 -14
- package/src/progress.ts +258 -23
- package/src/types.ts +31 -0
- package/src/ui/workflow-format.ts +83 -0
- package/src/ui/workflow-inspector.ts +248 -0
- package/src/ui/workflow-result-renderer.ts +149 -0
- package/src/ui/workflow-widget.ts +143 -0
- package/workflows/code-review.ts +174 -11
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { truncateToWidth } from "@earendil-works/pi-tui";
|
|
3
|
+
import type { WorkflowProgressSnapshot } from "../progress.ts";
|
|
4
|
+
import type { WorkflowLaneItemStatus } from "../types.ts";
|
|
5
|
+
|
|
6
|
+
export type WorkflowDisplayStatus = WorkflowLaneItemStatus | "queued" | "done" | "failed";
|
|
7
|
+
export type WorkflowThemeColor = Parameters<Theme["fg"]>[0];
|
|
8
|
+
|
|
9
|
+
export function formatDuration(ms: number): string {
|
|
10
|
+
if (!Number.isFinite(ms) || ms <= 0) return "0s";
|
|
11
|
+
if (ms < 1_000) return `${Math.round(ms)}ms`;
|
|
12
|
+
|
|
13
|
+
const totalSeconds = Math.floor(ms / 1_000);
|
|
14
|
+
if (totalSeconds < 60) return `${totalSeconds}s`;
|
|
15
|
+
|
|
16
|
+
const minutes = Math.floor(totalSeconds / 60);
|
|
17
|
+
const seconds = totalSeconds % 60;
|
|
18
|
+
if (minutes < 60) return seconds === 0 ? `${minutes}m` : `${minutes}m ${seconds}s`;
|
|
19
|
+
|
|
20
|
+
const hours = Math.floor(minutes / 60);
|
|
21
|
+
const remainingMinutes = minutes % 60;
|
|
22
|
+
return remainingMinutes === 0 ? `${hours}h` : `${hours}h ${remainingMinutes}m`;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function formatCount(n: number): string {
|
|
26
|
+
if (!Number.isFinite(n)) return "0";
|
|
27
|
+
const sign = n < 0 ? "-" : "";
|
|
28
|
+
const value = Math.abs(n);
|
|
29
|
+
if (value < 1_000) return `${Math.trunc(n)}`;
|
|
30
|
+
if (value < 1_000_000) return `${sign}${formatCompact(value / 1_000)}k`;
|
|
31
|
+
return `${sign}${formatCompact(value / 1_000_000)}m`;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function statusIcon(status: WorkflowDisplayStatus, theme: Theme): string {
|
|
35
|
+
switch (status) {
|
|
36
|
+
case "success":
|
|
37
|
+
case "done":
|
|
38
|
+
return theme.fg("success", "✓");
|
|
39
|
+
case "warning":
|
|
40
|
+
return theme.fg("warning", "!");
|
|
41
|
+
case "error":
|
|
42
|
+
case "failed":
|
|
43
|
+
return theme.fg("error", "✗");
|
|
44
|
+
case "running":
|
|
45
|
+
return theme.fg("accent", "●");
|
|
46
|
+
case "queued":
|
|
47
|
+
case "pending":
|
|
48
|
+
return theme.fg("dim", "○");
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function badge(label: string, color: WorkflowThemeColor, theme: Theme): string {
|
|
53
|
+
return theme.fg(color, `[${label}]`);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function truncateDisplay(text: string, width: number): string {
|
|
57
|
+
if (width <= 0) return "";
|
|
58
|
+
return truncateToWidth(text, width);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function statusText(snapshot: WorkflowProgressSnapshot, theme: Theme): string | undefined {
|
|
62
|
+
if (snapshot.doneAt !== undefined) return undefined;
|
|
63
|
+
|
|
64
|
+
const agents = snapshot.phases.flatMap((phase) => phase.agents);
|
|
65
|
+
const complete = agents.filter((agent) => agent.status === "done" || agent.status === "failed").length;
|
|
66
|
+
const active = agents.filter((agent) => agent.status === "running" || agent.status === "queued").length;
|
|
67
|
+
const total = agents.length;
|
|
68
|
+
const kept = snapshot.counters.find((counter) => counter.key === "kept" || counter.label.toLowerCase() === "kept");
|
|
69
|
+
const displayName = snapshot.title === "code-review" ? "review" : snapshot.title;
|
|
70
|
+
|
|
71
|
+
const parts = [theme.fg("accent", displayName), theme.fg("muted", snapshot.currentPhase)];
|
|
72
|
+
if (total > 0) parts.push(theme.fg("muted", `${complete}/${total}`));
|
|
73
|
+
if (kept) parts.push(theme.fg("success", `${formatCount(kept.value)} kept`));
|
|
74
|
+
else if (active > 0) parts.push(theme.fg("muted", `${active} active`));
|
|
75
|
+
|
|
76
|
+
return parts.join(theme.fg("dim", " · "));
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function formatCompact(value: number): string {
|
|
80
|
+
if (value >= 100) return `${Math.round(value)}`;
|
|
81
|
+
const rounded = Math.round(value * 10) / 10;
|
|
82
|
+
return Number.isInteger(rounded) ? `${rounded}` : rounded.toFixed(1);
|
|
83
|
+
}
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { matchesKey, type TUI, visibleWidth } from "@earendil-works/pi-tui";
|
|
3
|
+
import type { AgentRowSnapshot, WorkflowLaneItemSnapshot, WorkflowProgressSnapshot } from "../progress.ts";
|
|
4
|
+
import { formatCount, formatDuration, statusIcon, truncateDisplay } from "./workflow-format.ts";
|
|
5
|
+
|
|
6
|
+
type Section = "Overview" | "Agents" | "Findings" | "Logs";
|
|
7
|
+
|
|
8
|
+
interface InspectorLine {
|
|
9
|
+
text: string;
|
|
10
|
+
selectable?: boolean;
|
|
11
|
+
key?: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const SECTIONS: readonly Section[] = ["Overview", "Agents", "Findings", "Logs"];
|
|
15
|
+
|
|
16
|
+
export class WorkflowInspector {
|
|
17
|
+
private sectionIndex = 0;
|
|
18
|
+
private readonly selected: Record<Section, number> = { Overview: 0, Agents: 0, Findings: 0, Logs: 0 };
|
|
19
|
+
private readonly expanded = new Set<string>();
|
|
20
|
+
|
|
21
|
+
constructor(
|
|
22
|
+
private readonly snapshotProvider: () => WorkflowProgressSnapshot,
|
|
23
|
+
private readonly tui: Pick<TUI, "requestRender" | "terminal">,
|
|
24
|
+
private readonly theme: Theme,
|
|
25
|
+
private readonly close: () => void,
|
|
26
|
+
) {}
|
|
27
|
+
|
|
28
|
+
handleInput(data: string): void {
|
|
29
|
+
if (matchesKey(data, "escape") || data === "q") {
|
|
30
|
+
this.close();
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
if (matchesKey(data, "tab")) {
|
|
35
|
+
this.sectionIndex = (this.sectionIndex + 1) % SECTIONS.length;
|
|
36
|
+
this.requestRender();
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const section = this.currentSection();
|
|
41
|
+
const count = this.itemCount(section);
|
|
42
|
+
if (matchesKey(data, "up")) {
|
|
43
|
+
this.selected[section] = Math.max(0, this.selected[section] - 1);
|
|
44
|
+
this.requestRender();
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
if (matchesKey(data, "down")) {
|
|
48
|
+
this.selected[section] = Math.min(Math.max(0, count - 1), this.selected[section] + 1);
|
|
49
|
+
this.requestRender();
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
if (matchesKey(data, "return") || matchesKey(data, "enter")) {
|
|
53
|
+
const key = this.selectedKey(section);
|
|
54
|
+
if (key) {
|
|
55
|
+
if (this.expanded.has(key)) this.expanded.delete(key);
|
|
56
|
+
else this.expanded.add(key);
|
|
57
|
+
this.requestRender();
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
render(width: number): string[] {
|
|
63
|
+
const w = Math.max(24, width);
|
|
64
|
+
const inner = Math.max(1, w - 4);
|
|
65
|
+
const th = this.theme;
|
|
66
|
+
const snapshot = this.snapshotProvider();
|
|
67
|
+
const body = this.sectionLines(this.currentSection(), snapshot, inner);
|
|
68
|
+
const maxBody = Math.max(6, Math.floor(this.tui.terminal.rows * 0.8) - 6);
|
|
69
|
+
const selectedLine = this.selectedLineIndex(body);
|
|
70
|
+
const maxStart = Math.max(0, body.length - maxBody);
|
|
71
|
+
const start = Math.min(maxStart, Math.max(0, selectedLine - Math.floor(maxBody / 2)));
|
|
72
|
+
const visible = body.slice(start, start + maxBody);
|
|
73
|
+
const lines: string[] = [];
|
|
74
|
+
|
|
75
|
+
lines.push(th.fg("border", `╭${"─".repeat(w - 2)}╮`));
|
|
76
|
+
lines.push(this.row(` ${th.fg("accent", th.bold("Workflow Inspector"))} ${th.fg("dim", snapshot.title)}`, inner));
|
|
77
|
+
lines.push(this.row(` ${this.tabs()}`, inner));
|
|
78
|
+
lines.push(this.row(th.fg("dim", "─".repeat(inner)), inner));
|
|
79
|
+
|
|
80
|
+
for (const line of visible) lines.push(this.row(line.text, inner));
|
|
81
|
+
while (visible.length < maxBody) {
|
|
82
|
+
visible.push({ text: "" });
|
|
83
|
+
lines.push(this.row("", inner));
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
lines.push(this.row(th.fg("dim", "─".repeat(inner)), inner));
|
|
87
|
+
const pct = body.length <= maxBody ? "100%" : `${Math.round(((start + visible.length) / body.length) * 100)}%`;
|
|
88
|
+
const selected = this.selected[this.currentSection()] + 1;
|
|
89
|
+
const count = Math.max(1, this.itemCount(this.currentSection()));
|
|
90
|
+
lines.push(
|
|
91
|
+
this.row(
|
|
92
|
+
`${th.fg("dim", `${body.length} lines · ${pct} · ${selected}/${count}`)} ${th.fg("dim", "· tab sections · ↑↓ select · enter expand · q/esc close")}`,
|
|
93
|
+
inner,
|
|
94
|
+
),
|
|
95
|
+
);
|
|
96
|
+
lines.push(th.fg("border", `╰${"─".repeat(w - 2)}╯`));
|
|
97
|
+
return lines.map((line) => truncateDisplay(line, w));
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
invalidate(): void {}
|
|
101
|
+
|
|
102
|
+
private currentSection(): Section {
|
|
103
|
+
return SECTIONS[this.sectionIndex] ?? "Overview";
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
private requestRender(): void {
|
|
107
|
+
this.tui.requestRender();
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
private tabs(): string {
|
|
111
|
+
return SECTIONS.map((section, index) => {
|
|
112
|
+
const label = index === this.sectionIndex ? this.theme.fg("accent", this.theme.bold(section)) : this.theme.fg("muted", section);
|
|
113
|
+
return label;
|
|
114
|
+
}).join(this.theme.fg("dim", " | "));
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
private row(content: string, innerWidth: number): string {
|
|
118
|
+
const padded = padRight(truncateDisplay(content, innerWidth), innerWidth);
|
|
119
|
+
return this.theme.fg("border", "│") + " " + padded + " " + this.theme.fg("border", "│");
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
private itemCount(section: Section): number {
|
|
123
|
+
return this.sectionLines(section, this.snapshotProvider(), 80).filter((line) => line.selectable).length;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
private selectedKey(section: Section): string | undefined {
|
|
127
|
+
let index = -1;
|
|
128
|
+
for (const line of this.sectionLines(section, this.snapshotProvider(), 80)) {
|
|
129
|
+
if (!line.selectable) continue;
|
|
130
|
+
index++;
|
|
131
|
+
if (index === this.selected[section]) return line.key;
|
|
132
|
+
}
|
|
133
|
+
return undefined;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
private selectedLineIndex(lines: readonly InspectorLine[]): number {
|
|
137
|
+
let index = -1;
|
|
138
|
+
for (let i = 0; i < lines.length; i++) {
|
|
139
|
+
if (!lines[i].selectable) continue;
|
|
140
|
+
index++;
|
|
141
|
+
if (index === this.selected[this.currentSection()]) return i;
|
|
142
|
+
}
|
|
143
|
+
return 0;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
private sectionLines(section: Section, snapshot: WorkflowProgressSnapshot, width: number): InspectorLine[] {
|
|
147
|
+
switch (section) {
|
|
148
|
+
case "Overview":
|
|
149
|
+
return this.overviewLines(snapshot, width);
|
|
150
|
+
case "Agents":
|
|
151
|
+
return this.agentLines(snapshot, width);
|
|
152
|
+
case "Findings":
|
|
153
|
+
return this.findingLines(snapshot, width);
|
|
154
|
+
case "Logs":
|
|
155
|
+
return this.logLines(snapshot);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
private overviewLines(snapshot: WorkflowProgressSnapshot, width: number): InspectorLine[] {
|
|
160
|
+
const elapsed = formatDuration((snapshot.doneAt ?? Date.now()) - snapshot.startedAt);
|
|
161
|
+
const lines: InspectorLine[] = [
|
|
162
|
+
{ text: `${this.theme.fg("muted", "Phase")} ${this.theme.fg("accent", snapshot.currentPhase)}`, selectable: true, key: "overview:phase" },
|
|
163
|
+
{ text: `${this.theme.fg("muted", "Elapsed")} ${elapsed}`, selectable: true, key: "overview:elapsed" },
|
|
164
|
+
];
|
|
165
|
+
if (snapshot.counters.length > 0) {
|
|
166
|
+
lines.push({ text: this.theme.fg("dim", "Counters") });
|
|
167
|
+
for (const counter of snapshot.counters) {
|
|
168
|
+
lines.push({ text: ` ${counter.label}: ${formatCount(counter.value)}`, selectable: true, key: `counter:${counter.key}` });
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
if (snapshot.summary.length > 0) {
|
|
172
|
+
lines.push({ text: this.theme.fg("dim", "Summary") });
|
|
173
|
+
for (const [key, value] of snapshot.summary) {
|
|
174
|
+
lines.push({ text: truncateDisplay(` ${key}: ${value}`, width), selectable: true, key: `summary:${key}` });
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
return lines;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
private agentLines(snapshot: WorkflowProgressSnapshot, _width: number): InspectorLine[] {
|
|
181
|
+
const lines: InspectorLine[] = [];
|
|
182
|
+
let selectableIndex = -1;
|
|
183
|
+
for (const phase of snapshot.phases) {
|
|
184
|
+
if (phase.agents.length === 0) continue;
|
|
185
|
+
lines.push({ text: this.theme.fg("dim", phase.title) });
|
|
186
|
+
for (const agent of phase.agents) {
|
|
187
|
+
selectableIndex++;
|
|
188
|
+
const key = `agent:${agent.id}`;
|
|
189
|
+
const selected = this.currentSection() === "Agents" && this.selected.Agents === selectableIndex;
|
|
190
|
+
lines.push({ text: this.agentLine(agent, selected), selectable: true, key });
|
|
191
|
+
if (this.expanded.has(key)) lines.push(...this.agentDetails(agent));
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
return lines.length > 0 ? lines : [{ text: this.theme.fg("dim", "No agents yet.") }];
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
private agentLine(agent: AgentRowSnapshot, selected: boolean): string {
|
|
198
|
+
const elapsed = agent.startedAt === undefined ? "queued" : formatDuration((agent.doneAt ?? Date.now()) - agent.startedAt);
|
|
199
|
+
const details = [`${agent.toolUses} tools`, elapsed];
|
|
200
|
+
if (agent.lastTool) details.unshift(agent.lastTool);
|
|
201
|
+
const text = `${statusIcon(agent.status, this.theme)} ${agent.label} ${this.theme.fg("dim", `· ${details.join(" · ")}`)}`;
|
|
202
|
+
return selected ? this.theme.bg("selectedBg", text) : text;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
private agentDetails(agent: AgentRowSnapshot): InspectorLine[] {
|
|
206
|
+
const lines: InspectorLine[] = [];
|
|
207
|
+
if (agent.error) lines.push({ text: ` ${this.theme.fg("error", `Error: ${agent.error}`)}` });
|
|
208
|
+
if (agent.lastTool) lines.push({ text: ` ${this.theme.fg("dim", `Last tool: ${agent.lastTool}`)}` });
|
|
209
|
+
return lines.length > 0 ? lines : [{ text: ` ${this.theme.fg("dim", "No details yet.")}` }];
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
private findingLines(snapshot: WorkflowProgressSnapshot, _width: number): InspectorLine[] {
|
|
213
|
+
const lines: InspectorLine[] = [];
|
|
214
|
+
let selectableIndex = -1;
|
|
215
|
+
for (const [lane, items] of snapshot.lanes) {
|
|
216
|
+
lines.push({ text: this.theme.fg("dim", `${lane} (${items.length})`) });
|
|
217
|
+
items.forEach((item, index) => {
|
|
218
|
+
selectableIndex++;
|
|
219
|
+
const key = `finding:${lane}:${index}:${item.createdAt}`;
|
|
220
|
+
const selected = this.currentSection() === "Findings" && this.selected.Findings === selectableIndex;
|
|
221
|
+
lines.push({ text: this.findingLine(item, selected), selectable: true, key });
|
|
222
|
+
if (this.expanded.has(key) && item.details) lines.push({ text: ` ${this.theme.fg("dim", item.details)}` });
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
return lines.length > 0 ? lines : [{ text: this.theme.fg("dim", "No findings lanes yet.") }];
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
private findingLine(item: WorkflowLaneItemSnapshot, selected: boolean): string {
|
|
229
|
+
const subtitle = item.subtitle ? ` ${this.theme.fg("accent", item.subtitle)}` : "";
|
|
230
|
+
const text = `${statusIcon(item.status, this.theme)} ${item.title}${subtitle}`;
|
|
231
|
+
return selected ? this.theme.bg("selectedBg", text) : text;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
private logLines(snapshot: WorkflowProgressSnapshot): InspectorLine[] {
|
|
235
|
+
if (snapshot.logs.length === 0) return [{ text: this.theme.fg("dim", "No logs yet.") }];
|
|
236
|
+
return snapshot.logs.map((log, index) => {
|
|
237
|
+
const key = `log:${index}`;
|
|
238
|
+
const selected = this.currentSection() === "Logs" && this.selected.Logs === index;
|
|
239
|
+
const text = selected ? this.theme.bg("selectedBg", log) : log;
|
|
240
|
+
return { text, selectable: true, key };
|
|
241
|
+
});
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function padRight(text: string, width: number): string {
|
|
246
|
+
const visible = visibleWidth(text);
|
|
247
|
+
return text + " ".repeat(Math.max(0, width - visible));
|
|
248
|
+
}
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { Box, type Component, Text } from "@earendil-works/pi-tui";
|
|
3
|
+
import { badge, formatCount } from "./workflow-format.ts";
|
|
4
|
+
|
|
5
|
+
export interface WorkflowResultEnvelope {
|
|
6
|
+
name: string;
|
|
7
|
+
result: unknown;
|
|
8
|
+
completedAt: number;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface CodeReviewFinding {
|
|
12
|
+
file: string;
|
|
13
|
+
line?: number;
|
|
14
|
+
severity?: string;
|
|
15
|
+
verdict?: string;
|
|
16
|
+
summary: string;
|
|
17
|
+
evidence?: string;
|
|
18
|
+
failure_scenario?: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface CodeReviewResult {
|
|
22
|
+
summary: string;
|
|
23
|
+
findings: CodeReviewFinding[];
|
|
24
|
+
stats?: Record<string, string | number>;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function isWorkflowResult(value: unknown): value is WorkflowResultEnvelope {
|
|
28
|
+
if (!isRecord(value)) return false;
|
|
29
|
+
return typeof value.name === "string" && "result" in value && typeof value.completedAt === "number";
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function isFinding(value: unknown): value is CodeReviewFinding {
|
|
33
|
+
if (!isRecord(value)) return false;
|
|
34
|
+
if (typeof value.file !== "string" || typeof value.summary !== "string") return false;
|
|
35
|
+
if (value.line !== undefined && typeof value.line !== "number") return false;
|
|
36
|
+
if (value.severity !== undefined && typeof value.severity !== "string") return false;
|
|
37
|
+
if (value.verdict !== undefined && typeof value.verdict !== "string") return false;
|
|
38
|
+
if (value.evidence !== undefined && typeof value.evidence !== "string") return false;
|
|
39
|
+
if (value.failure_scenario !== undefined && typeof value.failure_scenario !== "string") return false;
|
|
40
|
+
return true;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function isCodeReviewResult(value: unknown): value is CodeReviewResult {
|
|
44
|
+
if (!isRecord(value)) return false;
|
|
45
|
+
if (typeof value.summary !== "string" || !Array.isArray(value.findings)) return false;
|
|
46
|
+
if (!value.findings.every(isFinding)) return false;
|
|
47
|
+
if (value.stats !== undefined && !isStats(value.stats)) return false;
|
|
48
|
+
return true;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function renderWorkflowResult(name: string, result: unknown, expanded: boolean, theme: Theme): Component {
|
|
52
|
+
const box = new Box(1, 1, (text) => theme.bg("customMessageBg", text));
|
|
53
|
+
box.addChild(new Text(renderWorkflowResultText(name, result, expanded, theme), 0, 0));
|
|
54
|
+
return box;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function renderWorkflowResultText(name: string, result: unknown, expanded: boolean, theme: Theme): string {
|
|
58
|
+
if (name === "code-review" && isCodeReviewResult(result)) {
|
|
59
|
+
return renderCodeReviewResult(name, result, expanded, theme);
|
|
60
|
+
}
|
|
61
|
+
return renderGenericWorkflowResult(name, result, expanded, theme);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function renderCodeReviewResult(name: string, result: CodeReviewResult, expanded: boolean, theme: Theme): string {
|
|
65
|
+
const icon = theme.fg("success", "✓");
|
|
66
|
+
const title = theme.fg("accent", theme.bold(`Workflow: ${name}`));
|
|
67
|
+
const lines = [`${icon} ${title}`, theme.fg("muted", result.summary)];
|
|
68
|
+
const stats = statsLine(result.stats, theme);
|
|
69
|
+
if (stats) lines.push(stats);
|
|
70
|
+
|
|
71
|
+
if (result.findings.length === 0) {
|
|
72
|
+
lines.push(theme.fg("success", "No findings."));
|
|
73
|
+
return lines.join("\n");
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const findings = expanded ? result.findings : result.findings.slice(0, 3);
|
|
77
|
+
lines.push(theme.fg("dim", expanded ? "Findings:" : "Top findings:"));
|
|
78
|
+
for (const finding of findings) {
|
|
79
|
+
lines.push(renderFinding(finding, expanded, theme));
|
|
80
|
+
}
|
|
81
|
+
if (!expanded && result.findings.length > findings.length) {
|
|
82
|
+
lines.push(theme.fg("dim", `… ${result.findings.length - findings.length} more finding(s)`));
|
|
83
|
+
}
|
|
84
|
+
return lines.join("\n");
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function renderFinding(finding: CodeReviewFinding, expanded: boolean, theme: Theme): string {
|
|
88
|
+
const location = `${finding.file}${finding.line != null ? `:${finding.line}` : ""}`;
|
|
89
|
+
const severityColor = finding.severity === "bug" ? "error" : "warning";
|
|
90
|
+
const verdictColor = finding.verdict === "CONFIRMED" ? "success" : finding.verdict === "PLAUSIBLE" ? "warning" : "muted";
|
|
91
|
+
let text =
|
|
92
|
+
` ${badge(finding.severity ?? "finding", severityColor, theme)} ` +
|
|
93
|
+
`${badge(finding.verdict ?? "", verdictColor, theme)} ` +
|
|
94
|
+
`${theme.fg("accent", location)} ${theme.fg("muted", finding.summary)}`;
|
|
95
|
+
if (expanded) {
|
|
96
|
+
if (finding.failure_scenario) text += `\n ${theme.fg("dim", `Failure: ${finding.failure_scenario}`)}`;
|
|
97
|
+
if (finding.evidence) text += `\n ${theme.fg("dim", `Evidence: ${finding.evidence}`)}`;
|
|
98
|
+
}
|
|
99
|
+
return text;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function renderGenericWorkflowResult(name: string, result: unknown, expanded: boolean, theme: Theme): string {
|
|
103
|
+
const lines = [`${theme.fg("success", "✓")} ${theme.fg("accent", theme.bold(`Workflow: ${name}`))}`];
|
|
104
|
+
const summary = extractSummary(result);
|
|
105
|
+
if (summary) lines.push(theme.fg("muted", summary));
|
|
106
|
+
if (expanded) lines.push(theme.fg("dim", safeJson(result)));
|
|
107
|
+
else if (!summary) lines.push(theme.fg("dim", compactJson(result)));
|
|
108
|
+
return lines.join("\n");
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function statsLine(stats: Record<string, string | number> | undefined, theme: Theme): string | undefined {
|
|
112
|
+
if (!stats) return undefined;
|
|
113
|
+
const ordered = ["files", "candidates", "dropped", "verified", "kept"];
|
|
114
|
+
const parts = ordered.flatMap((key) => {
|
|
115
|
+
const value = stats[key];
|
|
116
|
+
if (value === undefined) return [];
|
|
117
|
+
return [`${key} ${typeof value === "number" ? formatCount(value) : value}`];
|
|
118
|
+
});
|
|
119
|
+
if (parts.length === 0) return undefined;
|
|
120
|
+
return theme.fg("dim", parts.join(" · "));
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function extractSummary(value: unknown): string | undefined {
|
|
124
|
+
if (typeof value === "string") return value;
|
|
125
|
+
if (isRecord(value) && typeof value.summary === "string") return value.summary;
|
|
126
|
+
return undefined;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function compactJson(value: unknown): string {
|
|
130
|
+
const json = safeJson(value).replace(/\s+/g, " ").trim();
|
|
131
|
+
return json.length > 240 ? `${json.slice(0, 237)}...` : json;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function safeJson(value: unknown): string {
|
|
135
|
+
try {
|
|
136
|
+
return JSON.stringify(value, null, 2) ?? String(value);
|
|
137
|
+
} catch (error) {
|
|
138
|
+
return error instanceof Error ? error.message : String(error);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function isStats(value: unknown): value is Record<string, string | number> {
|
|
143
|
+
if (!isRecord(value)) return false;
|
|
144
|
+
return Object.values(value).every((entry) => typeof entry === "string" || typeof entry === "number");
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
148
|
+
return typeof value === "object" && value !== null;
|
|
149
|
+
}
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import type { AgentRowSnapshot, PhaseSnapshot, WorkflowProgressSnapshot } from "../progress.ts";
|
|
3
|
+
import { formatCount, formatDuration, statusIcon, truncateDisplay } from "./workflow-format.ts";
|
|
4
|
+
|
|
5
|
+
const MAX_WIDGET_LINES = 12;
|
|
6
|
+
const SPINNER = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
|
7
|
+
|
|
8
|
+
export interface WorkflowWidget {
|
|
9
|
+
nextFrame(): void;
|
|
10
|
+
render(width: number, theme: Theme): string[];
|
|
11
|
+
invalidate(): void;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function createWorkflowWidget(snapshotProvider: () => WorkflowProgressSnapshot): WorkflowWidget {
|
|
15
|
+
return new LiveWorkflowWidget(snapshotProvider);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function renderWorkflowWidgetLines(
|
|
19
|
+
snapshot: WorkflowProgressSnapshot,
|
|
20
|
+
frame: number,
|
|
21
|
+
width: number,
|
|
22
|
+
theme: Theme,
|
|
23
|
+
): string[] {
|
|
24
|
+
const safeWidth = Math.max(1, width);
|
|
25
|
+
const agents = snapshot.phases.flatMap((phase) => phase.agents);
|
|
26
|
+
const running = agents.filter((agent) => agent.status === "running").length;
|
|
27
|
+
const queued = agents.filter((agent) => agent.status === "queued").length;
|
|
28
|
+
const done = agents.filter((agent) => agent.status === "done").length;
|
|
29
|
+
const failed = agents.filter((agent) => agent.status === "failed").length;
|
|
30
|
+
const active = running + queued;
|
|
31
|
+
const elapsed = formatDuration((snapshot.doneAt ?? Date.now()) - snapshot.startedAt);
|
|
32
|
+
const spinner = active > 0 ? theme.fg("accent", SPINNER[frame % SPINNER.length]) : theme.fg("success", "✓");
|
|
33
|
+
|
|
34
|
+
const headingParts = [`${done}/${agents.length} done`, elapsed];
|
|
35
|
+
if (running > 0) headingParts.unshift(`${running} running`);
|
|
36
|
+
if (queued > 0) headingParts.unshift(`${queued} queued`);
|
|
37
|
+
if (failed > 0) headingParts.unshift(theme.fg("error", `${failed} failed`));
|
|
38
|
+
|
|
39
|
+
const lines: string[] = [
|
|
40
|
+
truncateDisplay(
|
|
41
|
+
`${spinner} ${theme.bold(snapshot.title)} ${theme.fg("dim", "·")} ${theme.fg("muted", snapshot.currentPhase)} ${theme.fg("dim", "·")} ${headingParts.join(` ${theme.fg("dim", "·")} `)}`,
|
|
42
|
+
safeWidth,
|
|
43
|
+
),
|
|
44
|
+
];
|
|
45
|
+
|
|
46
|
+
const footer = footerLine(snapshot, theme);
|
|
47
|
+
const bodyBudget = Math.max(0, MAX_WIDGET_LINES - lines.length - (footer ? 1 : 0));
|
|
48
|
+
const bodyLines = prioritizedBodyLines(snapshot.phases, theme);
|
|
49
|
+
const visibleBody = bodyLines.slice(0, bodyBudget);
|
|
50
|
+
const hidden = bodyLines.length - visibleBody.length;
|
|
51
|
+
|
|
52
|
+
for (const line of visibleBody) lines.push(truncateDisplay(line, safeWidth));
|
|
53
|
+
if (hidden > 0 && lines.length < MAX_WIDGET_LINES) {
|
|
54
|
+
lines.push(truncateDisplay(`${theme.fg("dim", "└─")} ${theme.fg("dim", `+${hidden} more`)}`, safeWidth));
|
|
55
|
+
}
|
|
56
|
+
if (footer && lines.length < MAX_WIDGET_LINES) lines.push(truncateDisplay(footer, safeWidth));
|
|
57
|
+
|
|
58
|
+
return lines.slice(0, MAX_WIDGET_LINES).map((line) => truncateDisplay(line, safeWidth));
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
class LiveWorkflowWidget implements WorkflowWidget {
|
|
62
|
+
private frame = 0;
|
|
63
|
+
private cachedWidth: number | undefined;
|
|
64
|
+
private cachedFrame: number | undefined;
|
|
65
|
+
private cachedDoneAt: number | undefined;
|
|
66
|
+
private cachedLines: string[] | undefined;
|
|
67
|
+
|
|
68
|
+
constructor(private readonly snapshotProvider: () => WorkflowProgressSnapshot) {}
|
|
69
|
+
|
|
70
|
+
nextFrame(): void {
|
|
71
|
+
this.frame = (this.frame + 1) % SPINNER.length;
|
|
72
|
+
this.invalidate();
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
render(width: number, theme: Theme): string[] {
|
|
76
|
+
const snapshot = this.snapshotProvider();
|
|
77
|
+
if (
|
|
78
|
+
this.cachedLines &&
|
|
79
|
+
this.cachedWidth === width &&
|
|
80
|
+
this.cachedFrame === this.frame &&
|
|
81
|
+
this.cachedDoneAt === snapshot.doneAt
|
|
82
|
+
) {
|
|
83
|
+
return this.cachedLines;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
this.cachedLines = renderWorkflowWidgetLines(snapshot, this.frame, width, theme);
|
|
87
|
+
this.cachedWidth = width;
|
|
88
|
+
this.cachedFrame = this.frame;
|
|
89
|
+
this.cachedDoneAt = snapshot.doneAt;
|
|
90
|
+
return this.cachedLines;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
invalidate(): void {
|
|
94
|
+
this.cachedWidth = undefined;
|
|
95
|
+
this.cachedFrame = undefined;
|
|
96
|
+
this.cachedDoneAt = undefined;
|
|
97
|
+
this.cachedLines = undefined;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function prioritizedBodyLines(phases: readonly PhaseSnapshot[], theme: Theme): string[] {
|
|
102
|
+
const activePhases = phases.filter((phase) => phase.agents.some((agent) => agent.status === "running" || agent.status === "queued"));
|
|
103
|
+
const inactivePhases = phases.filter((phase) => !activePhases.includes(phase));
|
|
104
|
+
return [...activePhases, ...inactivePhases].flatMap((phase) => phaseLines(phase, theme));
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function phaseLines(phase: PhaseSnapshot, theme: Theme): string[] {
|
|
108
|
+
if (phase.agents.length === 0 && phase.title === "Workflow") return [];
|
|
109
|
+
const running = phase.agents.filter((agent) => agent.status === "running").length;
|
|
110
|
+
const done = phase.agents.filter((agent) => agent.status === "done").length;
|
|
111
|
+
const failed = phase.agents.filter((agent) => agent.status === "failed").length;
|
|
112
|
+
const parts: string[] = [];
|
|
113
|
+
if (running > 0) parts.push(`${running} running`);
|
|
114
|
+
if (done > 0) parts.push(`${done} done`);
|
|
115
|
+
if (failed > 0) parts.push(`${failed} failed`);
|
|
116
|
+
|
|
117
|
+
const lines = [`${theme.fg("dim", "├─")} ${theme.fg(running > 0 ? "accent" : "muted", phase.title)}${parts.length ? ` ${theme.fg("dim", `(${parts.join(" · ")})`)}` : ""}`];
|
|
118
|
+
const active = phase.agents.filter((agent) => agent.status === "running" || agent.status === "queued");
|
|
119
|
+
const failedAgents = phase.agents.filter((agent) => agent.status === "failed");
|
|
120
|
+
const completed = phase.agents.filter((agent) => agent.status === "done");
|
|
121
|
+
for (const agent of [...active, ...failedAgents, ...completed]) lines.push(agentLine(agent, theme));
|
|
122
|
+
return lines;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function agentLine(agent: AgentRowSnapshot, theme: Theme): string {
|
|
126
|
+
const detailParts: string[] = [];
|
|
127
|
+
if (agent.toolUses > 0) detailParts.push(`${agent.toolUses} tool${agent.toolUses === 1 ? "" : "s"}`);
|
|
128
|
+
if (agent.status === "running" && agent.lastTool) detailParts.push(agent.lastTool);
|
|
129
|
+
if (agent.startedAt !== undefined) detailParts.push(formatDuration((agent.doneAt ?? Date.now()) - agent.startedAt));
|
|
130
|
+
if (agent.status === "failed" && agent.error) detailParts.push(agent.error);
|
|
131
|
+
|
|
132
|
+
const activity = detailParts.length > 0 ? ` ${theme.fg("dim", `· ${detailParts.join(" · ")}`)}` : "";
|
|
133
|
+
return `${theme.fg("dim", "│ ")} ${statusIcon(agent.status, theme)} ${theme.fg(agent.status === "running" ? "text" : "muted", agent.label)}${activity}`;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function footerLine(snapshot: WorkflowProgressSnapshot, theme: Theme): string | undefined {
|
|
137
|
+
const counters = snapshot.counters.slice(0, 4).map((counter) => `${counter.label} ${formatCount(counter.value)}`);
|
|
138
|
+
const latestLog = snapshot.logs[snapshot.logs.length - 1];
|
|
139
|
+
const parts = counters.length > 0 ? counters : [];
|
|
140
|
+
if (latestLog) parts.push(latestLog);
|
|
141
|
+
if (parts.length === 0) return undefined;
|
|
142
|
+
return `${theme.fg("dim", "└─")} ${theme.fg("dim", parts.join(" · "))}`;
|
|
143
|
+
}
|