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.
Files changed (55) hide show
  1. package/.pi/extensions/pi-workflow-engine/index.ts +168 -121
  2. package/.pi/extensions/pi-workflow-engine/src/advisory-schema.ts +11 -0
  3. package/.pi/extensions/pi-workflow-engine/src/agent-attempt.ts +145 -0
  4. package/.pi/extensions/pi-workflow-engine/src/agent-replay.ts +376 -0
  5. package/.pi/extensions/pi-workflow-engine/src/agent-runner-types.ts +87 -0
  6. package/.pi/extensions/pi-workflow-engine/src/agent-runner.ts +84 -529
  7. package/.pi/extensions/pi-workflow-engine/src/agent-session-identity.ts +256 -0
  8. package/.pi/extensions/pi-workflow-engine/src/agent-session.ts +413 -0
  9. package/.pi/extensions/pi-workflow-engine/src/agent-skills.ts +43 -1
  10. package/.pi/extensions/pi-workflow-engine/src/agent-workspace.ts +81 -0
  11. package/.pi/extensions/pi-workflow-engine/src/budget.ts +7 -0
  12. package/.pi/extensions/pi-workflow-engine/src/cancellation.ts +17 -3
  13. package/.pi/extensions/pi-workflow-engine/src/concurrency.ts +164 -61
  14. package/.pi/extensions/pi-workflow-engine/src/diff-capture.ts +117 -20
  15. package/.pi/extensions/pi-workflow-engine/src/discovery.ts +64 -15
  16. package/.pi/extensions/pi-workflow-engine/src/dynamax-shortcuts.ts +59 -13
  17. package/.pi/extensions/pi-workflow-engine/src/dynamax.ts +14 -3
  18. package/.pi/extensions/pi-workflow-engine/src/engine.ts +208 -74
  19. package/.pi/extensions/pi-workflow-engine/src/finalizers.ts +66 -0
  20. package/.pi/extensions/pi-workflow-engine/src/identity-canonicalization.ts +138 -0
  21. package/.pi/extensions/pi-workflow-engine/src/identity-fingerprint.ts +15 -0
  22. package/.pi/extensions/pi-workflow-engine/src/inline-workflow.ts +8 -3
  23. package/.pi/extensions/pi-workflow-engine/src/journal.ts +148 -64
  24. package/.pi/extensions/pi-workflow-engine/src/options.ts +9 -6
  25. package/.pi/extensions/pi-workflow-engine/src/process-runner.ts +203 -39
  26. package/.pi/extensions/pi-workflow-engine/src/progress.ts +2 -5
  27. package/.pi/extensions/pi-workflow-engine/src/replay-path-identity.ts +48 -0
  28. package/.pi/extensions/pi-workflow-engine/src/resume-context.ts +665 -0
  29. package/.pi/extensions/pi-workflow-engine/src/review/github-pr-comments.ts +75 -16
  30. package/.pi/extensions/pi-workflow-engine/src/review/review-actions.ts +83 -17
  31. package/.pi/extensions/pi-workflow-engine/src/review/review-budget.ts +45 -0
  32. package/.pi/extensions/pi-workflow-engine/src/review/review-fix-workflow.ts +117 -0
  33. package/.pi/extensions/pi-workflow-engine/src/review/review-handoff.ts +14 -66
  34. package/.pi/extensions/pi-workflow-engine/src/review/review-issues.ts +38 -12
  35. package/.pi/extensions/pi-workflow-engine/src/review/review-report.ts +42 -0
  36. package/.pi/extensions/pi-workflow-engine/src/review/review-results-flow.ts +17 -22
  37. package/.pi/extensions/pi-workflow-engine/src/review/review-results-viewer.ts +143 -52
  38. package/.pi/extensions/pi-workflow-engine/src/review/review-session-coordinator.ts +153 -0
  39. package/.pi/extensions/pi-workflow-engine/src/review/review-snapshot.ts +295 -0
  40. package/.pi/extensions/pi-workflow-engine/src/tool-source-identity.ts +153 -0
  41. package/.pi/extensions/pi-workflow-engine/src/tree-fingerprint.ts +404 -0
  42. package/.pi/extensions/pi-workflow-engine/src/types.ts +38 -10
  43. package/.pi/extensions/pi-workflow-engine/src/ui/workflow-result-renderer.ts +5 -69
  44. package/.pi/extensions/pi-workflow-engine/src/unknown-error.ts +8 -0
  45. package/.pi/extensions/pi-workflow-engine/src/workflow-execution.ts +121 -0
  46. package/.pi/extensions/pi-workflow-engine/src/workflow-module.ts +19 -1
  47. package/.pi/extensions/pi-workflow-engine/src/workflows.ts +28 -3
  48. package/.pi/extensions/pi-workflow-engine/src/worktree.ts +282 -41
  49. package/.pi/extensions/pi-workflow-engine/workflows/code-review.ts +48 -26
  50. package/.pi/extensions/pi-workflow-engine/workflows/diagnose.ts +8 -1
  51. package/.pi/extensions/pi-workflow-engine/workflows/perf-review.ts +8 -2
  52. package/.pi/extensions/pi-workflow-engine/workflows/refactor-scout.ts +8 -1
  53. package/README.md +170 -226
  54. package/USAGE.md +92 -12
  55. 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 { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
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: "tool-invocation" | "not-code-review" | "not-advisory" | "no-findings" | "disabled" | "not-tui" | "not-requested";
10
+ readonly reason: "not-code-review" | "not-advisory" | "no-findings" | "disabled" | "not-tui" | "not-requested";
11
11
  }
