pi-workflow-engine 0.4.0 → 0.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (23) hide show
  1. package/.pi/extensions/pi-workflow-engine/index.ts +199 -18
  2. package/.pi/extensions/pi-workflow-engine/src/discovery.ts +2 -33
  3. package/.pi/extensions/pi-workflow-engine/src/dynamax.ts +123 -0
  4. package/.pi/extensions/pi-workflow-engine/src/engine.ts +11 -7
  5. package/.pi/extensions/pi-workflow-engine/src/inline-workflow.ts +396 -0
  6. package/.pi/extensions/pi-workflow-engine/src/review/github-pr-comments.ts +185 -0
  7. package/.pi/extensions/pi-workflow-engine/src/review/review-actions.ts +114 -0
  8. package/.pi/extensions/pi-workflow-engine/src/review/review-format.ts +132 -0
  9. package/.pi/extensions/pi-workflow-engine/src/review/review-handoff.ts +90 -0
  10. package/.pi/extensions/pi-workflow-engine/src/review/review-issues.ts +61 -0
  11. package/.pi/extensions/pi-workflow-engine/src/review/review-results-flow.ts +56 -0
  12. package/.pi/extensions/pi-workflow-engine/src/review/review-results-viewer.ts +240 -0
  13. package/.pi/extensions/pi-workflow-engine/src/types.ts +4 -0
  14. package/.pi/extensions/pi-workflow-engine/src/ui/workflow-inspector.ts +22 -4
  15. package/.pi/extensions/pi-workflow-engine/src/ui/workflow-result-renderer.ts +11 -52
  16. package/.pi/extensions/pi-workflow-engine/src/workflow-module.ts +57 -0
  17. package/.pi/extensions/pi-workflow-engine/src/workflows.ts +2 -3
  18. package/.pi/extensions/pi-workflow-engine/workflows/code-review.ts +13 -3
  19. package/README.md +61 -12
  20. package/USAGE.md +189 -0
  21. package/package.json +4 -1
  22. package/skills/workflow-code-review-actions/SKILL.md +44 -0
  23. package/.pi/extensions/pi-workflow-engine/workflows/ping.ts +0 -29
