pi-workflow-engine 0.13.0 → 1.0.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 (92) hide show
  1. package/.pi/extensions/pi-workflow-engine/index.ts +106 -391
  2. package/.pi/extensions/pi-workflow-engine/src/advisory-challenge.ts +1 -1
  3. package/.pi/extensions/pi-workflow-engine/src/advisory-evidence.ts +8 -8
  4. package/.pi/extensions/pi-workflow-engine/src/advisory-schema.ts +7 -1
  5. package/.pi/extensions/pi-workflow-engine/src/agent-attempt.ts +48 -62
  6. package/.pi/extensions/pi-workflow-engine/src/agent-replay.ts +46 -67
  7. package/.pi/extensions/pi-workflow-engine/src/agent-retry.ts +14 -37
  8. package/.pi/extensions/pi-workflow-engine/src/agent-runner-types.ts +5 -11
  9. package/.pi/extensions/pi-workflow-engine/src/agent-runner.ts +15 -21
  10. package/.pi/extensions/pi-workflow-engine/src/agent-session-identity.ts +13 -43
  11. package/.pi/extensions/pi-workflow-engine/src/agent-session-providers.ts +17 -5
  12. package/.pi/extensions/pi-workflow-engine/src/agent-session.ts +93 -100
  13. package/.pi/extensions/pi-workflow-engine/src/agent-skills.ts +11 -43
  14. package/.pi/extensions/pi-workflow-engine/src/agent-workspace.ts +4 -18
  15. package/.pi/extensions/pi-workflow-engine/src/background-workflows.ts +33 -86
  16. package/.pi/extensions/pi-workflow-engine/src/cancellation.ts +1 -5
  17. package/.pi/extensions/pi-workflow-engine/src/concurrency.ts +25 -80
  18. package/.pi/extensions/pi-workflow-engine/src/discovery.ts +16 -23
  19. package/.pi/extensions/pi-workflow-engine/src/dynamax-shortcuts.ts +1 -4
  20. package/.pi/extensions/pi-workflow-engine/src/dynamax.ts +39 -98
  21. package/.pi/extensions/pi-workflow-engine/src/engine.ts +37 -80
  22. package/.pi/extensions/pi-workflow-engine/src/finalizers.ts +2 -10
  23. package/.pi/extensions/pi-workflow-engine/src/git.ts +55 -0
  24. package/.pi/extensions/pi-workflow-engine/src/guards.ts +8 -0
  25. package/.pi/extensions/pi-workflow-engine/src/identity-canonicalization.ts +7 -19
  26. package/.pi/extensions/pi-workflow-engine/src/inline-workflow.ts +171 -106
  27. package/.pi/extensions/pi-workflow-engine/src/journal.ts +0 -12
  28. package/.pi/extensions/pi-workflow-engine/src/model-profile-command.ts +37 -8
  29. package/.pi/extensions/pi-workflow-engine/src/model-profiles.ts +15 -66
  30. package/.pi/extensions/pi-workflow-engine/src/options.ts +97 -16
  31. package/.pi/extensions/pi-workflow-engine/src/perf.ts +43 -72
  32. package/.pi/extensions/pi-workflow-engine/src/process-runner.ts +14 -4
  33. package/.pi/extensions/pi-workflow-engine/src/progress-types.ts +2 -0
  34. package/.pi/extensions/pi-workflow-engine/src/progress.ts +79 -80
  35. package/.pi/extensions/pi-workflow-engine/src/provider-usage-limit.ts +28 -20
  36. package/.pi/extensions/pi-workflow-engine/src/replay-path-identity.ts +2 -7
  37. package/.pi/extensions/pi-workflow-engine/src/research-contract.ts +0 -1
  38. package/.pi/extensions/pi-workflow-engine/src/research-evidence.ts +27 -31
  39. package/.pi/extensions/pi-workflow-engine/src/resume-context.ts +75 -139
  40. package/.pi/extensions/pi-workflow-engine/src/review/github-pr-comments.ts +14 -31
  41. package/.pi/extensions/pi-workflow-engine/src/review/patch-validation.ts +3 -4
  42. package/.pi/extensions/pi-workflow-engine/src/review/review-actions.ts +1 -1
  43. package/.pi/extensions/pi-workflow-engine/src/review/review-diff-lines.ts +54 -0
  44. package/.pi/extensions/pi-workflow-engine/src/review/review-fix-workflow.ts +7 -9
  45. package/.pi/extensions/pi-workflow-engine/src/review/review-format.ts +51 -117
  46. package/.pi/extensions/pi-workflow-engine/src/review/review-handoff.ts +1 -1
  47. package/.pi/extensions/pi-workflow-engine/src/review/review-issues.ts +8 -13
  48. package/.pi/extensions/pi-workflow-engine/src/review/review-report.ts +3 -7
  49. package/.pi/extensions/pi-workflow-engine/src/review/review-results-viewer.ts +158 -241
  50. package/.pi/extensions/pi-workflow-engine/src/review/review-session-coordinator.ts +28 -38
  51. package/.pi/extensions/pi-workflow-engine/src/review/review-snapshot.ts +35 -44
  52. package/.pi/extensions/pi-workflow-engine/src/review-diff-target.ts +13 -14
  53. package/.pi/extensions/pi-workflow-engine/src/text.ts +29 -0
  54. package/.pi/extensions/pi-workflow-engine/src/tool-capabilities.ts +2 -1
  55. package/.pi/extensions/pi-workflow-engine/src/tool-source-identity.ts +16 -28
  56. package/.pi/extensions/pi-workflow-engine/src/tree-fingerprint.ts +34 -14
  57. package/.pi/extensions/pi-workflow-engine/src/types.ts +6 -0
  58. package/.pi/extensions/pi-workflow-engine/src/ui/kit.ts +193 -0
  59. package/.pi/extensions/pi-workflow-engine/src/ui/research-result-renderer.ts +177 -0
  60. package/.pi/extensions/pi-workflow-engine/src/ui/result-layout.ts +209 -0
  61. package/.pi/extensions/pi-workflow-engine/src/ui/workflow-format.ts +17 -44
  62. package/.pi/extensions/pi-workflow-engine/src/ui/workflow-inspector.ts +703 -235
  63. package/.pi/extensions/pi-workflow-engine/src/ui/workflow-picker.ts +48 -0
  64. package/.pi/extensions/pi-workflow-engine/src/ui/workflow-result-renderer.ts +239 -129
  65. package/.pi/extensions/pi-workflow-engine/src/ui/workflow-runs-browser.ts +461 -0
  66. package/.pi/extensions/pi-workflow-engine/src/ui/workflow-tool-renderer.ts +91 -0
  67. package/.pi/extensions/pi-workflow-engine/src/ui/workflow-widget.ts +316 -98
  68. package/.pi/extensions/pi-workflow-engine/src/usage.ts +21 -32
  69. package/.pi/extensions/pi-workflow-engine/src/workflow-advisory-utils.ts +56 -19
  70. package/.pi/extensions/pi-workflow-engine/src/workflow-execution.ts +19 -24
  71. package/.pi/extensions/pi-workflow-engine/src/workflow-module.ts +17 -37
  72. package/.pi/extensions/pi-workflow-engine/src/workflow-result-context.ts +79 -0
  73. package/.pi/extensions/pi-workflow-engine/src/workflow-run-background.ts +4 -14
  74. package/.pi/extensions/pi-workflow-engine/src/workflow-run-controller.ts +77 -63
  75. package/.pi/extensions/pi-workflow-engine/src/workflow-run-history.ts +42 -29
  76. package/.pi/extensions/pi-workflow-engine/src/workflow-run-record.ts +15 -14
  77. package/.pi/extensions/pi-workflow-engine/src/workflow-run-store.ts +27 -31
  78. package/.pi/extensions/pi-workflow-engine/src/workflow-usage-limit-scheduler.ts +1 -5
  79. package/.pi/extensions/pi-workflow-engine/src/workflows.ts +3 -6
  80. package/.pi/extensions/pi-workflow-engine/src/worktree.ts +48 -110
  81. package/.pi/extensions/pi-workflow-engine/workflows/code-review.ts +64 -93
  82. package/.pi/extensions/pi-workflow-engine/workflows/diagnose.ts +25 -67
  83. package/.pi/extensions/pi-workflow-engine/workflows/perf-review.ts +19 -53
  84. package/.pi/extensions/pi-workflow-engine/workflows/refactor-scout.ts +21 -49
  85. package/.pi/extensions/pi-workflow-engine/workflows/research.ts +74 -66
  86. package/USAGE.md +31 -17
  87. package/package.json +1 -2
  88. package/skills/workflow-code-review-actions/SKILL.md +12 -23
  89. package/.pi/extensions/pi-workflow-engine/src/diff-capture.ts +0 -81
  90. package/.pi/extensions/pi-workflow-engine/src/review/code-review-orchestration.ts +0 -24
  91. package/.pi/extensions/pi-workflow-engine/src/review/review-results-flow.ts +0 -52
  92. package/.pi/settings.json +0 -3
