pi-workflow-engine 0.1.1 → 0.3.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/README.md +109 -56
- package/package.json +2 -1
- package/src/advisory-schema.ts +64 -0
- package/src/agent-runner.ts +8 -4
- package/src/engine.ts +18 -2
- package/src/index.ts +79 -14
- package/src/progress.ts +258 -23
- package/src/types.ts +29 -0
- package/src/ui/workflow-format.ts +83 -0
- package/src/ui/workflow-inspector.ts +248 -0
- package/src/ui/workflow-result-renderer.ts +195 -0
- package/src/ui/workflow-widget.ts +143 -0
- package/src/workflows.ts +4 -1
- package/workflows/code-review.ts +163 -58
- package/workflows/diagnose.ts +305 -0
- package/workflows/perf-review.ts +295 -0
- package/workflows/refactor-scout.ts +289 -0
|
@@ -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,195 @@
|
|
|
1
|
+
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { Box, type Component, Text } from "@earendil-works/pi-tui";
|
|
3
|
+
import type { AdvisoryFinding, AdvisoryLocation, AdvisoryReport } from "../advisory-schema.ts";
|
|
4
|
+
import { badge, formatCount, type WorkflowThemeColor } from "./workflow-format.ts";
|
|
5
|
+
|
|
6
|
+
export interface WorkflowResultEnvelope {
|
|
7
|
+
name: string;
|
|
8
|
+
result: unknown;
|
|
9
|
+
completedAt: number;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface AdvisoryWorkflowResult extends AdvisoryReport {
|
|
13
|
+
stats?: Record<string, string | number>;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function isWorkflowResult(value: unknown): value is WorkflowResultEnvelope {
|
|
17
|
+
if (!isRecord(value)) return false;
|
|
18
|
+
return typeof value.name === "string" && "result" in value && typeof value.completedAt === "number";
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function isAdvisoryLocation(value: unknown): value is AdvisoryLocation {
|
|
22
|
+
if (!isRecord(value)) return false;
|
|
23
|
+
if (typeof value.file !== "string") return false;
|
|
24
|
+
if (value.line !== undefined && typeof value.line !== "number") return false;
|
|
25
|
+
if (value.symbol !== undefined && typeof value.symbol !== "string") return false;
|
|
26
|
+
return true;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function isAdvisoryFinding(value: unknown): value is AdvisoryFinding {
|
|
30
|
+
if (!isRecord(value)) return false;
|
|
31
|
+
if (typeof value.summary !== "string") return false;
|
|
32
|
+
if (typeof value.category !== "string") return false;
|
|
33
|
+
if (!isSeverity(value.severity)) return false;
|
|
34
|
+
if (!isConfidence(value.confidence)) return false;
|
|
35
|
+
if (!Array.isArray(value.locations) || !value.locations.every(isAdvisoryLocation)) return false;
|
|
36
|
+
if (!Array.isArray(value.evidence) || !value.evidence.every((entry) => typeof entry === "string")) return false;
|
|
37
|
+
if (typeof value.impact !== "string") return false;
|
|
38
|
+
if (typeof value.recommendation !== "string") return false;
|
|
39
|
+
return true;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function isAdvisoryReport(value: unknown): value is AdvisoryWorkflowResult {
|
|
43
|
+
if (!isRecord(value)) return false;
|
|
44
|
+
if (typeof value.summary !== "string" || !Array.isArray(value.findings)) return false;
|
|
45
|
+
if (!value.findings.every(isAdvisoryFinding)) return false;
|
|
46
|
+
if (!Array.isArray(value.nextSteps) || !value.nextSteps.every((entry) => typeof entry === "string")) 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 (isAdvisoryReport(result)) {
|
|
59
|
+
return renderAdvisoryResult(name, result, expanded, theme);
|
|
60
|
+
}
|
|
61
|
+
return renderGenericWorkflowResult(name, result, expanded, theme);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function renderAdvisoryResult(name: string, result: AdvisoryWorkflowResult, 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
|
+
if (expanded && result.nextSteps.length > 0) renderNextSteps(result.nextSteps, lines, theme);
|
|
74
|
+
return lines.join("\n");
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const findings = expanded ? result.findings : result.findings.slice(0, 3);
|
|
78
|
+
lines.push(theme.fg("dim", expanded ? "Findings:" : "Top findings:"));
|
|
79
|
+
for (const finding of findings) {
|
|
80
|
+
lines.push(renderAdvisoryFinding(finding, expanded, theme));
|
|
81
|
+
}
|
|
82
|
+
if (!expanded && result.findings.length > findings.length) {
|
|
83
|
+
lines.push(theme.fg("dim", `… ${result.findings.length - findings.length} more finding(s)`));
|
|
84
|
+
}
|
|
85
|
+
if (expanded && result.nextSteps.length > 0) renderNextSteps(result.nextSteps, lines, theme);
|
|
86
|
+
return lines.join("\n");
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function renderAdvisoryFinding(finding: AdvisoryFinding, expanded: boolean, theme: Theme): string {
|
|
90
|
+
const location = formatLocation(finding.locations[0]);
|
|
91
|
+
let text =
|
|
92
|
+
` ${badge(finding.category, "accent", theme)} ` +
|
|
93
|
+
`${badge(finding.severity, severityColor(finding.severity), theme)} ` +
|
|
94
|
+
`${badge(`${finding.confidence} confidence`, confidenceColor(finding.confidence), theme)} ` +
|
|
95
|
+
`${theme.fg("accent", location)} ${theme.fg("muted", finding.summary)}`;
|
|
96
|
+
if (expanded) {
|
|
97
|
+
text += `\n ${theme.fg("dim", `Impact: ${finding.impact}`)}`;
|
|
98
|
+
text += `\n ${theme.fg("dim", `Evidence: ${finding.evidence.join("; ") || "(none cited)"}`)}`;
|
|
99
|
+
text += `\n ${theme.fg("dim", `Recommendation: ${finding.recommendation}`)}`;
|
|
100
|
+
}
|
|
101
|
+
return text;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function renderNextSteps(nextSteps: string[], lines: string[], theme: Theme): void {
|
|
105
|
+
lines.push(theme.fg("dim", "Next steps:"));
|
|
106
|
+
for (const step of nextSteps) {
|
|
107
|
+
lines.push(` - ${theme.fg("muted", step)}`);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function formatLocation(location: AdvisoryLocation | undefined): string {
|
|
112
|
+
if (!location) return "(no location)";
|
|
113
|
+
const line = location.line != null ? `:${location.line}` : "";
|
|
114
|
+
const symbol = location.symbol ? ` (${location.symbol})` : "";
|
|
115
|
+
return `${location.file}${line}${symbol}`;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function severityColor(severity: AdvisoryFinding["severity"]): WorkflowThemeColor {
|
|
119
|
+
switch (severity) {
|
|
120
|
+
case "high":
|
|
121
|
+
return "error";
|
|
122
|
+
case "medium":
|
|
123
|
+
return "warning";
|
|
124
|
+
case "low":
|
|
125
|
+
return "muted";
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function confidenceColor(confidence: AdvisoryFinding["confidence"]): WorkflowThemeColor {
|
|
130
|
+
switch (confidence) {
|
|
131
|
+
case "high":
|
|
132
|
+
return "success";
|
|
133
|
+
case "medium":
|
|
134
|
+
return "warning";
|
|
135
|
+
case "low":
|
|
136
|
+
return "muted";
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function renderGenericWorkflowResult(name: string, result: unknown, expanded: boolean, theme: Theme): string {
|
|
141
|
+
const lines = [`${theme.fg("success", "✓")} ${theme.fg("accent", theme.bold(`Workflow: ${name}`))}`];
|
|
142
|
+
const summary = extractSummary(result);
|
|
143
|
+
if (summary) lines.push(theme.fg("muted", summary));
|
|
144
|
+
if (expanded) lines.push(theme.fg("dim", safeJson(result)));
|
|
145
|
+
else if (!summary) lines.push(theme.fg("dim", compactJson(result)));
|
|
146
|
+
return lines.join("\n");
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function statsLine(stats: Record<string, string | number> | undefined, theme: Theme): string | undefined {
|
|
150
|
+
if (!stats) return undefined;
|
|
151
|
+
const ordered = ["files", "candidates", "dropped", "verified", "kept"];
|
|
152
|
+
const parts = ordered.flatMap((key) => {
|
|
153
|
+
const value = stats[key];
|
|
154
|
+
if (value === undefined) return [];
|
|
155
|
+
return [`${key} ${typeof value === "number" ? formatCount(value) : value}`];
|
|
156
|
+
});
|
|
157
|
+
if (parts.length === 0) return undefined;
|
|
158
|
+
return theme.fg("dim", parts.join(" · "));
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function extractSummary(value: unknown): string | undefined {
|
|
162
|
+
if (typeof value === "string") return value;
|
|
163
|
+
if (isRecord(value) && typeof value.summary === "string") return value.summary;
|
|
164
|
+
return undefined;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function compactJson(value: unknown): string {
|
|
168
|
+
const json = safeJson(value).replace(/\s+/g, " ").trim();
|
|
169
|
+
return json.length > 240 ? `${json.slice(0, 237)}...` : json;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function safeJson(value: unknown): string {
|
|
173
|
+
try {
|
|
174
|
+
return JSON.stringify(value, null, 2) ?? String(value);
|
|
175
|
+
} catch (error) {
|
|
176
|
+
return error instanceof Error ? error.message : String(error);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function isSeverity(value: unknown): value is AdvisoryFinding["severity"] {
|
|
181
|
+
return value === "low" || value === "medium" || value === "high";
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function isConfidence(value: unknown): value is AdvisoryFinding["confidence"] {
|
|
185
|
+
return value === "low" || value === "medium" || value === "high";
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function isStats(value: unknown): value is Record<string, string | number> {
|
|
189
|
+
if (!isRecord(value)) return false;
|
|
190
|
+
return Object.values(value).every((entry) => typeof entry === "string" || typeof entry === "number");
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
194
|
+
return typeof value === "object" && value !== null;
|
|
195
|
+
}
|
|
@@ -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
|
+
}
|
package/src/workflows.ts
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import type { WorkflowModule } from "./types.ts";
|
|
2
2
|
import * as codeReview from "../workflows/code-review.ts";
|
|
3
3
|
import * as ping from "../workflows/ping.ts";
|
|
4
|
+
import * as refactorScout from "../workflows/refactor-scout.ts";
|
|
5
|
+
import * as diagnose from "../workflows/diagnose.ts";
|
|
6
|
+
import * as perfReview from "../workflows/perf-review.ts";
|
|
4
7
|
|
|
5
8
|
/**
|
|
6
9
|
* Workflows statically imported by the extension. These are loaded through pi's own
|
|
@@ -11,4 +14,4 @@ import * as ping from "../workflows/ping.ts";
|
|
|
11
14
|
* it to this array. (Drop-in workflows discovered at runtime are also supported — see
|
|
12
15
|
* discovery.ts — but the static registry is the always-correct path.)
|
|
13
16
|
*/
|
|
14
|
-
export const BUILTIN_WORKFLOWS: WorkflowModule[] = [codeReview, ping];
|
|
17
|
+
export const BUILTIN_WORKFLOWS: WorkflowModule[] = [codeReview, ping, refactorScout, diagnose, perfReview];
|