@@ -0,0 +1,132 @@
1
+ import type { Theme } from "@earendil-works/pi-coding-agent";
2
+ import { visibleWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
3
+ import { truncateDisplay, type WorkflowThemeColor } from "../ui/workflow-format.ts";
4
+ import { formatIssueLocation, type ReviewIssue } from "./review-issues.ts";
5
+
6
+ export interface RenderIssuesTableOptions {
7
+ readonly maxRows?: number;
8
+ }
9
+
10
+ const DEFAULT_MAX_ROWS = 12;
11
+ const ID_WIDTH = 4;
12
+ const SEVERITY_WIDTH = 6;
13
+ const CONFIDENCE_WIDTH = 6;
14
+ const CATEGORY_WIDTH = 8;
15
+ const LOCATION_WIDTH = 30;
16
+ const SUMMARY_WIDTH = 58;
17
+
18
+ export function renderIssuesTable(issues: readonly ReviewIssue[], theme: Theme, options: RenderIssuesTableOptions = {}): string {
19
+ const maxRows = options.maxRows ?? DEFAULT_MAX_ROWS;
20
+ const visible = maxRows >= 0 ? issues.slice(0, maxRows) : issues;
21
+ const lines = [
22
+ renderRow(["ID", "Sev", "Conf", "Cat", "Location", "Summary"], theme.fg("dim", "│"), theme, true),
23
+ theme.fg("dim", renderSeparator()),
24
+ ];
25
+
26
+ for (const issue of visible) {
27
+ lines.push(
28
+ renderRow(
29
+ [
30
+ issue.id,
31
+ issue.finding.severity,
32
+ issue.finding.confidence,
33
+ issue.finding.category,
34
+ formatIssueLocation(issue),
35
+ issue.finding.summary,
36
+ ],
37
+ theme.fg("dim", "│"),
38
+ theme,
39
+ false,
40
+ issue,
41
+ ),
42
+ );
43
+ }
44
+
45
+ if (issues.length > visible.length) {
46
+ lines.push(theme.fg("dim", `… ${issues.length - visible.length} more finding(s)`));
47
+ }
48
+
49
+ return lines.join("\n");
50
+ }
51
+
52
+ export function renderIssueDetails(issue: ReviewIssue, theme: Theme): string {
53
+ return renderIssueDetailLines(issue, theme, 120).join("\n");
54
+ }
55
+
56
+ export function renderIssueDetailLines(issue: ReviewIssue, theme: Theme, width: number): string[] {
57
+ const finding = issue.finding;
58
+ const metadata = `${finding.category} · severity ${finding.severity} · confidence ${finding.confidence}`;
59
+ const lines = [`${theme.fg("accent", issue.id)} ${theme.fg("text", finding.summary)}`];
60
+ lines.push(...fieldLines("Metadata", metadata, width, theme));
61
+ lines.push(...fieldLines("Location", formatIssueLocation(issue), width, theme, "accent"));
62
+ lines.push(...fieldLines("Impact", finding.impact, width, theme));
63
+ lines.push(...fieldLines("Evidence", finding.evidence.join("; ") || "(none cited)", width, theme));
64
+ lines.push(...fieldLines("Recommendation", finding.recommendation, width, theme));
65
+ return lines.flatMap((line) => wrapTextWithAnsi(line, Math.max(10, width)));
66
+ }
67
+
68
+ function fieldLines(label: string, value: string, width: number, theme: Theme, valueColor: WorkflowThemeColor = "muted"): string[] {
69
+ const prefix = ` ${theme.fg("dim", `${label}:`)}`;
70
+ const separator = " ";
71
+ const valueText = theme.fg(valueColor, value);
72
+ const available = Math.max(10, width - visibleWidth(`${label}: `) - 2);
73
+ const wrapped = wrapTextWithAnsi(valueText, available);
74
+ if (wrapped.length === 0) return [`${prefix}${separator}`];
75
+ const continuation = " ".repeat(visibleWidth(`${label}: `) + 2);
76
+ return wrapped.map((line, index) => (index === 0 ? `${prefix}${separator}${line}` : `${continuation}${line}`));
77
+ }
78
+
79
+ function renderRow(
80
+ cells: readonly [string, string, string, string, string, string],
81
+ separator: string,
82
+ theme: Theme,
83
+ header: boolean,
84
+ issue?: ReviewIssue,
85
+ ): string {
86
+ const rendered = [
87
+ cell(cells[0], ID_WIDTH),
88
+ colorCell(cell(cells[1], SEVERITY_WIDTH), header ? "dim" : severityColor(issue?.finding.severity ?? "low"), theme),
89
+ colorCell(cell(cells[2], CONFIDENCE_WIDTH), header ? "dim" : confidenceColor(issue?.finding.confidence ?? "low"), theme),
90
+ cell(cells[3], CATEGORY_WIDTH),
91
+ cell(cells[4], LOCATION_WIDTH),
92
+ cell(cells[5], SUMMARY_WIDTH),
93
+ ];
94
+ if (header) {
95
+ return rendered.map((entry) => theme.fg("dim", entry)).join(` ${separator} `);
96
+ }
97
+ return rendered.join(` ${separator} `);
98
+ }
99
+
100
+ function cell(value: string, width: number): string {
101
+ return truncateDisplay(value, width).padEnd(width, " ");
102
+ }
103
+
104
+ function colorCell(value: string, color: WorkflowThemeColor, theme: Theme): string {
105
+ return theme.fg(color, value);
106
+ }
107
+
108
+ function renderSeparator(): string {
109
+ return [ID_WIDTH, SEVERITY_WIDTH, CONFIDENCE_WIDTH, CATEGORY_WIDTH, LOCATION_WIDTH, SUMMARY_WIDTH].map((width) => "─".repeat(width)).join("─┼─");
110
+ }
111
+
112
+ function severityColor(severity: ReviewIssue["finding"]["severity"]): WorkflowThemeColor {
113
+ switch (severity) {
114
+ case "high":
115
+ return "error";
116
+ case "medium":
117
+ return "warning";
118
+ case "low":
119
+ return "muted";
120
+ }
121
+ }
122
+
123
+ function confidenceColor(confidence: ReviewIssue["finding"]["confidence"]): WorkflowThemeColor {
124
+ switch (confidence) {
125
+ case "high":
126
+ return "success";
127
+ case "medium":
128
+ return "warning";
129
+ case "low":
130
+ return "muted";
131
+ }
132
+ }
@@ -0,0 +1,90 @@
1
+ import { formatIssueLocation, type ReviewContext, type ReviewIssue } from "./review-issues.ts";
2
+
3
+ export interface ReviewHandoffPayload {
4
+ readonly context: ReviewContext | undefined;
5
+ readonly issues: readonly ReviewHandoffIssue[];
6
+ }
7
+
8
+ export interface ReviewHandoffIssue {
9
+ readonly id: string;
10
+ readonly summary: string;
11
+ readonly category: string;
12
+ readonly severity: string;
13
+ readonly confidence: string;
14
+ readonly location: {
15
+ readonly file?: string;
16
+ readonly line?: number;
17
+ readonly symbol?: string;
18
+ readonly display: string;
19
+ };
20
+ readonly impact: string;
21
+ readonly evidence: readonly string[];
22
+ readonly recommendation: string;
23
+ }
24
+
25
+ export function buildFixHandoffPrompt(issues: readonly ReviewIssue[], context: ReviewContext | undefined): string {
26
+ return `Use the workflow-code-review-actions skill if available.
27
+
28
+ Mode: fix selected code-review findings.
29
+
30
+ Selected findings JSON:
31
+ \`\`\`json
32
+ ${JSON.stringify(toHandoffPayload(issues, context))}
33
+ \`\`\`
34
+
35
+ Instructions:
36
+ - Inspect the selected issue JSON before editing.
37
+ - Make minimal edits that address only the selected findings.
38
+ - Preserve unrelated user changes and avoid broad refactors.
39
+ - Run focused validation if an appropriate local check is available.
40
+ - Do not post GitHub PR comments or any upstream review comments.
41
+ - Summarize changed files and validation results when done.`;
42
+ }
43
+
44
+ export function buildCommentHandoffPrompt(issues: readonly ReviewIssue[], context: ReviewContext | undefined, reason: string): string {
45
+ return `Use the workflow-code-review-actions skill if available.
46
+
47
+ Mode: post inline GitHub PR comments for selected code-review findings.
48
+
49
+ Fallback reason: ${reason}
50
+
51
+ Selected findings JSON:
52
+ \`\`\`json
53
+ ${JSON.stringify(toHandoffPayload(issues, context))}
54
+ \`\`\`
55
+
56
+ Instructions:
57
+ - Do not edit files or make code changes.
58
+ - Prefer installed GitHub MCP/tools if present; otherwise use the GitHub CLI (gh).
59
+ - With gh, resolve the PR using \`gh pr view\` and \`gh repo view\`, then call \`gh api repos/{owner}/{repo}/pulls/{number}/comments\` with \`commit_id\`, \`path\`, \`line\`, and \`side=RIGHT\`.
60
+ - Do not post duplicate comments.
61
+ - Do not post line-less findings as inline comments.
62
+ - Ask the user if the upstream PR cannot be identified.
63
+ - Summarize posted, skipped, and failed comments when done.`;
64
+ }
65
+
66
+ function toHandoffPayload(issues: readonly ReviewIssue[], context: ReviewContext | undefined): ReviewHandoffPayload {
67
+ return {
68
+ context,
69
+ issues: issues.map(toHandoffIssue),
70
+ };
71
+ }
72
+
73
+ function toHandoffIssue(issue: ReviewIssue): ReviewHandoffIssue {
74
+ return {
75
+ id: issue.id,
76
+ summary: issue.finding.summary,
77
+ category: issue.finding.category,
78
+ severity: issue.finding.severity,
79
+ confidence: issue.finding.confidence,
80
+ location: {
81
+ file: issue.file,
82
+ line: issue.line,
83
+ symbol: issue.symbol,
84
+ display: formatIssueLocation(issue),
85
+ },
86
+ impact: issue.finding.impact,
87
+ evidence: issue.finding.evidence,
88
+ recommendation: issue.finding.recommendation,
89
+ };
90
+ }
@@ -0,0 +1,61 @@
1
+ import type { AdvisoryFinding, AdvisoryReport } from "../advisory-schema.ts";
2
+
3
+ export type ReviewIssueAction = "fix" | "comment" | "close";
4
+
5
+ export interface ReviewIssueSelection {
6
+ readonly action: ReviewIssueAction;
7
+ readonly issueIds: readonly string[];
8
+ }
9
+
10
+ export interface ReviewContext {
11
+ readonly workflowName: string;
12
+ readonly target: string;
13
+ readonly diffCommand: string;
14
+ readonly files: readonly string[];
15
+ readonly summary?: string;
16
+ }
17
+
18
+ export interface ReviewIssue {
19
+ readonly id: string;
20
+ readonly index: number;
21
+ readonly workflowName: string;
22
+ readonly file?: string;
23
+ readonly line?: number;
24
+ readonly symbol?: string;
25
+ readonly finding: AdvisoryFinding;
26
+ }
27
+
28
+ export interface ReviewReportWithContext extends AdvisoryReport {
29
+ readonly stats?: Record<string, string | number>;
30
+ readonly reviewContext?: ReviewContext;
31
+ }
32
+
33
+ export function toReviewIssues(name: string, report: Pick<AdvisoryReport, "findings">): ReviewIssue[] {
34
+ return report.findings.map((finding, index) => {
35
+ const location = finding.locations[0];
36
+ return {
37
+ id: formatIssueId(index),
38
+ index,
39
+ workflowName: name,
40
+ file: location?.file,
41
+ line: location?.line,
42
+ symbol: location?.symbol,
43
+ finding,
44
+ };
45
+ });
46
+ }
47
+
48
+ export function formatIssueLocation(issue: ReviewIssue): string {
49
+ if (!issue.file) return "(no location)";
50
+ const line = issue.line != null ? `:${issue.line}` : "";
51
+ const symbol = issue.symbol ? ` (${issue.symbol})` : "";
52
+ return `${issue.file}${line}${symbol}`;
53
+ }
54
+
55
+ export function isCommentableIssue(issue: ReviewIssue): boolean {
56
+ return typeof issue.file === "string" && issue.file.trim().length > 0 && typeof issue.line === "number" && Number.isFinite(issue.line);
57
+ }
58
+
59
+ function formatIssueId(index: number): string {
60
+ return `R${String(index + 1).padStart(3, "0")}`;
61
+ }
@@ -0,0 +1,56 @@
1
+ import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
2
+ import type { WorkflowRunOptions } from "../types.ts";
3
+ import { isAdvisoryReport, type AdvisoryWorkflowResult } from "../ui/workflow-result-renderer.ts";
4
+ import { toReviewIssues, type ReviewIssue, type ReviewIssueSelection } from "./review-issues.ts";
5
+ import { ReviewResultsViewer } from "./review-results-viewer.ts";
6
+
7
+ export type ReviewResultsPresentationDecision =
8
+ | {
9
+ readonly kind: "send";
10
+ readonly reason: "tool-invocation" | "not-code-review" | "not-advisory" | "no-findings" | "disabled" | "not-tui" | "not-requested";
11
+ }
12
+ | { readonly kind: "open"; readonly report: AdvisoryWorkflowResult; readonly issues: readonly ReviewIssue[]; readonly findingCount: number };
13
+
14
+ export interface ReviewResultsDecisionInput {
15
+ readonly workflowName: string;
16
+ readonly result: unknown;
17
+ readonly mode?: string;
18
+ readonly hasUI: boolean;
19
+ readonly resultViewer?: WorkflowRunOptions["resultViewer"];
20
+ readonly invocationKind?: "command" | "tool";
21
+ }
22
+
23
+ export interface ReviewResultsViewerContext {
24
+ readonly ui: Pick<ExtensionCommandContext["ui"], "custom">;
25
+ }
26
+
27
+ export function extensionContextMode(ctx: ExtensionCommandContext): string | undefined {
28
+ const candidate = (ctx as ExtensionCommandContext & { readonly mode?: unknown }).mode;
29
+ return typeof candidate === "string" ? candidate : undefined;
30
+ }
31
+
32
+ export function decideReviewResultsPresentation(input: ReviewResultsDecisionInput): ReviewResultsPresentationDecision {
33
+ if (input.invocationKind === "tool") return { kind: "send", reason: "tool-invocation" };
34
+ if (input.workflowName !== "code-review") return { kind: "send", reason: "not-code-review" };
35
+ if (!isAdvisoryReport(input.result)) return { kind: "send", reason: "not-advisory" };
36
+ if (input.result.findings.length === 0) return { kind: "send", reason: "no-findings" };
37
+ if (input.resultViewer === "skip") return { kind: "send", reason: "disabled" };
38
+ if (input.mode !== "tui" || !input.hasUI) return { kind: "send", reason: "not-tui" };
39
+
40
+ if (input.resultViewer !== "open") return { kind: "send", reason: "not-requested" };
41
+
42
+ const issues = toReviewIssues(input.workflowName, input.result);
43
+ return { kind: "open", report: input.result, issues, findingCount: issues.length };
44
+ }
45
+
46
+ export async function maybeShowReviewResultsViewer(
47
+ ctx: ReviewResultsViewerContext,
48
+ decision: ReviewResultsPresentationDecision,
49
+ ): Promise<ReviewIssueSelection | undefined> {
50
+ if (decision.kind !== "open") return undefined;
51
+
52
+ return await ctx.ui.custom<ReviewIssueSelection>(
53
+ (tui, theme, _keybindings, done) => new ReviewResultsViewer(decision.issues, "code-review", theme, () => tui.requestRender(), done),
54
+ { overlay: true, overlayOptions: { anchor: "right-center", width: "80%", maxHeight: "90%", margin: 1 } },
55
+ );
56
+ }
@@ -0,0 +1,240 @@
1
+ import type { Theme } from "@earendil-works/pi-coding-agent";
2
+ import { matchesKey, visibleWidth, type Component } from "@earendil-works/pi-tui";
3
+ import { renderIssueDetailLines } from "./review-format.ts";
4
+ import { formatIssueLocation, type ReviewIssue, type ReviewIssueSelection } from "./review-issues.ts";
5
+ import { truncateDisplay } from "../ui/workflow-format.ts";
6
+
7
+ const MIN_WIDTH = 40;
8
+ const SPLIT_WIDTH = 96;
9
+ const LIST_MIN_WIDTH = 34;
10
+ const LIST_MAX_WIDTH = 48;
11
+ const DETAIL_SCROLL_STEP = 5;
12
+
13
+ export class ReviewResultsViewer implements Component {
14
+ private readonly selected = new Set<string>();
15
+ private cursor = 0;
16
+ private detailScroll = 0;
17
+ private detailsExpanded = true;
18
+ private warning: string | undefined;
19
+
20
+ constructor(
21
+ private readonly issues: readonly ReviewIssue[],
22
+ private readonly workflowName: string,
23
+ private readonly theme: Theme,
24
+ private readonly requestRender: () => void,
25
+ private readonly done: (result: ReviewIssueSelection) => void,
26
+ ) {}
27
+
28
+ invalidate(): void {}
29
+
30
+ render(width: number): string[] {
31
+ const outerWidth = Math.max(MIN_WIDTH, width);
32
+ const innerWidth = Math.max(1, outerWidth - 2);
33
+ const body = this.renderBody(innerWidth);
34
+ return [this.topBorder(innerWidth), ...body.map((line) => `│${padAnsi(line, innerWidth)}│`), this.bottomBorder(innerWidth)];
35
+ }
36
+
37
+ handleInput(data: string): void {
38
+ const digitJump = digitJumpIndex(data);
39
+ if (digitJump !== undefined) {
40
+ this.jumpTo(digitJump);
41
+ return;
42
+ }
43
+
44
+ if (matchesKey(data, "up")) {
45
+ this.moveCursor(-1);
46
+ return;
47
+ }
48
+ if (matchesKey(data, "down")) {
49
+ this.moveCursor(1);
50
+ return;
51
+ }
52
+ if (matchesKey(data, "space") || data === " ") {
53
+ this.toggleCurrent();
54
+ return;
55
+ }
56
+ if (data === "a") {
57
+ this.toggleAllVisible();
58
+ return;
59
+ }
60
+ if (matchesKey(data, "enter") || matchesKey(data, "return") || data === "\r") {
61
+ this.detailsExpanded = !this.detailsExpanded;
62
+ this.detailScroll = 0;
63
+ this.warning = undefined;
64
+ this.requestRender();
65
+ return;
66
+ }
67
+ if (matchesKey(data, "pageUp") || matchesKey(data, "left")) {
68
+ this.scrollDetail(-DETAIL_SCROLL_STEP);
69
+ return;
70
+ }
71
+ if (matchesKey(data, "pageDown") || matchesKey(data, "right")) {
72
+ this.scrollDetail(DETAIL_SCROLL_STEP);
73
+ return;
74
+ }
75
+ if (data === "f") {
76
+ this.finishSelected("fix");
77
+ return;
78
+ }
79
+ if (data === "c") {
80
+ this.finishSelected("comment");
81
+ return;
82
+ }
83
+ if (data === "q" || matchesKey(data, "escape") || matchesKey(data, "esc")) {
84
+ this.done({ action: "close", issueIds: this.selectedIssueIds() });
85
+ }
86
+ }
87
+
88
+ private renderBody(width: number): string[] {
89
+ const header = this.headerLine(width);
90
+ const help = this.helpLine(width);
91
+ const warning = this.warning ? [this.theme.fg("warning", this.warning)] : [];
92
+ if (this.issues.length === 0) return [header, this.theme.fg("success", "No findings."), ...warning, help];
93
+
94
+ const split = width >= SPLIT_WIDTH;
95
+ const content = split ? this.renderSplit(width) : this.renderStacked(width);
96
+ return [header, ...warning, ...content, help];
97
+ }
98
+
99
+ private renderSplit(width: number): string[] {
100
+ const listWidth = Math.min(LIST_MAX_WIDTH, Math.max(LIST_MIN_WIDTH, Math.floor(width * 0.42)));
101
+ const detailWidth = Math.max(20, width - listWidth - 3);
102
+ const list = this.renderIssueList(listWidth);
103
+ const details = this.renderDetail(detailWidth);
104
+ const rows = Math.max(list.length, details.length);
105
+ const lines: string[] = [];
106
+ for (let index = 0; index < rows; index++) {
107
+ lines.push(`${padAnsi(list[index] ?? "", listWidth)} ${this.theme.fg("dim", "│")} ${padAnsi(details[index] ?? "", detailWidth)}`);
108
+ }
109
+ return lines;
110
+ }
111
+
112
+ private renderStacked(width: number): string[] {
113
+ return [...this.renderIssueList(width), this.theme.fg("dim", "─".repeat(Math.max(1, width))), ...this.renderDetail(width)];
114
+ }
115
+
116
+ private renderIssueList(width: number): string[] {
117
+ return this.issues.map((issue, index) => {
118
+ const cursor = index === this.cursor ? this.theme.fg("accent", ">") : " ";
119
+ const checked = this.selected.has(issue.id) ? this.theme.fg("success", "x") : " ";
120
+ const severity = this.theme.fg(severityColor(issue.finding.severity), issue.finding.severity);
121
+ const row = `${cursor} [${checked}] ${issue.id} ${severity} ${issue.finding.category} ${formatIssueLocation(issue)} — ${issue.finding.summary}`;
122
+ return truncateDisplay(row, width);
123
+ });
124
+ }
125
+
126
+ private renderDetail(width: number): string[] {
127
+ const issue = this.issues[this.cursor];
128
+ if (!issue) return [this.theme.fg("dim", "No finding selected.")];
129
+ if (!this.detailsExpanded) return [this.theme.fg("accent", issue.id), issue.finding.summary, this.theme.fg("dim", "Press enter to expand details.")];
130
+ const lines = renderIssueDetailLines(issue, this.theme, width);
131
+ const maxScroll = Math.max(0, lines.length - 1);
132
+ this.detailScroll = Math.min(this.detailScroll, maxScroll);
133
+ const visible = lines.slice(this.detailScroll, this.detailScroll + 14);
134
+ if (this.detailScroll > 0) visible.unshift(this.theme.fg("dim", "↑ more"));
135
+ if (this.detailScroll + 14 < lines.length) visible.push(this.theme.fg("dim", "↓ more"));
136
+ return visible.map((line) => truncateDisplay(line, width));
137
+ }
138
+
139
+ private headerLine(width: number): string {
140
+ const count = `${this.issues.length} finding${this.issues.length === 1 ? "" : "s"}`;
141
+ const selected = `${this.selected.size} selected`;
142
+ return truncateDisplay(`${this.theme.fg("accent", this.theme.bold("Review results"))} ${this.theme.fg("muted", this.workflowName)} · ${count} · ${selected}`, width);
143
+ }
144
+
145
+ private helpLine(width: number): string {
146
+ return truncateDisplay(this.theme.fg("dim", "↑/↓ move · 1-9 jump · space tag · a all · enter expand/collapse · ←/→ scroll · f fix · c comment · q close"), width);
147
+ }
148
+
149
+ private topBorder(innerWidth: number): string {
150
+ return `┌${this.theme.fg("dim", "─".repeat(innerWidth))}┐`;
151
+ }
152
+
153
+ private bottomBorder(innerWidth: number): string {
154
+ return `└${this.theme.fg("dim", "─".repeat(innerWidth))}┘`;
155
+ }
156
+
157
+ private moveCursor(delta: number): void {
158
+ if (this.issues.length === 0) return;
159
+ this.cursor = Math.min(this.issues.length - 1, Math.max(0, this.cursor + delta));
160
+ this.detailScroll = 0;
161
+ this.warning = undefined;
162
+ this.requestRender();
163
+ }
164
+
165
+ private jumpTo(index: number): void {
166
+ if (index < 0 || index >= this.issues.length) {
167
+ this.warning = `No finding ${index + 1}.`;
168
+ this.requestRender();
169
+ return;
170
+ }
171
+ this.cursor = index;
172
+ this.detailScroll = 0;
173
+ this.warning = undefined;
174
+ this.requestRender();
175
+ }
176
+
177
+ private toggleCurrent(): void {
178
+ const issue = this.issues[this.cursor];
179
+ if (!issue) return;
180
+ this.toggleIssue(issue.id);
181
+ this.warning = undefined;
182
+ this.requestRender();
183
+ }
184
+
185
+ private toggleAllVisible(): void {
186
+ const allSelected = this.issues.length > 0 && this.issues.every((issue) => this.selected.has(issue.id));
187
+ this.selected.clear();
188
+ if (!allSelected) {
189
+ for (const issue of this.issues) this.selected.add(issue.id);
190
+ }
191
+ this.warning = undefined;
192
+ this.requestRender();
193
+ }
194
+
195
+ private scrollDetail(delta: number): void {
196
+ this.detailScroll = Math.max(0, this.detailScroll + delta);
197
+ this.warning = undefined;
198
+ this.requestRender();
199
+ }
200
+
201
+ private finishSelected(action: "fix" | "comment"): void {
202
+ const issueIds = this.selectedIssueIds();
203
+ if (issueIds.length === 0) {
204
+ this.warning = `Select at least one finding before pressing ${action === "fix" ? "f" : "c"}.`;
205
+ this.requestRender();
206
+ return;
207
+ }
208
+ this.done({ action, issueIds });
209
+ }
210
+
211
+ private toggleIssue(id: string): void {
212
+ if (this.selected.has(id)) this.selected.delete(id);
213
+ else this.selected.add(id);
214
+ }
215
+
216
+ private selectedIssueIds(): string[] {
217
+ return this.issues.filter((issue) => this.selected.has(issue.id)).map((issue) => issue.id);
218
+ }
219
+ }
220
+
221
+ function padAnsi(text: string, width: number): string {
222
+ const truncated = truncateDisplay(text, width);
223
+ const padding = Math.max(0, width - visibleWidth(truncated));
224
+ return `${truncated}${" ".repeat(padding)}`;
225
+ }
226
+
227
+ function digitJumpIndex(data: string): number | undefined {
228
+ return data.length === 1 && data >= "1" && data <= "9" ? Number(data) - 1 : undefined;
229
+ }
230
+
231
+ function severityColor(severity: ReviewIssue["finding"]["severity"]): Parameters<Theme["fg"]>[0] {
232
+ switch (severity) {
233
+ case "high":
234
+ return "error";
235
+ case "medium":
236
+ return "warning";
237
+ case "low":
238
+ return "muted";
239
+ }
240
+ }
@@ -2,6 +2,7 @@ import type { Static, TSchema } from "typebox";
2
2
  import type { ThinkingLevel } from "@earendil-works/pi-agent-core";
3
3
  import type { Pipeline } from "./concurrency.ts";
4
4
  import type { PerfSink, PerfSnapshot } from "./perf.ts";
5
+ import type { WorkflowProgressSnapshot } from "./progress.ts";
5
6
 
6
7
  /** Metadata every workflow module must export. */
7
8
  export interface WorkflowMeta {
@@ -23,12 +24,15 @@ export interface WorkflowRunOptions {
23
24
  perf?: boolean;
24
25
  concurrency?: number;
25
26
  parallelSubmissionLimit?: number;
27
+ resultViewer?: "open" | "skip";
26
28
  /** Additional abort signal to compose with the host context signal. */
27
29
  signal?: AbortSignal;
28
30
  /** Internal recorder override for command/tool invocation timing. */
29
31
  perfRecorder?: PerfSink;
30
32
  /** Called with the final performance snapshot when perf is enabled. */
31
33
  onPerfSnapshot?: (snapshot: PerfSnapshot) => void;
34
+ /** Called with the final completed progress snapshot after live workflow UI teardown. */
35
+ onProgressSnapshot?: (snapshot: WorkflowProgressSnapshot) => void;
32
36
  }
33
37
 
34
38
  /** Options for a single `agent()` call. */
@@ -1,5 +1,5 @@
1
1
  import type { Theme } from "@earendil-works/pi-coding-agent";
2
- import { matchesKey, type TUI, visibleWidth } from "@earendil-works/pi-tui";
2
+ import { matchesKey, type TUI, visibleWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
3
3
  import type { AgentRowSnapshot, WorkflowLaneItemSnapshot, WorkflowProgressSnapshot } from "../progress.ts";
4
4
  import { agentDetailParts, formatCount, formatDuration, statusIcon, truncateDisplay } from "./workflow-format.ts";
5
5
 
@@ -89,7 +89,7 @@ export class WorkflowInspector {
89
89
  const count = Math.max(1, this.itemCount(this.currentSection()));
90
90
  lines.push(
91
91
  this.row(
92
- `${th.fg("dim", `${body.length} lines · ${pct} · ${selected}/${count}`)} ${th.fg("dim", "· tab sections · ↑↓ select · enter expand · q/esc close")}`,
92
+ `${th.fg("dim", `${body.length} lines · ${pct} · ${selected}/${count}`)} ${th.fg("dim", "· tab sections · ↑↓ select · enter expand/collapse · q/esc close")}`,
93
93
  inner,
94
94
  ),
95
95
  );
@@ -208,7 +208,7 @@ export class WorkflowInspector {
208
208
  return lines.length > 0 ? lines : [{ text: ` ${this.theme.fg("dim", "No details yet.")}` }];
209
209
  }
210
210
 
211
- private findingLines(snapshot: WorkflowProgressSnapshot, _width: number): InspectorLine[] {
211
+ private findingLines(snapshot: WorkflowProgressSnapshot, width: number): InspectorLine[] {
212
212
  const lines: InspectorLine[] = [];
213
213
  let selectableIndex = -1;
214
214
  const overflowByLane = new Map(snapshot.laneOverflow);
@@ -221,7 +221,7 @@ export class WorkflowInspector {
221
221
  const key = `finding:${lane}:${index}:${item.createdAt}`;
222
222
  const selected = this.currentSection() === "Findings" && this.selected.Findings === selectableIndex;
223
223
  lines.push({ text: this.findingLine(item, selected), selectable: true, key });
224
- if (this.expanded.has(key) && item.details) lines.push({ text: ` ${this.theme.fg("dim", item.details)}` });
224
+ if (this.expanded.has(key)) lines.push(...this.findingDetails(item, width));
225
225
  });
226
226
  }
227
227
  return lines.length > 0 ? lines : [{ text: this.theme.fg("dim", "No findings lanes yet.") }];
@@ -233,6 +233,24 @@ export class WorkflowInspector {
233
233
  return selected ? this.theme.bg("selectedBg", text) : text;
234
234
  }
235
235
 
236
+ private findingDetails(item: WorkflowLaneItemSnapshot, width: number): InspectorLine[] {
237
+ return [
238
+ ...this.detailFieldLines("Title", item.title, width, "text"),
239
+ ...this.detailFieldLines("Location", item.subtitle || "(unknown)", width, "accent"),
240
+ ...this.detailFieldLines("Status", item.status, width, "muted"),
241
+ ...this.detailFieldLines("Details", item.details || "(no details)", width, "muted"),
242
+ ];
243
+ }
244
+
245
+ private detailFieldLines(label: string, value: string, width: number, color: Parameters<Theme["fg"]>[0]): InspectorLine[] {
246
+ const plainPrefix = ` ${label}: `;
247
+ const prefix = ` ${this.theme.fg("dim", `${label}:`)} `;
248
+ const continuation = ` ${" ".repeat(visibleWidth(`${label}: `))}`;
249
+ const wrapped = wrapTextWithAnsi(this.theme.fg(color, value), Math.max(10, width - visibleWidth(plainPrefix) - 4));
250
+ if (wrapped.length === 0) return [{ text: prefix }];
251
+ return wrapped.map((line, index) => ({ text: `${index === 0 ? prefix : continuation}${line}` }));
252
+ }
253
+
236
254
  private logLines(snapshot: WorkflowProgressSnapshot): InspectorLine[] {
237
255
  if (snapshot.logs.length === 0) return [{ text: this.theme.fg("dim", "No logs yet.") }];
238
256
  return snapshot.logs.map((log, index) => {