@@ -1,312 +1,229 @@
1
- import type { Theme } from "@earendil-works/pi-coding-agent";
2
- import { matchesKey, type Component, type TUI } 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
- import { fitWorkflowViewerRow, fitWorkflowViewerRows, workflowViewerHeight } from "../ui/workflow-viewer-layout.ts";
7
-
1
+ import type { ExtensionContext, Theme } from "@earendil-works/pi-coding-agent";
2
+ import { decodeKittyPrintable, matchesKey, visibleWidth, type Component, type TUI } from "@earendil-works/pi-tui";
3
+ import { findingPathColumn, renderFindingDetail, renderFindingRow, sortIssuesForDisplay } from "./review-format.ts";
4
+ import { dot, fit, frame, GLYPH, keyHints, overlayHeight, scrollHint } from "../ui/kit.ts";
5
+ import { isCommentableIssue, type ReviewIssue, type ReviewIssueSelection } from "./review-issues.ts";
6
+ import { WORKFLOW_VIEWER_OVERLAY_OPTIONS } from "../ui/workflow-viewer-layout.ts";
7
+
8
+ /** Inner width from which the list and the detail pane sit side by side. */
8
9
  const SPLIT_WIDTH = 96;
9
- const LIST_MIN_WIDTH = 34;
10
- const LIST_MAX_WIDTH = 48;
11
- const DETAIL_SCROLL_STEP = 5;
10
+ const SPLIT_LIST_SHARE = 0.5;
11
+ const SPLIT_LIST_MIN = 40;
12
+ const SPLIT_LIST_MAX = 64;
13
+ /** Stacked layout keeps at least this many detail rows below the list. */
14
+ const STACKED_DETAIL_MIN = 6;
15
+ const PREFIX_WIDTH = 4;
12
16
 