12
- | { readonly kind: "open"; readonly report: AdvisoryWorkflowResult; readonly issues: readonly ReviewIssue[]; readonly findingCount: number };
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 interface ReviewResultsViewerContext {
24
- readonly ui: Pick<ExtensionCommandContext["ui"], "custom">;
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 function extensionContextMode(ctx: ExtensionCommandContext): string | undefined {
28
- const candidate = (ctx as ExtensionCommandContext & { readonly mode?: unknown }).mode;
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
- if (!isAdvisoryReport(input.result)) return { kind: "send", reason: "not-advisory" };
36
- if (input.result.findings.length === 0) return { kind: "send", reason: "no-findings" };
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
- const issues = toReviewIssues(input.workflowName, input.result);
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 maybeShowReviewResultsViewer(
43
+ export async function showReviewResultsViewer(
47
44
  ctx: ReviewResultsViewerContext,
48
- decision: ReviewResultsPresentationDecision,
49
- ): Promise<ReviewIssueSelection | undefined> {
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(decision.issues, "code-review", theme, () => tui.requestRender(), done),
54
- { overlay: true, overlayOptions: { anchor: "right-center", width: "80%", maxHeight: "90%", margin: 1 } },
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 MIN_WIDTH = 40;
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(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
+ 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 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
+ 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 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)}`);
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
- return lines;
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 renderStacked(width: number): string[] {
113
- return [...this.renderIssueList(width), this.theme.fg("dim", "─".repeat(Math.max(1, width))), ...this.renderDetail(width)];
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 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
- });
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 renderDetail(width: number): string[] {
149
+ private renderCompact(width: number, height: number): string[] {
150
+ if (height <= 0) return [];
127
151
  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));
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(`${this.theme.fg("accent", this.theme.bold("Review results"))} ${this.theme.fg("muted", this.workflowName)} · ${count} · ${selected}`, width);
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
- 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);
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 topBorder(innerWidth: number): string {
150
- return `┌${this.theme.fg("dim", "─".repeat(innerWidth))}┐`;
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 bottomBorder(innerWidth: number): string {
154
- return `└${this.theme.fg("dim", "─".repeat(innerWidth))}┘`;
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
+ }