pi-workflow-engine 0.12.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 +75 -0
  3. package/.pi/extensions/pi-workflow-engine/src/advisory-evidence.ts +90 -0
  4. package/.pi/extensions/pi-workflow-engine/src/advisory-schema.ts +20 -2
  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 +18 -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 +37 -86
  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 +1 -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 +128 -142
  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 +119 -0
  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 +41 -7
  45. package/.pi/extensions/pi-workflow-engine/src/review/review-format.ts +51 -116
  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 +9 -14
  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 +9 -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 -126
  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 +178 -188
  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 +55 -110
  81. package/.pi/extensions/pi-workflow-engine/workflows/code-review.ts +101 -203
  82. package/.pi/extensions/pi-workflow-engine/workflows/diagnose.ts +51 -187
  83. package/.pi/extensions/pi-workflow-engine/workflows/perf-review.ts +33 -109
  84. package/.pi/extensions/pi-workflow-engine/workflows/refactor-scout.ts +36 -106
  85. package/.pi/extensions/pi-workflow-engine/workflows/research.ts +74 -66
  86. package/USAGE.md +90 -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 -49
  91. package/.pi/extensions/pi-workflow-engine/src/review/review-results-flow.ts +0 -52
  92. package/.pi/settings.json +0 -3
@@ -1,4 +1,5 @@
1
- import type { AdvisoryFinding, AdvisoryReport } from "../advisory-schema.ts";
1
+ import { formatAdvisoryLocation, type AdvisoryFinding, type AdvisoryReport } from "../advisory-schema.ts";
2
+ import { isFiniteNumber } from "../guards.ts";
2
3
 
3
4
  export type ReviewIssueAction = "fix" | "comment" | "close";
4
5
 
@@ -9,8 +10,6 @@ export interface ReviewIssueSelection {
9
10
 
10
11
  export interface ReviewIssue {
11
12
  readonly id: string;
12
- readonly index: number;
13
- readonly workflowName: string;
14
13
  readonly file?: string;
15
14
  readonly line?: number;
16
15
  readonly symbol?: string;
@@ -35,13 +34,11 @@ export interface SerializedReviewIssue {
35
34
  readonly recommendation: string;
36
35
  }
37
36
 
38
- export function toReviewIssues(name: string, report: Pick<AdvisoryReport, "findings">): ReviewIssue[] {
37
+ export function toReviewIssues(report: Pick<AdvisoryReport, "findings">): ReviewIssue[] {
39
38
  return report.findings.map((finding, index) => {
40
- const location = finding.locations[0];
39
+ const location = finding.reviewAnchor ?? finding.locations[0];
41
40
  return {
42
41
  id: formatIssueId(index),
43
- index,
44
- workflowName: name,
45
42
  file: location?.file,
46
43
  line: location?.line,
47
44
  symbol: location?.symbol,
@@ -52,9 +49,7 @@ export function toReviewIssues(name: string, report: Pick<AdvisoryReport, "findi
52
49
 
53
50
  export function formatIssueLocation(issue: ReviewIssue): string {
54
51
  if (!issue.file) return "(no location)";
55
- const line = issue.line != null ? `:${issue.line}` : "";
56
- const symbol = issue.symbol ? ` (${issue.symbol})` : "";
57
- return `${issue.file}${line}${symbol}`;
52
+ return formatAdvisoryLocation({ file: issue.file, line: issue.line, symbol: issue.symbol });
58
53
  }
59
54
 
60
55
  export function serializeReviewIssue(issue: ReviewIssue): SerializedReviewIssue {
@@ -76,10 +71,10 @@ export function serializeReviewIssue(issue: ReviewIssue): SerializedReviewIssue
76
71
  };
77
72
  }
78
73
 
79
- export function isCommentableIssue(
80
- issue: ReviewIssue,
81
- ): issue is ReviewIssue & { readonly file: string; readonly line: number } {
82
- return typeof issue.file === "string" && issue.file.trim().length > 0 && typeof issue.line === "number" && Number.isFinite(issue.line);
74
+ export type CommentableReviewIssue = ReviewIssue & { readonly file: string; readonly line: number };
75
+
76
+ export function isCommentableIssue(issue: ReviewIssue): issue is CommentableReviewIssue {
77
+ return typeof issue.file === "string" && issue.file.trim().length > 0 && isFiniteNumber(issue.line);
83
78
  }
84
79
 
85
80
  function formatIssueId(index: number): string {
@@ -1,7 +1,7 @@
1
1
  import { Type, type Static } from "typebox";
2
2
  import { Value } from "typebox/value";
3
3
  import { AdvisoryReportWithStatsSchema } from "../advisory-schema.ts";
4
- import { formatReviewDiffTarget, isReviewDiffTarget, ReviewDiffTargetSchema } from "../review-diff-target.ts";
4
+ import { formatReviewDiffTarget, isAllowedReviewDiffTarget, ReviewDiffTargetSchema } from "../review-diff-target.ts";
5
5
 
6
6
  /** Atomic identity of the exact diff and post-change snapshot that were reviewed. */
7
7
  export const ReviewSnapshotIdentitySchema = Type.Object({
@@ -27,13 +27,9 @@ export type ReviewSnapshotIdentity = Static<typeof ReviewSnapshotIdentitySchema>
27
27
  export type ReviewContext = Static<typeof ReviewContextSchema>;
28
28
  export type ReviewReport = Static<typeof ReviewReportSchema>;
29
29
 
30
+ /** The schema check covers the nested context; the diff target also has to round-trip through the allowlist parser. */
30
31
  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);
32
+ return Value.Check(ReviewReportSchema, value) && (value.reviewContext === undefined || isAllowedReviewDiffTarget(value.reviewContext.diffTarget));
37
33
  }
38
34
 
39
35
  /** Prompt-facing context retains the canonical display command without persisting duplicate identity. */
@@ -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
  }