13
17
  type ViewerTui = Pick<TUI, "requestRender" | "terminal">;
18
+ type Hint = readonly [key: string, description: string];
19
+
20
+ export interface ReviewResultsViewerContext {
21
+ readonly ui: Pick<ExtensionContext["ui"], "custom">;
22
+ }
23
+
24
+ export async function showReviewResultsViewer(
25
+ ctx: ReviewResultsViewerContext,
26
+ issues: readonly ReviewIssue[],
27
+ ): Promise<ReviewIssueSelection> {
28
+ return await ctx.ui.custom<ReviewIssueSelection>(
29
+ (tui, theme, _keybindings, done) => new ReviewResultsViewer(issues, tui, theme, done),
30
+ WORKFLOW_VIEWER_OVERLAY_OPTIONS,
31
+ );
32
+ }
14
33
 
34
+ /**
35
+ * Framed findings triage overlay: most severe first, a selectable list with the cursor finding's full
36
+ * detail beside it (or below it when narrow), and selection-gated patch-preview and PR-comment actions.
37
+ */
15
38
  export class ReviewResultsViewer implements Component {
39
+ private readonly issues: readonly ReviewIssue[];
40
+ private readonly reportOrder: readonly ReviewIssue[];
16
41
  private readonly selected = new Set<string>();
17
42
  private cursor = 0;
18
43
  private listScroll = 0;
19
44
  private detailScroll = 0;
20
- private detailsExpanded = true;
45
+ private detailClipped = false;
21
46
  private warning: string | undefined;
47
+ private detailHeightCache: { readonly width: number; readonly rows: number } | undefined;
22
48
 
23
49
  constructor(
24
- private readonly issues: readonly ReviewIssue[],
25
- private readonly workflowName: string,
50
+ issues: readonly ReviewIssue[],
26
51
  private readonly tui: ViewerTui,
27
52
  private readonly theme: Theme,
28
53
  private readonly done: (result: ReviewIssueSelection) => void,
29
- ) {}
54
+ ) {
55
+ this.reportOrder = issues;
56
+ this.issues = sortIssuesForDisplay(issues);
57
+ }
30
58
 
31
- invalidate(): void {}
59
+ invalidate(): void {
60
+ this.detailHeightCache = undefined;
61
+ }
32
62
 
33
63
  render(width: number): string[] {
34
- const outerWidth = Math.max(4, width);
35
- const contentWidth = Math.max(1, outerWidth - 4);
36
- const innerHeight = Math.max(1, workflowViewerHeight(this.tui.terminal.rows) - 2);
37
- const body = fitWorkflowViewerRows(this.renderBody(contentWidth, innerHeight), innerHeight);
38
- return [
39
- this.theme.fg("borderAccent", `╭${"─".repeat(Math.max(0, outerWidth - 2))}╮`),
40
- ...body.map((line) => this.borderedRow(line, contentWidth)),
41
- this.theme.fg("borderAccent", `╰${"─".repeat(Math.max(0, outerWidth - 2))}╯`),
42
- ];
64
+ const inner = Math.max(1, width - 4);
65
+ this.detailClipped = false;
66
+ const body = this.issues.length === 0
67
+ ? [this.theme.fg("muted", "No findings.")]
68
+ : inner >= SPLIT_WIDTH ? this.renderSplit(inner) : this.renderStacked(inner);
69
+ return frame(body, width, this.theme, {
70
+ title: `${this.theme.fg("accent", this.theme.bold("Review findings"))}${dot(this.theme)}${this.theme.fg("muted", "code-review")}`,
71
+ right: this.counts(),
72
+ footer: this.footer(inner),
73
+ });
43
74
  }
44
75
 
