pi-workflow-engine 0.10.3 → 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.pi/extensions/pi-workflow-engine/index.ts +168 -121
- package/.pi/extensions/pi-workflow-engine/src/advisory-schema.ts +11 -0
- package/.pi/extensions/pi-workflow-engine/src/agent-attempt.ts +145 -0
- package/.pi/extensions/pi-workflow-engine/src/agent-replay.ts +376 -0
- package/.pi/extensions/pi-workflow-engine/src/agent-runner-types.ts +87 -0
- package/.pi/extensions/pi-workflow-engine/src/agent-runner.ts +84 -529
- package/.pi/extensions/pi-workflow-engine/src/agent-session-identity.ts +256 -0
- package/.pi/extensions/pi-workflow-engine/src/agent-session.ts +413 -0
- package/.pi/extensions/pi-workflow-engine/src/agent-skills.ts +43 -1
- package/.pi/extensions/pi-workflow-engine/src/agent-workspace.ts +81 -0
- package/.pi/extensions/pi-workflow-engine/src/budget.ts +7 -0
- package/.pi/extensions/pi-workflow-engine/src/cancellation.ts +17 -3
- package/.pi/extensions/pi-workflow-engine/src/concurrency.ts +164 -61
- package/.pi/extensions/pi-workflow-engine/src/diff-capture.ts +117 -20
- package/.pi/extensions/pi-workflow-engine/src/discovery.ts +64 -15
- package/.pi/extensions/pi-workflow-engine/src/dynamax-shortcuts.ts +59 -13
- package/.pi/extensions/pi-workflow-engine/src/dynamax.ts +14 -3
- package/.pi/extensions/pi-workflow-engine/src/engine.ts +208 -74
- package/.pi/extensions/pi-workflow-engine/src/finalizers.ts +66 -0
- package/.pi/extensions/pi-workflow-engine/src/identity-canonicalization.ts +138 -0
- package/.pi/extensions/pi-workflow-engine/src/identity-fingerprint.ts +15 -0
- package/.pi/extensions/pi-workflow-engine/src/inline-workflow.ts +8 -3
- package/.pi/extensions/pi-workflow-engine/src/journal.ts +148 -64
- package/.pi/extensions/pi-workflow-engine/src/options.ts +9 -6
- package/.pi/extensions/pi-workflow-engine/src/process-runner.ts +203 -39
- package/.pi/extensions/pi-workflow-engine/src/progress.ts +2 -5
- package/.pi/extensions/pi-workflow-engine/src/replay-path-identity.ts +48 -0
- package/.pi/extensions/pi-workflow-engine/src/resume-context.ts +665 -0
- package/.pi/extensions/pi-workflow-engine/src/review/github-pr-comments.ts +75 -16
- package/.pi/extensions/pi-workflow-engine/src/review/review-actions.ts +83 -17
- package/.pi/extensions/pi-workflow-engine/src/review/review-budget.ts +45 -0
- package/.pi/extensions/pi-workflow-engine/src/review/review-fix-workflow.ts +117 -0
- package/.pi/extensions/pi-workflow-engine/src/review/review-handoff.ts +14 -66
- package/.pi/extensions/pi-workflow-engine/src/review/review-issues.ts +38 -12
- package/.pi/extensions/pi-workflow-engine/src/review/review-report.ts +42 -0
- package/.pi/extensions/pi-workflow-engine/src/review/review-results-flow.ts +17 -22
- package/.pi/extensions/pi-workflow-engine/src/review/review-results-viewer.ts +143 -52
- package/.pi/extensions/pi-workflow-engine/src/review/review-session-coordinator.ts +153 -0
- package/.pi/extensions/pi-workflow-engine/src/review/review-snapshot.ts +295 -0
- package/.pi/extensions/pi-workflow-engine/src/tool-source-identity.ts +153 -0
- package/.pi/extensions/pi-workflow-engine/src/tree-fingerprint.ts +404 -0
- package/.pi/extensions/pi-workflow-engine/src/types.ts +38 -10
- package/.pi/extensions/pi-workflow-engine/src/ui/workflow-result-renderer.ts +5 -69
- package/.pi/extensions/pi-workflow-engine/src/unknown-error.ts +8 -0
- package/.pi/extensions/pi-workflow-engine/src/workflow-execution.ts +121 -0
- package/.pi/extensions/pi-workflow-engine/src/workflow-module.ts +19 -1
- package/.pi/extensions/pi-workflow-engine/src/workflows.ts +28 -3
- package/.pi/extensions/pi-workflow-engine/src/worktree.ts +282 -41
- package/.pi/extensions/pi-workflow-engine/workflows/code-review.ts +48 -26
- package/.pi/extensions/pi-workflow-engine/workflows/diagnose.ts +8 -1
- package/.pi/extensions/pi-workflow-engine/workflows/perf-review.ts +8 -2
- package/.pi/extensions/pi-workflow-engine/workflows/refactor-scout.ts +8 -1
- package/README.md +170 -226
- package/USAGE.md +92 -12
- package/package.json +9 -9
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { Type, type Static } from "typebox";
|
|
2
|
+
import { Value } from "typebox/value";
|
|
3
|
+
import { AdvisoryReportWithStatsSchema } from "../advisory-schema.ts";
|
|
4
|
+
import { formatReviewDiffTarget, isReviewDiffTarget, ReviewDiffTargetSchema } from "../diff-capture.ts";
|
|
5
|
+
|
|
6
|
+
/** Atomic identity of the exact diff and post-change snapshot that were reviewed. */
|
|
7
|
+
export const ReviewSnapshotIdentitySchema = Type.Object({
|
|
8
|
+
diffFingerprint: Type.String({ pattern: "^[0-9a-fA-F]{64}$" }),
|
|
9
|
+
baselineFingerprint: Type.String({ pattern: "^[0-9a-fA-F]{64}$" }),
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
export const ReviewContextSchema = Type.Object({
|
|
13
|
+
workflowName: Type.String(),
|
|
14
|
+
target: Type.String(),
|
|
15
|
+
diffTarget: ReviewDiffTargetSchema,
|
|
16
|
+
files: Type.Array(Type.String()),
|
|
17
|
+
summary: Type.Optional(Type.String()),
|
|
18
|
+
snapshot: Type.Optional(ReviewSnapshotIdentitySchema),
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
export const ReviewReportSchema = Type.Object({
|
|
22
|
+
...AdvisoryReportWithStatsSchema.properties,
|
|
23
|
+
reviewContext: Type.Optional(ReviewContextSchema),
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
export type ReviewSnapshotIdentity = Static<typeof ReviewSnapshotIdentitySchema>;
|
|
27
|
+
export type ReviewContext = Static<typeof ReviewContextSchema>;
|
|
28
|
+
export type ReviewReport = Static<typeof ReviewReportSchema>;
|
|
29
|
+
|
|
30
|
+
export function isReviewReport(value: unknown): value is ReviewReport {
|
|
31
|
+
if (!Value.Check(ReviewReportSchema, value)) return false;
|
|
32
|
+
return value.reviewContext === undefined || isReviewContext(value.reviewContext);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function isReviewContext(value: unknown): value is ReviewContext {
|
|
36
|
+
return Value.Check(ReviewContextSchema, value) && isReviewDiffTarget(value.diffTarget);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Prompt-facing context retains the canonical display command without persisting duplicate identity. */
|
|
40
|
+
export function serializeReviewContext(context: ReviewContext | undefined): (ReviewContext & { readonly diffCommand: string }) | undefined {
|
|
41
|
+
return context ? { ...context, diffCommand: formatReviewDiffTarget(context.diffTarget) } : undefined;
|
|
42
|
+
}
|
|
@@ -1,15 +1,15 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import type { WorkflowRunOptions } from "../types.ts";
|
|
3
|
-
import { isAdvisoryReport, type AdvisoryWorkflowResult } from "../ui/workflow-result-renderer.ts";
|
|
4
3
|
import { toReviewIssues, type ReviewIssue, type ReviewIssueSelection } from "./review-issues.ts";
|
|
4
|
+
import { isReviewReport, type ReviewReport } from "./review-report.ts";
|
|
5
5
|
import { ReviewResultsViewer } from "./review-results-viewer.ts";
|
|
6
6
|
|
|
7
7
|
export type ReviewResultsPresentationDecision =
|
|
8
8
|
| {
|
|
9
9
|
readonly kind: "send";
|
|
10
|
-
readonly reason: "
|
|
10
|
+
readonly reason: "not-code-review" | "not-advisory" | "no-findings" | "disabled" | "not-tui" | "not-requested";
|
|
11
11
|
}
|
|
12
|
-
| { readonly kind: "open"; readonly
|
|
12
|
+
| { readonly kind: "open"; readonly issues: readonly ReviewIssue[] };
|
|
13
13
|
|
|
14
14
|
export interface ReviewResultsDecisionInput {
|
|
15
15
|
readonly workflowName: string;
|
|
@@ -17,40 +17,35 @@ export interface ReviewResultsDecisionInput {
|
|
|
17
17
|
readonly mode?: string;
|
|
18
18
|
readonly hasUI: boolean;
|
|
19
19
|
readonly resultViewer?: WorkflowRunOptions["resultViewer"];
|
|
20
|
-
readonly invocationKind?: "command" | "tool";
|
|
21
20
|
}
|
|
22
21
|
|
|
23
|
-
export
|
|
24
|
-
|
|
22
|
+
export function codeReviewReport(workflowName: string, result: unknown): ReviewReport | undefined {
|
|
23
|
+
return workflowName === "code-review" && isReviewReport(result) ? result : undefined;
|
|
25
24
|
}
|
|
26
25
|
|
|
27
|
-
export
|
|
28
|
-
|
|
29
|
-
return typeof candidate === "string" ? candidate : undefined;
|
|
26
|
+
export interface ReviewResultsViewerContext {
|
|
27
|
+
readonly ui: Pick<ExtensionContext["ui"], "custom">;
|
|
30
28
|
}
|
|
31
29
|
|
|
32
30
|
export function decideReviewResultsPresentation(input: ReviewResultsDecisionInput): ReviewResultsPresentationDecision {
|
|
33
|
-
if (input.invocationKind === "tool") return { kind: "send", reason: "tool-invocation" };
|
|
34
31
|
if (input.workflowName !== "code-review") return { kind: "send", reason: "not-code-review" };
|
|
35
|
-
|
|
36
|
-
if (
|
|
32
|
+
const report = codeReviewReport(input.workflowName, input.result);
|
|
33
|
+
if (!report) return { kind: "send", reason: "not-advisory" };
|
|
34
|
+
if (report.findings.length === 0) return { kind: "send", reason: "no-findings" };
|
|
37
35
|
if (input.resultViewer === "skip") return { kind: "send", reason: "disabled" };
|
|
38
36
|
if (input.mode !== "tui" || !input.hasUI) return { kind: "send", reason: "not-tui" };
|
|
39
37
|
|
|
40
38
|
if (input.resultViewer !== "open") return { kind: "send", reason: "not-requested" };
|
|
41
39
|
|
|
42
|
-
|
|
43
|
-
return { kind: "open", report: input.result, issues, findingCount: issues.length };
|
|
40
|
+
return { kind: "open", issues: toReviewIssues(input.workflowName, report) };
|
|
44
41
|
}
|
|
45
42
|
|
|
46
|
-
export async function
|
|
43
|
+
export async function showReviewResultsViewer(
|
|
47
44
|
ctx: ReviewResultsViewerContext,
|
|
48
|
-
|
|
49
|
-
): Promise<ReviewIssueSelection
|
|
50
|
-
if (decision.kind !== "open") return undefined;
|
|
51
|
-
|
|
45
|
+
issues: readonly ReviewIssue[],
|
|
46
|
+
): Promise<ReviewIssueSelection> {
|
|
52
47
|
return await ctx.ui.custom<ReviewIssueSelection>(
|
|
53
|
-
(tui, theme, _keybindings, done) => new ReviewResultsViewer(
|
|
54
|
-
{ overlay: true, overlayOptions: { anchor: "
|
|
48
|
+
(tui, theme, _keybindings, done) => new ReviewResultsViewer(issues, "code-review", tui, theme, done),
|
|
49
|
+
{ overlay: true, overlayOptions: { anchor: "center", width: "80%", minWidth: 40, maxHeight: "80%", margin: 1 } },
|
|
55
50
|
);
|
|
56
51
|
}
|
|
@@ -1,18 +1,22 @@
|
|
|
1
1
|
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
2
|
-
import { matchesKey, visibleWidth, type Component } from "@earendil-works/pi-tui";
|
|
2
|
+
import { matchesKey, visibleWidth, type Component, type TUI } from "@earendil-works/pi-tui";
|
|
3
3
|
import { renderIssueDetailLines } from "./review-format.ts";
|
|
4
4
|
import { formatIssueLocation, type ReviewIssue, type ReviewIssueSelection } from "./review-issues.ts";
|
|
5
5
|
import { truncateDisplay } from "../ui/workflow-format.ts";
|
|
6
6
|
|
|
7
|
-
const
|
|
7
|
+
const VIEWPORT_HEIGHT_RATIO = 0.8;
|
|
8
|
+
const VIEWPORT_MARGIN_ROWS = 2;
|
|
8
9
|
const SPLIT_WIDTH = 96;
|
|
9
10
|
const LIST_MIN_WIDTH = 34;
|
|
10
11
|
const LIST_MAX_WIDTH = 48;
|
|
11
12
|
const DETAIL_SCROLL_STEP = 5;
|
|
12
13
|
|
|
14
|
+
type ViewerTui = Pick<TUI, "requestRender" | "terminal">;
|
|
15
|
+
|
|
13
16
|
export class ReviewResultsViewer implements Component {
|
|
14
17
|
private readonly selected = new Set<string>();
|
|
15
18
|
private cursor = 0;
|
|
19
|
+
private listScroll = 0;
|
|
16
20
|
private detailScroll = 0;
|
|
17
21
|
private detailsExpanded = true;
|
|
18
22
|
private warning: string | undefined;
|
|
@@ -20,18 +24,23 @@ export class ReviewResultsViewer implements Component {
|
|
|
20
24
|
constructor(
|
|
21
25
|
private readonly issues: readonly ReviewIssue[],
|
|
22
26
|
private readonly workflowName: string,
|
|
27
|
+
private readonly tui: ViewerTui,
|
|
23
28
|
private readonly theme: Theme,
|
|
24
|
-
private readonly requestRender: () => void,
|
|
25
29
|
private readonly done: (result: ReviewIssueSelection) => void,
|
|
26
30
|
) {}
|
|
27
31
|
|
|
28
32
|
invalidate(): void {}
|
|
29
33
|
|
|
30
34
|
render(width: number): string[] {
|
|
31
|
-
const outerWidth = Math.max(
|
|
32
|
-
const
|
|
33
|
-
const
|
|
34
|
-
|
|
35
|
+
const outerWidth = Math.max(4, width);
|
|
36
|
+
const contentWidth = Math.max(1, outerWidth - 4);
|
|
37
|
+
const innerHeight = Math.max(1, this.modalHeight() - 2);
|
|
38
|
+
const body = fitRows(this.renderBody(contentWidth, innerHeight), innerHeight);
|
|
39
|
+
return [
|
|
40
|
+
this.theme.fg("borderAccent", `╭${"─".repeat(Math.max(0, outerWidth - 2))}╮`),
|
|
41
|
+
...body.map((line) => this.borderedRow(line, contentWidth)),
|
|
42
|
+
this.theme.fg("borderAccent", `╰${"─".repeat(Math.max(0, outerWidth - 2))}╯`),
|
|
43
|
+
];
|
|
35
44
|
}
|
|
36
45
|
|
|
37
46
|
handleInput(data: string): void {
|
|
@@ -85,73 +94,146 @@ export class ReviewResultsViewer implements Component {
|
|
|
85
94
|
}
|
|
86
95
|
}
|
|
87
96
|
|
|
88
|
-
private
|
|
89
|
-
const
|
|
90
|
-
const
|
|
91
|
-
const
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
const split = width >= SPLIT_WIDTH;
|
|
95
|
-
const content = split ? this.renderSplit(width) : this.renderStacked(width);
|
|
96
|
-
return [header, ...warning, ...content, help];
|
|
97
|
+
private modalHeight(): number {
|
|
98
|
+
const terminalRows = Math.max(1, this.tui.terminal.rows);
|
|
99
|
+
const availableRows = Math.max(1, terminalRows - VIEWPORT_MARGIN_ROWS);
|
|
100
|
+
const proportionalRows = Math.floor(terminalRows * VIEWPORT_HEIGHT_RATIO);
|
|
101
|
+
return Math.min(availableRows, Math.max(3, proportionalRows));
|
|
97
102
|
}
|
|
98
103
|
|
|
99
|
-
private
|
|
100
|
-
const
|
|
101
|
-
const
|
|
102
|
-
const
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
104
|
+
private renderBody(width: number, height: number): string[] {
|
|
105
|
+
const warning = this.warning ? this.theme.fg("warning", this.warning) : "";
|
|
106
|
+
const chromeRows = 5;
|
|
107
|
+
const contentHeight = Math.max(1, height - chromeRows);
|
|
108
|
+
let content: string[];
|
|
109
|
+
if (this.issues.length === 0) {
|
|
110
|
+
content = fitRows([this.theme.fg("success", "No findings.")], contentHeight);
|
|
111
|
+
} else if (width >= SPLIT_WIDTH) {
|
|
112
|
+
content = this.renderSplit(width, contentHeight);
|
|
113
|
+
} else {
|
|
114
|
+
content = this.renderStacked(width, contentHeight);
|
|
108
115
|
}
|
|
109
|
-
|
|
116
|
+
|
|
117
|
+
return [
|
|
118
|
+
this.headerLine(width),
|
|
119
|
+
warning,
|
|
120
|
+
this.theme.fg("borderMuted", "─".repeat(width)),
|
|
121
|
+
...fitRows(content, contentHeight),
|
|
122
|
+
this.theme.fg("borderMuted", "─".repeat(width)),
|
|
123
|
+
this.helpLine(width),
|
|
124
|
+
];
|
|
110
125
|
}
|
|
111
126
|
|
|
112
|
-
private
|
|
113
|
-
|
|
127
|
+
private renderSplit(width: number, height: number): string[] {
|
|
128
|
+
const listWidth = Math.min(LIST_MAX_WIDTH, Math.max(LIST_MIN_WIDTH, Math.floor(width * 0.38)));
|
|
129
|
+
const detailWidth = Math.max(1, width - listWidth - 3);
|
|
130
|
+
const list = this.renderIssuePane(listWidth, height);
|
|
131
|
+
const details = this.renderDetailPane(detailWidth, height);
|
|
132
|
+
const divider = this.theme.fg("borderMuted", "│");
|
|
133
|
+
return Array.from({ length: height }, (_value, index) =>
|
|
134
|
+
`${padAnsi(list[index] ?? "", listWidth)} ${divider} ${padAnsi(details[index] ?? "", detailWidth)}`,
|
|
135
|
+
);
|
|
114
136
|
}
|
|
115
137
|
|
|
116
|
-
private
|
|
117
|
-
return this.
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
138
|
+
private renderStacked(width: number, height: number): string[] {
|
|
139
|
+
if (height < 7) return this.renderCompact(width, height);
|
|
140
|
+
const listHeight = Math.max(3, Math.min(height - 4, Math.floor(height * 0.38)));
|
|
141
|
+
const detailHeight = height - listHeight - 1;
|
|
142
|
+
return [
|
|
143
|
+
...this.renderIssuePane(width, listHeight),
|
|
144
|
+
this.theme.fg("borderMuted", "─".repeat(width)),
|
|
145
|
+
...this.renderDetailPane(width, detailHeight),
|
|
146
|
+
];
|
|
124
147
|
}
|
|
125
148
|
|
|
126
|
-
private
|
|
149
|
+
private renderCompact(width: number, height: number): string[] {
|
|
150
|
+
if (height <= 0) return [];
|
|
127
151
|
const issue = this.issues[this.cursor];
|
|
128
|
-
|
|
129
|
-
if (
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
if (
|
|
135
|
-
|
|
136
|
-
|
|
152
|
+
const selected = issue ? this.renderIssueRow(issue, this.cursor, width) : this.theme.fg("dim", "No finding selected.");
|
|
153
|
+
if (height === 1) return [selected];
|
|
154
|
+
return [selected, ...this.renderDetailPane(width, height - 1)];
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
private renderIssuePane(width: number, height: number): string[] {
|
|
158
|
+
if (height <= 0) return [];
|
|
159
|
+
const itemRows = Math.max(0, height - 2);
|
|
160
|
+
if (itemRows === 0) this.listScroll = 0;
|
|
161
|
+
const maxStart = Math.max(0, this.issues.length - itemRows);
|
|
162
|
+
if (this.cursor < this.listScroll) this.listScroll = this.cursor;
|
|
163
|
+
if (itemRows > 0 && this.cursor >= this.listScroll + itemRows) this.listScroll = this.cursor - itemRows + 1;
|
|
164
|
+
this.listScroll = Math.min(maxStart, Math.max(0, this.listScroll));
|
|
165
|
+
const end = Math.min(this.issues.length, this.listScroll + itemRows);
|
|
166
|
+
const visible = this.issues.slice(this.listScroll, end).map((issue, offset) => this.renderIssueRow(issue, this.listScroll + offset, width));
|
|
167
|
+
const range = this.issues.length === 0 || itemRows === 0 ? `0/${this.issues.length}` : `${this.listScroll + 1}–${end}/${this.issues.length}`;
|
|
168
|
+
const arrows = scrollArrows(this.listScroll > 0, end < this.issues.length);
|
|
169
|
+
const heading = truncateDisplay(
|
|
170
|
+
`${this.theme.fg("accent", this.theme.bold("Findings"))} ${this.theme.fg("muted", `${this.cursor + 1}/${Math.max(1, this.issues.length)}`)}`,
|
|
171
|
+
width,
|
|
172
|
+
);
|
|
173
|
+
const indicator = truncateDisplay(this.theme.fg("dim", `${range}${arrows ? ` ${arrows}` : ""}`), width);
|
|
174
|
+
return [heading, ...fitRows(visible, itemRows), indicator].slice(0, height);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
private renderIssueRow(issue: ReviewIssue, index: number, width: number): string {
|
|
178
|
+
const cursor = index === this.cursor ? this.theme.fg("accent", ">") : " ";
|
|
179
|
+
const checked = this.selected.has(issue.id) ? this.theme.fg("success", "x") : " ";
|
|
180
|
+
const severity = this.theme.fg(severityColor(issue.finding.severity), issue.finding.severity);
|
|
181
|
+
const row = `${cursor} [${checked}] ${issue.id} ${severity} ${issue.finding.category} ${formatIssueLocation(issue)} — ${issue.finding.summary}`;
|
|
182
|
+
return truncateDisplay(row, width);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
private renderDetailPane(width: number, height: number): string[] {
|
|
186
|
+
if (height <= 0) return [];
|
|
187
|
+
const issue = this.issues[this.cursor];
|
|
188
|
+
if (!issue) return fitRows([this.theme.fg("dim", "No finding selected.")], height);
|
|
189
|
+
|
|
190
|
+
const bodyHeight = Math.max(0, height - 2);
|
|
191
|
+
const heading = truncateDisplay(
|
|
192
|
+
`${this.theme.fg("accent", this.theme.bold("Details"))} ${this.theme.fg("muted", issue.id)}`,
|
|
193
|
+
width,
|
|
194
|
+
);
|
|
195
|
+
const lines = this.detailsExpanded
|
|
196
|
+
? renderIssueDetailLines(issue, this.theme, width)
|
|
197
|
+
: [issue.finding.summary, this.theme.fg("dim", "Press enter to expand details.")];
|
|
198
|
+
if (bodyHeight === 0) this.detailScroll = 0;
|
|
199
|
+
const maxScroll = Math.max(0, lines.length - bodyHeight);
|
|
200
|
+
this.detailScroll = Math.min(maxScroll, Math.max(0, this.detailScroll));
|
|
201
|
+
const end = Math.min(lines.length, this.detailScroll + bodyHeight);
|
|
202
|
+
const visible = lines.slice(this.detailScroll, end).map((line) => truncateDisplay(line, width));
|
|
203
|
+
const range = lines.length === 0 || bodyHeight === 0 ? `0/${lines.length}` : `${this.detailScroll + 1}–${end}/${lines.length}`;
|
|
204
|
+
const arrows = scrollArrows(this.detailScroll > 0, bodyHeight === 0 ? lines.length > 0 : end < lines.length);
|
|
205
|
+
const indicator = truncateDisplay(
|
|
206
|
+
this.theme.fg("dim", `${this.detailsExpanded ? "Lines" : "Collapsed"} ${range}${arrows ? ` ${arrows}` : ""}`),
|
|
207
|
+
width,
|
|
208
|
+
);
|
|
209
|
+
return [heading, ...fitRows(visible, bodyHeight), indicator].slice(0, height);
|
|
137
210
|
}
|
|
138
211
|
|
|
139
212
|
private headerLine(width: number): string {
|
|
140
213
|
const count = `${this.issues.length} finding${this.issues.length === 1 ? "" : "s"}`;
|
|
141
214
|
const selected = `${this.selected.size} selected`;
|
|
142
|
-
return truncateDisplay(
|
|
215
|
+
return truncateDisplay(
|
|
216
|
+
`${this.theme.fg("accent", this.theme.bold("Review results"))} ${this.theme.fg("muted", this.workflowName)} ${this.theme.fg("dim", `· ${count} · ${selected}`)}`,
|
|
217
|
+
width,
|
|
218
|
+
);
|
|
143
219
|
}
|
|
144
220
|
|
|
145
221
|
private helpLine(width: number): string {
|
|
146
|
-
|
|
222
|
+
const help = width >= 110
|
|
223
|
+
? "↑↓ findings · 1-9 jump · space select · a all · enter details · ←→ scroll · f fix · c comment · q close"
|
|
224
|
+
: "q close · ↑↓ move · space select · enter details · ←→ scroll · f fix · c comment";
|
|
225
|
+
return truncateDisplay(
|
|
226
|
+
this.theme.fg("dim", help),
|
|
227
|
+
width,
|
|
228
|
+
);
|
|
147
229
|
}
|
|
148
230
|
|
|
149
|
-
private
|
|
150
|
-
return
|
|
231
|
+
private borderedRow(content: string, width: number): string {
|
|
232
|
+
return `${this.theme.fg("border", "│")} ${padAnsi(content, width)} ${this.theme.fg("border", "│")}`;
|
|
151
233
|
}
|
|
152
234
|
|
|
153
|
-
private
|
|
154
|
-
|
|
235
|
+
private requestRender(): void {
|
|
236
|
+
this.tui.requestRender();
|
|
155
237
|
}
|
|
156
238
|
|
|
157
239
|
private moveCursor(delta: number): void {
|
|
@@ -218,12 +300,21 @@ export class ReviewResultsViewer implements Component {
|
|
|
218
300
|
}
|
|
219
301
|
}
|
|
220
302
|
|
|
303
|
+
function fitRows(lines: readonly string[], height: number): string[] {
|
|
304
|
+
const visible = lines.slice(0, Math.max(0, height));
|
|
305
|
+
return [...visible, ...Array.from({ length: Math.max(0, height - visible.length) }, () => "")];
|
|
306
|
+
}
|
|
307
|
+
|
|
221
308
|
function padAnsi(text: string, width: number): string {
|
|
222
309
|
const truncated = truncateDisplay(text, width);
|
|
223
310
|
const padding = Math.max(0, width - visibleWidth(truncated));
|
|
224
311
|
return `${truncated}${" ".repeat(padding)}`;
|
|
225
312
|
}
|
|
226
313
|
|
|
314
|
+
function scrollArrows(hasAbove: boolean, hasBelow: boolean): string {
|
|
315
|
+
return `${hasAbove ? "↑" : ""}${hasBelow ? "↓" : ""}`;
|
|
316
|
+
}
|
|
317
|
+
|
|
227
318
|
function digitJumpIndex(data: string): number | undefined {
|
|
228
319
|
return data.length === 1 && data >= "1" && data <= "9" ? Number(data) - 1 : undefined;
|
|
229
320
|
}
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { dynamaxSessionKey } from "../dynamax.ts";
|
|
3
|
+
import { resolveWorkflowRunOptions, type ResolvedWorkflowRunOptions } from "../options.ts";
|
|
4
|
+
import type { LoadedWorkflow } from "../types.ts";
|
|
5
|
+
import type { WorkflowExecution } from "../workflow-execution.ts";
|
|
6
|
+
import { handleReviewViewerAction } from "./review-actions.ts";
|
|
7
|
+
import { ReviewFixBudgetLedger } from "./review-budget.ts";
|
|
8
|
+
import { toReviewIssues, type ReviewIssue, type ReviewIssueSelection } from "./review-issues.ts";
|
|
9
|
+
import { codeReviewReport, decideReviewResultsPresentation, showReviewResultsViewer } from "./review-results-flow.ts";
|
|
10
|
+
import type { ReviewReport } from "./review-report.ts";
|
|
11
|
+
|
|
12
|
+
interface RetainedCodeReviewResult {
|
|
13
|
+
readonly report: ReviewReport;
|
|
14
|
+
readonly concurrency: number;
|
|
15
|
+
readonly parallelSubmissionLimit?: number;
|
|
16
|
+
readonly perf: boolean;
|
|
17
|
+
readonly budget: ReviewFixBudgetLedger;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface ReviewSessionCoordinatorDependencies {
|
|
21
|
+
readonly runFollowUp: (
|
|
22
|
+
ctx: ExtensionContext,
|
|
23
|
+
workflow: LoadedWorkflow,
|
|
24
|
+
options: ResolvedWorkflowRunOptions,
|
|
25
|
+
) => Promise<WorkflowExecution>;
|
|
26
|
+
readonly publish: (execution: WorkflowExecution) => void;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Owns retained code-review state and every path that presents or acts on it. */
|
|
30
|
+
export class ReviewSessionCoordinator {
|
|
31
|
+
private readonly sessions = new Map<string, RetainedCodeReviewResult>();
|
|
32
|
+
|
|
33
|
+
constructor(
|
|
34
|
+
private readonly pi: Pick<ExtensionAPI, "sendUserMessage" | "exec">,
|
|
35
|
+
private readonly dependencies: ReviewSessionCoordinatorDependencies,
|
|
36
|
+
) {}
|
|
37
|
+
|
|
38
|
+
remember(
|
|
39
|
+
ctx: ExtensionContext,
|
|
40
|
+
execution: WorkflowExecution,
|
|
41
|
+
options: ResolvedWorkflowRunOptions,
|
|
42
|
+
): void {
|
|
43
|
+
const name = execution.envelope.name;
|
|
44
|
+
if (name !== "code-review") return;
|
|
45
|
+
const key = dynamaxSessionKey(ctx);
|
|
46
|
+
const report = codeReviewReport(name, execution.envelope.result);
|
|
47
|
+
if (!report) {
|
|
48
|
+
this.sessions.delete(key);
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
this.sessions.set(key, {
|
|
52
|
+
report,
|
|
53
|
+
concurrency: options.concurrency,
|
|
54
|
+
parallelSubmissionLimit: options.parallelSubmissionLimit ?? undefined,
|
|
55
|
+
perf: options.perf,
|
|
56
|
+
budget: new ReviewFixBudgetLedger(options.budget, execution.envelope.usage),
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async present(
|
|
61
|
+
ctx: ExtensionContext,
|
|
62
|
+
execution: WorkflowExecution,
|
|
63
|
+
options: ResolvedWorkflowRunOptions,
|
|
64
|
+
): Promise<void> {
|
|
65
|
+
const name = execution.envelope.name;
|
|
66
|
+
const decision = decideReviewResultsPresentation({
|
|
67
|
+
workflowName: name,
|
|
68
|
+
result: execution.envelope.result,
|
|
69
|
+
mode: ctx.mode,
|
|
70
|
+
hasUI: ctx.hasUI,
|
|
71
|
+
resultViewer: options.resultViewer,
|
|
72
|
+
});
|
|
73
|
+
if (decision.kind !== "open") return;
|
|
74
|
+
|
|
75
|
+
const retained = this.sessions.get(dynamaxSessionKey(ctx));
|
|
76
|
+
if (!retained) return;
|
|
77
|
+
await this.openAndHandle(ctx, retained, decision.issues);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async reopen(ctx: ExtensionContext): Promise<void> {
|
|
81
|
+
if (!ctx.hasUI || ctx.mode !== "tui") {
|
|
82
|
+
ctx.ui.notify("Code-review results viewer requires the TUI", "warning");
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
const retained = this.sessions.get(dynamaxSessionKey(ctx));
|
|
86
|
+
if (!retained) {
|
|
87
|
+
ctx.ui.notify("No code-review result is available yet. Run /workflow code-review first.", "warning");
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
if (retained.report.findings.length === 0) {
|
|
91
|
+
ctx.ui.notify("The last code review had no findings", "info");
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
await this.openAndHandle(ctx, retained, toReviewIssues("code-review", retained.report));
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
dispose(ctx: ExtensionContext): void {
|
|
99
|
+
this.sessions.delete(dynamaxSessionKey(ctx));
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
private async openAndHandle(
|
|
103
|
+
ctx: ExtensionContext,
|
|
104
|
+
retained: RetainedCodeReviewResult,
|
|
105
|
+
issues: readonly ReviewIssue[],
|
|
106
|
+
): Promise<void> {
|
|
107
|
+
let action: ReviewIssueSelection | undefined;
|
|
108
|
+
try {
|
|
109
|
+
action = await showReviewResultsViewer(ctx, issues);
|
|
110
|
+
} catch {
|
|
111
|
+
ctx.ui.notify("Review completed, but the findings viewer could not be opened.", "warning");
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
await this.runAction(ctx, retained, action, issues);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
private async runAction(
|
|
118
|
+
ctx: ExtensionContext,
|
|
119
|
+
retained: RetainedCodeReviewResult,
|
|
120
|
+
action: ReviewIssueSelection | undefined,
|
|
121
|
+
issues: readonly ReviewIssue[],
|
|
122
|
+
): Promise<void> {
|
|
123
|
+
const lease = action?.action === "fix" ? retained.budget.acquire() : undefined;
|
|
124
|
+
if (lease && !lease.ok) {
|
|
125
|
+
const message = lease.reason === "exhausted"
|
|
126
|
+
? "Review-fix previews are unavailable because this review's output-token budget is exhausted."
|
|
127
|
+
: "A review-fix preview is already running for this review.";
|
|
128
|
+
ctx.ui.notify(message, "warning");
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
try {
|
|
133
|
+
const followUp = await handleReviewViewerAction(this.pi, ctx, action, issues, retained.report.reviewContext);
|
|
134
|
+
if (!followUp) return;
|
|
135
|
+
const execution = await this.dependencies.runFollowUp(ctx, followUp, followUpOptions(retained, ctx.signal));
|
|
136
|
+
this.dependencies.publish(execution);
|
|
137
|
+
} finally {
|
|
138
|
+
if (lease?.ok) lease.release();
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function followUpOptions(retained: RetainedCodeReviewResult, signal: AbortSignal | undefined): ResolvedWorkflowRunOptions {
|
|
144
|
+
return resolveWorkflowRunOptions({
|
|
145
|
+
concurrency: retained.concurrency,
|
|
146
|
+
parallelSubmissionLimit: retained.parallelSubmissionLimit,
|
|
147
|
+
budget: retained.budget.remaining,
|
|
148
|
+
perf: retained.perf,
|
|
149
|
+
resultViewer: "skip",
|
|
150
|
+
signal,
|
|
151
|
+
onUsageSnapshot: (usage) => retained.budget.record(usage),
|
|
152
|
+
}, {});
|
|
153
|
+
}
|