45
76
  handleInput(data: string): void {
46
- const digitJump = digitJumpIndex(data);
47
- if (digitJump !== undefined) {
48
- this.jumpTo(digitJump);
49
- return;
50
- }
51
-
52
- if (matchesKey(data, "up")) {
53
- this.moveCursor(-1);
54
- return;
55
- }
56
- if (matchesKey(data, "down")) {
57
- this.moveCursor(1);
58
- return;
59
- }
60
- if (matchesKey(data, "space") || data === " ") {
61
- this.toggleCurrent();
62
- return;
63
- }
64
- if (data === "a") {
65
- this.toggleAllVisible();
66
- return;
67
- }
68
- if (matchesKey(data, "enter") || matchesKey(data, "return") || data === "\r") {
69
- this.detailsExpanded = !this.detailsExpanded;
70
- this.detailScroll = 0;
71
- this.warning = undefined;
72
- this.requestRender();
73
- return;
74
- }
75
- if (matchesKey(data, "pageUp") || matchesKey(data, "left")) {
76
- this.scrollDetail(-DETAIL_SCROLL_STEP);
77
- return;
78
- }
79
- if (matchesKey(data, "pageDown") || matchesKey(data, "right")) {
80
- this.scrollDetail(DETAIL_SCROLL_STEP);
81
- return;
82
- }
83
- if (data === "f") {
84
- this.finishSelected("fix");
85
- return;
86
- }
87
- if (data === "c") {
88
- this.finishSelected("comment");
89
- return;
90
- }
91
- if (data === "q" || matchesKey(data, "escape") || matchesKey(data, "esc")) {
77
+ const key = decodeKittyPrintable(data) ?? data;
78
+ if (key === "q" || matchesKey(data, "escape")) {
92
79
  this.done({ action: "close", issueIds: this.selectedIssueIds() });
80
+ return;
93
81
  }
82
+ this.warning = undefined;
83
+ if (matchesKey(data, "up") || key === "k") this.moveCursor(-1);
84
+ else if (matchesKey(data, "down") || key === "j") this.moveCursor(1);
85
+ else if (matchesKey(data, "home") || key === "g") this.moveCursor(-this.issues.length);
86
+ else if (matchesKey(data, "end") || key === "G") this.moveCursor(this.issues.length);
87
+ else if (matchesKey(data, "pageUp")) this.detailScroll = Math.max(0, this.detailScroll - this.pageSize());
88
+ else if (matchesKey(data, "pageDown")) this.detailScroll += this.pageSize();
89
+ else if (matchesKey(data, "space") || matchesKey(data, "enter")) this.toggleCurrent();
90
+ else if (key === "a") this.toggleAll();
91
+ else if (key === "f") this.finish("fix");
92
+ else if (key === "c") this.finish("comment");
93
+ this.tui.requestRender();
94
94
  }
95
95
 
96
- private renderBody(width: number, height: number): string[] {
97
- const warning = this.warning ? this.theme.fg("warning", this.warning) : "";
98
- const chromeRows = 5;
99
- const contentHeight = Math.max(1, height - chromeRows);
100
- let content: string[];
101
- if (this.issues.length === 0) {
102
- content = fitWorkflowViewerRows([this.theme.fg("success", "No findings.")], contentHeight);
103
- } else if (width >= SPLIT_WIDTH) {
104
- content = this.renderSplit(width, contentHeight);
105
- } else {
106
- content = this.renderStacked(width, contentHeight);
107
- }
108
-
109
- return [
110
- this.headerLine(width),
111
- warning,
112
- this.theme.fg("borderMuted", "─".repeat(width)),
113
- ...fitWorkflowViewerRows(content, contentHeight),
114
- this.theme.fg("borderMuted", "─".repeat(width)),
115
- this.helpLine(width),
116
- ];
117
- }
118
-
119
- private renderSplit(width: number, height: number): string[] {
120
- const listWidth = Math.min(LIST_MAX_WIDTH, Math.max(LIST_MIN_WIDTH, Math.floor(width * 0.38)));
121
- const detailWidth = Math.max(1, width - listWidth - 3);
122
- const list = this.renderIssuePane(listWidth, height);
123
- const details = this.renderDetailPane(detailWidth, height);
96
+ private renderSplit(inner: number): string[] {
97
+ const listWidth = Math.min(SPLIT_LIST_MAX, Math.max(SPLIT_LIST_MIN, Math.floor(inner * SPLIT_LIST_SHARE)));
98
+ const detailWidth = Math.max(1, inner - listWidth - 3);
99
+ const height = Math.min(this.bodyCeiling(), Math.max(this.issues.length, this.detailHeight(detailWidth)));
100
+ const list = this.renderList(listWidth, height);
101
+ const detail = this.renderDetail(detailWidth, height);
124
102
  const divider = this.theme.fg("borderMuted", "│");
125
- return Array.from({ length: height }, (_value, index) =>
126
- `${fitWorkflowViewerRow(list[index] ?? "", listWidth)} ${divider} ${fitWorkflowViewerRow(details[index] ?? "", detailWidth)}`,
127
- );
103
+ const rows: string[] = [];
104
+ for (let index = 0; index < height; index++) rows.push(`${fit(list[index] ?? "", listWidth)} ${divider} ${detail[index] ?? ""}`);
105
+ return rows;
128
106
  }
129
107
 
130
- private renderStacked(width: number, height: number): string[] {
131
- if (height < 7) return this.renderCompact(width, height);
132
- const listHeight = Math.max(3, Math.min(height - 4, Math.floor(height * 0.38)));
133
- const detailHeight = height - listHeight - 1;
108
+ private renderStacked(inner: number): string[] {
109
+ const ceiling = this.bodyCeiling();
110
+ const detailNeed = this.detailHeight(inner);
111
+ const listRows = Math.max(1, Math.min(this.issues.length, ceiling - 1 - Math.min(detailNeed, STACKED_DETAIL_MIN)));
112
+ const detailRows = Math.max(1, Math.min(detailNeed, ceiling - listRows - 1));
113
+ const detail = this.renderDetail(inner, detailRows);
134
114
  return [
135
- ...this.renderIssuePane(width, listHeight),
136
- this.theme.fg("borderMuted", "─".repeat(width)),
137
- ...this.renderDetailPane(width, detailHeight),
115
+ ...this.renderList(inner, listRows),
116
+ this.theme.fg("borderMuted", "─".repeat(inner)),
117
+ ...detail,
118
+ ...new Array<string>(Math.max(0, detailRows - detail.length)).fill(""),
138
119
  ];
139
120
  }
140
121
 
141
- private renderCompact(width: number, height: number): string[] {
142
- if (height <= 0) return [];
143
- const issue = this.issues[this.cursor];
144
- const selected = issue ? this.renderIssueRow(issue, this.cursor, width) : this.theme.fg("dim", "No finding selected.");
145
- if (height === 1) return [selected];
146
- return [selected, ...this.renderDetailPane(width, height - 1)];
147
- }
148
-
149
- private renderIssuePane(width: number, height: number): string[] {
150
- if (height <= 0) return [];
151
- const itemRows = Math.max(0, height - 2);
152
- if (itemRows === 0) this.listScroll = 0;
153
- const maxStart = Math.max(0, this.issues.length - itemRows);
122
+ /** Finding rows with cursor and selection marks; a scroll hint takes the last row only when clipped. */
123
+ private renderList(width: number, height: number): string[] {
124
+ const rows = this.issues.length > height ? Math.max(1, height - 1) : height;
154
125
  if (this.cursor < this.listScroll) this.listScroll = this.cursor;
155
- if (itemRows > 0 && this.cursor >= this.listScroll + itemRows) this.listScroll = this.cursor - itemRows + 1;
156
- this.listScroll = Math.min(maxStart, Math.max(0, this.listScroll));
157
- const end = Math.min(this.issues.length, this.listScroll + itemRows);
158
- const visible = this.issues.slice(this.listScroll, end).map((issue, offset) => this.renderIssueRow(issue, this.listScroll + offset, width));
159
- const range = this.issues.length === 0 || itemRows === 0 ? `0/${this.issues.length}` : `${this.listScroll + 1}–${end}/${this.issues.length}`;
160
- const arrows = scrollArrows(this.listScroll > 0, end < this.issues.length);
161
- const heading = truncateDisplay(
162
- `${this.theme.fg("accent", this.theme.bold("Findings"))} ${this.theme.fg("muted", `${this.cursor + 1}/${Math.max(1, this.issues.length)}`)}`,
163
- width,
164
- );
165
- const indicator = truncateDisplay(this.theme.fg("dim", `${range}${arrows ? ` ${arrows}` : ""}`), width);
166
- return [heading, ...fitWorkflowViewerRows(visible, itemRows), indicator].slice(0, height);
126
+ if (this.cursor >= this.listScroll + rows) this.listScroll = this.cursor - rows + 1;
127
+ this.listScroll = Math.max(0, Math.min(this.listScroll, this.issues.length - rows));
128
+ const visible = this.issues.slice(this.listScroll, this.listScroll + rows).map((issue, offset) => this.renderRow(issue, this.listScroll + offset, width));
129
+ const hint = scrollHint(this.listScroll, this.issues.length - this.listScroll - visible.length, this.theme);
130
+ return hint && this.issues.length > height ? [...visible, `${" ".repeat(PREFIX_WIDTH)}${hint}`] : visible;
167
131
  }
168
132
 
169
- private renderIssueRow(issue: ReviewIssue, index: number, width: number): string {
170
- const cursor = index === this.cursor ? this.theme.fg("accent", ">") : " ";
171
- const checked = this.selected.has(issue.id) ? this.theme.fg("success", "x") : " ";
172
- const severity = this.theme.fg(severityColor(issue.finding.severity), issue.finding.severity);
173
- const row = `${cursor} [${checked}] ${issue.id} ${severity} ${issue.finding.category} ${formatIssueLocation(issue)} — ${issue.finding.summary}`;
174
- return truncateDisplay(row, width);
133
+ private renderRow(issue: ReviewIssue, index: number, width: number): string {
134
+ const cursor = index === this.cursor ? this.theme.fg("accent", GLYPH.cursor) : " ";
135
+ const mark = this.selected.has(issue.id) ? this.theme.fg("success", GLYPH.running) : this.theme.fg("dim", GLYPH.queued);
136
+ return `${cursor} ${mark} ${renderFindingRow(issue, Math.max(1, width - PREFIX_WIDTH), this.theme, findingPathColumn(this.issues))}`;
175
137
  }
176
138
 
177
- private renderDetailPane(width: number, height: number): string[] {
178
- if (height <= 0) return [];
139
+ private renderDetail(width: number, height: number): string[] {
179
140
  const issue = this.issues[this.cursor];
180
- if (!issue) return fitWorkflowViewerRows([this.theme.fg("dim", "No finding selected.")], height);
181
-
182
- const bodyHeight = Math.max(0, height - 2);
183
- const heading = truncateDisplay(
184
- `${this.theme.fg("accent", this.theme.bold("Details"))} ${this.theme.fg("muted", issue.id)}`,
185
- width,
186
- );
187
- const lines = this.detailsExpanded
188
- ? renderIssueDetailLines(issue, this.theme, width)
189
- : [issue.finding.summary, this.theme.fg("dim", "Press enter to expand details.")];
190
- if (bodyHeight === 0) this.detailScroll = 0;
191
- const maxScroll = Math.max(0, lines.length - bodyHeight);
192
- this.detailScroll = Math.min(maxScroll, Math.max(0, this.detailScroll));
193
- const end = Math.min(lines.length, this.detailScroll + bodyHeight);
194
- const visible = lines.slice(this.detailScroll, end).map((line) => truncateDisplay(line, width));
195
- const range = lines.length === 0 || bodyHeight === 0 ? `0/${lines.length}` : `${this.detailScroll + 1}–${end}/${lines.length}`;
196
- const arrows = scrollArrows(this.detailScroll > 0, bodyHeight === 0 ? lines.length > 0 : end < lines.length);
197
- const indicator = truncateDisplay(
198
- this.theme.fg("dim", `${this.detailsExpanded ? "Lines" : "Collapsed"} ${range}${arrows ? ` ${arrows}` : ""}`),
199
- width,
200
- );
201
- return [heading, ...fitWorkflowViewerRows(visible, bodyHeight), indicator].slice(0, height);
141
+ if (!issue) return [];
142
+ const lines = renderFindingDetail(issue, width, this.theme);
143
+ if (lines.length <= height) {
144
+ this.detailScroll = 0;
145
+ return lines;
146
+ }
147
+ this.detailClipped = true;
148
+ const rows = Math.max(1, height - 1);
149
+ this.detailScroll = Math.max(0, Math.min(this.detailScroll, lines.length - rows));
150
+ const below = lines.length - this.detailScroll - rows;
151
+ const hint = scrollHint(this.detailScroll, below, this.theme) ?? "";
152
+ return [...lines.slice(this.detailScroll, this.detailScroll + rows), hint];
153
+ }
154
+
155
+ /** The tallest finding detail at this width, so the overlay keeps one height while the cursor moves. */
156
+ private detailHeight(width: number): number {
157
+ if (this.detailHeightCache?.width !== width) {
158
+ const rows = Math.max(0, ...this.issues.map((issue) => renderFindingDetail(issue, width, this.theme).length));
159
+ this.detailHeightCache = { width, rows };
160
+ }
161
+ return this.detailHeightCache.rows;
202
162
  }
203
163
 
204
- private headerLine(width: number): string {
205
- const count = `${this.issues.length} finding${this.issues.length === 1 ? "" : "s"}`;
206
- const selected = `${this.selected.size} selected`;
207
- return truncateDisplay(
208
- `${this.theme.fg("accent", this.theme.bold("Review results"))} ${this.theme.fg("muted", this.workflowName)} ${this.theme.fg("dim", `· ${count} · ${selected}`)}`,
209
- width,
210
- );
164
+ private bodyCeiling(): number {
165
+ return Math.max(3, overlayHeight(this.tui.terminal.rows, Number.MAX_SAFE_INTEGER) - 2);
211
166
  }
212
167
 
213
- private helpLine(width: number): string {
214
- const help = width >= 110
215
- ? "↑↓ findings · 1-9 jump · space select · a all · enter details · ←→ scroll · f fix · c comment · q close"
216
- : "q close · ↑↓ move · space select · enter details · ←→ scroll · f fix · c comment";
217
- return truncateDisplay(
218
- this.theme.fg("dim", help),
219
- width,
220
- );
168
+ private pageSize(): number {
169
+ return Math.max(1, Math.floor(this.bodyCeiling() / 2));
221
170
  }
222
171
 
223
- private borderedRow(content: string, width: number): string {
224
- return `${this.theme.fg("border", "│")} ${fitWorkflowViewerRow(content, width)} ${this.theme.fg("border", "│")}`;
172
+ private counts(): string {
173
+ const findings = `${this.issues.length} finding${this.issues.length === 1 ? "" : "s"}`;
174
+ const selected = this.selected.size > 0 ? this.theme.fg("success", `${this.selected.size} selected`) : undefined;
175
+ return [this.theme.fg("muted", findings), selected].filter(Boolean).join(dot(this.theme));
225
176
  }
226
177
 
227
- private requestRender(): void {
228
- this.tui.requestRender();
178
+ /** Key hints in the bottom border; a transient warning replaces them until the next key. */
179
+ private footer(inner: number): string {
180
+ if (this.warning) return this.theme.fg("warning", `${GLYPH.warning} ${this.warning}`);
181
+ const scroll: Hint[] = this.detailClipped ? [["pgup/pgdn", "scroll"]] : [];
182
+ const variants: Hint[][] = [
183
+ [["↑↓", "move"], ["space", "select"], ["a", "all"], ["f", "patch preview"], ["c", "PR comment"], ...scroll, ["q", "close"]],
184
+ [["↑↓", "move"], ["space", "select"], ["a", "all"], ["f", "patch"], ["c", "comment"], ...scroll, ["q", "close"]],
185
+ [["space", "select"], ["f", "patch"], ["c", "comment"], ["q", "close"]],
186
+ [["space", "select"], ["q", "close"]],
187
+ ];
188
+ const rendered = variants.map((hints) => keyHints(hints, this.theme));
189
+ return rendered.find((hints) => visibleWidth(hints) <= inner - 2) ?? rendered[rendered.length - 1] ?? "";
229
190
  }
230
191
 
231
192
  private moveCursor(delta: number): void {
232
193
  if (this.issues.length === 0) return;
233
- this.cursor = Math.min(this.issues.length - 1, Math.max(0, this.cursor + delta));
234
- this.detailScroll = 0;
235
- this.warning = undefined;
236
- this.requestRender();
237
- }
238
-
239
- private jumpTo(index: number): void {
240
- if (index < 0 || index >= this.issues.length) {
241
- this.warning = `No finding ${index + 1}.`;
242
- this.requestRender();
243
- return;
244
- }
245
- this.cursor = index;
246
- this.detailScroll = 0;
247
- this.warning = undefined;
248
- this.requestRender();
194
+ const next = Math.min(this.issues.length - 1, Math.max(0, this.cursor + delta));
195
+ if (next !== this.cursor) this.detailScroll = 0;
196
+ this.cursor = next;
249
197
  }
250
198
 
251
199
  private toggleCurrent(): void {
252
200
  const issue = this.issues[this.cursor];
253
201
  if (!issue) return;
254
- this.toggleIssue(issue.id);
255
- this.warning = undefined;
256
- this.requestRender();
202
+ if (this.selected.has(issue.id)) this.selected.delete(issue.id);
203
+ else this.selected.add(issue.id);
257
204
  }
258
205
 
259
- private toggleAllVisible(): void {
206
+ private toggleAll(): void {
260
207
  const allSelected = this.issues.length > 0 && this.issues.every((issue) => this.selected.has(issue.id));
261
208
  this.selected.clear();
262
- if (!allSelected) {
263
- for (const issue of this.issues) this.selected.add(issue.id);
264
- }
265
- this.warning = undefined;
266
- this.requestRender();
209
+ if (!allSelected) for (const issue of this.issues) this.selected.add(issue.id);
267
210
  }
268
211
 
269
- private scrollDetail(delta: number): void {
270
- this.detailScroll = Math.max(0, this.detailScroll + delta);
271
- this.warning = undefined;
272
- this.requestRender();
273
- }
274
-
275
- private finishSelected(action: "fix" | "comment"): void {
212
+ private finish(action: "fix" | "comment"): void {
276
213
  const issueIds = this.selectedIssueIds();
277
214
  if (issueIds.length === 0) {
278
- this.warning = `Select at least one finding before pressing ${action === "fix" ? "f" : "c"}.`;
279
- this.requestRender();
215
+ this.warning = `Select findings first (space or a), then press ${action === "fix" ? "f" : "c"}`;
216
+ return;
217
+ }
218
+ if (action === "comment" && !this.issues.some((issue) => this.selected.has(issue.id) && isCommentableIssue(issue))) {
219
+ this.warning = "None of the selected findings has a file and line to comment on";
280
220
  return;
281
221
  }
282
222
  this.done({ action, issueIds });
283
223
  }
284
224
 
285
- private toggleIssue(id: string): void {
286
- if (this.selected.has(id)) this.selected.delete(id);
287
- else this.selected.add(id);
288
- }
289
-
225
+ /** Selected ids in report order, which keeps follow-up prompts and comments stable. */
290
226
  private selectedIssueIds(): string[] {
291
- return this.issues.filter((issue) => this.selected.has(issue.id)).map((issue) => issue.id);
292
- }
293
- }
294
-
295
- function scrollArrows(hasAbove: boolean, hasBelow: boolean): string {
296
- return `${hasAbove ? "↑" : ""}${hasBelow ? "↓" : ""}`;
297
- }
298
-
299
- function digitJumpIndex(data: string): number | undefined {
300
- return data.length === 1 && data >= "1" && data <= "9" ? Number(data) - 1 : undefined;
301
- }
302
-
303
- function severityColor(severity: ReviewIssue["finding"]["severity"]): Parameters<Theme["fg"]>[0] {
304
- switch (severity) {
305
- case "high":
306
- return "error";
307
- case "medium":
308
- return "warning";
309
- case "low":
310
- return "muted";
227
+ return this.reportOrder.filter((issue) => this.selected.has(issue.id)).map((issue) => issue.id);
311
228
  }
312
229
  }
@@ -2,14 +2,14 @@ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-a
2
2
  import { sessionKey } from "../session-identity.ts";
3
3
  import { resolveWorkflowRunOptions, type ResolvedWorkflowRunOptions } from "../options.ts";
4
4
  import type { LoadedWorkflow } from "../types.ts";
5
- import type { WorkflowExecution } from "../workflow-execution.ts";
5
+ import type { WorkflowResultEnvelope } from "../workflow-execution.ts";
6
6
  import { handleReviewViewerAction } from "./review-actions.ts";
7
7
  import { ReviewFixBudgetLedger } from "./review-budget.ts";
8
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";
9
+ import { showReviewResultsViewer } from "./review-results-viewer.ts";
10
+ import { isReviewReport, type ReviewReport } from "./review-report.ts";
11
11
 
12
- interface RetainedCodeReviewResult {
12
+ export interface RetainedCodeReviewResult {
13
13
  readonly report: ReviewReport;
14
14
  readonly concurrency: number;
15
15
  readonly parallelSubmissionLimit?: number;
@@ -22,8 +22,8 @@ export interface ReviewSessionCoordinatorDependencies {
22
22
  ctx: ExtensionContext,
23
23
  workflow: LoadedWorkflow,
24
24
  options: ResolvedWorkflowRunOptions,
25
- ) => Promise<WorkflowExecution>;
26
- readonly publish: (execution: WorkflowExecution) => void;
25
+ ) => Promise<WorkflowResultEnvelope>;
26
+ readonly publish: (envelope: WorkflowResultEnvelope) => void;
27
27
  }
28
28
 
29
29
  /** Owns retained code-review state and every path that presents or acts on it. */
@@ -35,46 +35,39 @@ export class ReviewSessionCoordinator {
35
35
  private readonly dependencies: ReviewSessionCoordinatorDependencies,
36
36
  ) {}
37
37
 
38
+ /** Retains a code-review result for this session and returns it; any other result retains nothing. */
38
39
  remember(
39
40
  ctx: ExtensionContext,
40
- execution: WorkflowExecution,
41
+ envelope: WorkflowResultEnvelope,
41
42
  options: ResolvedWorkflowRunOptions,
42
- ): void {
43
- const name = execution.envelope.name;
44
- if (name !== "code-review") return;
43
+ ): RetainedCodeReviewResult | undefined {
44
+ if (envelope.name !== "code-review") return undefined;
45
45
  const key = sessionKey(ctx);
46
- const report = codeReviewReport(name, execution.envelope.result);
47
- if (!report) {
46
+ const report = envelope.result;
47
+ if (!isReviewReport(report)) {
48
48
  this.sessions.delete(key);
49
- return;
49
+ return undefined;
50
50
  }
51
- this.sessions.set(key, {
51
+ const retained: RetainedCodeReviewResult = {
52
52
  report,
53
53
  concurrency: options.concurrency,
54
54
  parallelSubmissionLimit: options.parallelSubmissionLimit ?? undefined,
55
55
  perf: options.perf,
56
- budget: new ReviewFixBudgetLedger(options.budget, execution.envelope.usage),
57
- });
56
+ budget: new ReviewFixBudgetLedger(options.budget, envelope.usage),
57
+ };
58
+ this.sessions.set(key, retained);
59
+ return retained;
58
60
  }
59
61
 
62
+ /** Opens the findings viewer for a result remember() just retained, when explicitly requested. */
60
63
  async present(
61
64
  ctx: ExtensionContext,
62
- execution: WorkflowExecution,
65
+ retained: RetainedCodeReviewResult | undefined,
63
66
  options: ResolvedWorkflowRunOptions,
64
67
  ): 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(sessionKey(ctx));
76
- if (!retained) return;
77
- await this.openAndHandle(ctx, retained, decision.issues);
68
+ if (!retained || retained.report.findings.length === 0) return;
69
+ if (options.resultViewer !== "open" || ctx.mode !== "tui" || !ctx.hasUI) return;
70
+ await this.openAndHandle(ctx, retained);
78
71
  }
79
72
 
80
73
  async reopen(ctx: ExtensionContext): Promise<void> {
@@ -92,18 +85,15 @@ export class ReviewSessionCoordinator {
92
85
  return;
93
86
  }
94
87
 
95
- await this.openAndHandle(ctx, retained, toReviewIssues("code-review", retained.report));
88
+ await this.openAndHandle(ctx, retained);
96
89
  }
97
90
 
98
91
  dispose(ctx: ExtensionContext): void {
99
92
  this.sessions.delete(sessionKey(ctx));
100
93
  }
101
94
 
102
- private async openAndHandle(
103
- ctx: ExtensionContext,
104
- retained: RetainedCodeReviewResult,
105
- issues: readonly ReviewIssue[],
106
- ): Promise<void> {
95
+ private async openAndHandle(ctx: ExtensionContext, retained: RetainedCodeReviewResult): Promise<void> {
96
+ const issues = toReviewIssues(retained.report);
107
97
  let action: ReviewIssueSelection | undefined;
108
98
  try {
109
99
  action = await showReviewResultsViewer(ctx, issues);
@@ -132,8 +122,8 @@ export class ReviewSessionCoordinator {
132
122
  try {
133
123
  const followUp = await handleReviewViewerAction(this.pi, ctx, action, issues, retained.report.reviewContext);
134
124
  if (!followUp) return;
135
- const execution = await this.dependencies.runFollowUp(ctx, followUp, followUpOptions(retained, ctx.signal));
136
- this.dependencies.publish(execution);
125
+ const envelope = await this.dependencies.runFollowUp(ctx, followUp, followUpOptions(retained, ctx.signal));
126
+ this.dependencies.publish(envelope);
137
127
  } finally {
138
128
  if (lease?.ok) lease.release();
139
129
  }