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.
- package/.pi/extensions/pi-workflow-engine/index.ts +106 -391
- package/.pi/extensions/pi-workflow-engine/src/advisory-challenge.ts +75 -0
- package/.pi/extensions/pi-workflow-engine/src/advisory-evidence.ts +90 -0
- package/.pi/extensions/pi-workflow-engine/src/advisory-schema.ts +20 -2
- package/.pi/extensions/pi-workflow-engine/src/agent-attempt.ts +48 -62
- package/.pi/extensions/pi-workflow-engine/src/agent-replay.ts +46 -67
- package/.pi/extensions/pi-workflow-engine/src/agent-retry.ts +14 -37
- package/.pi/extensions/pi-workflow-engine/src/agent-runner-types.ts +5 -11
- package/.pi/extensions/pi-workflow-engine/src/agent-runner.ts +15 -21
- package/.pi/extensions/pi-workflow-engine/src/agent-session-identity.ts +13 -43
- package/.pi/extensions/pi-workflow-engine/src/agent-session-providers.ts +17 -5
- package/.pi/extensions/pi-workflow-engine/src/agent-session.ts +93 -100
- package/.pi/extensions/pi-workflow-engine/src/agent-skills.ts +11 -43
- package/.pi/extensions/pi-workflow-engine/src/agent-workspace.ts +18 -18
- package/.pi/extensions/pi-workflow-engine/src/background-workflows.ts +33 -86
- package/.pi/extensions/pi-workflow-engine/src/cancellation.ts +1 -5
- package/.pi/extensions/pi-workflow-engine/src/concurrency.ts +37 -86
- package/.pi/extensions/pi-workflow-engine/src/discovery.ts +16 -23
- package/.pi/extensions/pi-workflow-engine/src/dynamax-shortcuts.ts +1 -4
- package/.pi/extensions/pi-workflow-engine/src/dynamax.ts +39 -98
- package/.pi/extensions/pi-workflow-engine/src/engine.ts +37 -80
- package/.pi/extensions/pi-workflow-engine/src/finalizers.ts +2 -10
- package/.pi/extensions/pi-workflow-engine/src/git.ts +55 -0
- package/.pi/extensions/pi-workflow-engine/src/guards.ts +8 -0
- package/.pi/extensions/pi-workflow-engine/src/identity-canonicalization.ts +7 -19
- package/.pi/extensions/pi-workflow-engine/src/inline-workflow.ts +171 -106
- package/.pi/extensions/pi-workflow-engine/src/journal.ts +1 -12
- package/.pi/extensions/pi-workflow-engine/src/model-profile-command.ts +37 -8
- package/.pi/extensions/pi-workflow-engine/src/model-profiles.ts +15 -66
- package/.pi/extensions/pi-workflow-engine/src/options.ts +97 -16
- package/.pi/extensions/pi-workflow-engine/src/perf.ts +43 -72
- package/.pi/extensions/pi-workflow-engine/src/process-runner.ts +14 -4
- package/.pi/extensions/pi-workflow-engine/src/progress-types.ts +2 -0
- package/.pi/extensions/pi-workflow-engine/src/progress.ts +79 -80
- package/.pi/extensions/pi-workflow-engine/src/provider-usage-limit.ts +28 -20
- package/.pi/extensions/pi-workflow-engine/src/replay-path-identity.ts +2 -7
- package/.pi/extensions/pi-workflow-engine/src/research-contract.ts +0 -1
- package/.pi/extensions/pi-workflow-engine/src/research-evidence.ts +27 -31
- package/.pi/extensions/pi-workflow-engine/src/resume-context.ts +128 -142
- package/.pi/extensions/pi-workflow-engine/src/review/github-pr-comments.ts +14 -31
- package/.pi/extensions/pi-workflow-engine/src/review/patch-validation.ts +119 -0
- package/.pi/extensions/pi-workflow-engine/src/review/review-actions.ts +1 -1
- package/.pi/extensions/pi-workflow-engine/src/review/review-diff-lines.ts +54 -0
- package/.pi/extensions/pi-workflow-engine/src/review/review-fix-workflow.ts +41 -7
- package/.pi/extensions/pi-workflow-engine/src/review/review-format.ts +51 -116
- package/.pi/extensions/pi-workflow-engine/src/review/review-handoff.ts +1 -1
- package/.pi/extensions/pi-workflow-engine/src/review/review-issues.ts +9 -14
- package/.pi/extensions/pi-workflow-engine/src/review/review-report.ts +3 -7
- package/.pi/extensions/pi-workflow-engine/src/review/review-results-viewer.ts +158 -241
- package/.pi/extensions/pi-workflow-engine/src/review/review-session-coordinator.ts +28 -38
- package/.pi/extensions/pi-workflow-engine/src/review/review-snapshot.ts +35 -44
- package/.pi/extensions/pi-workflow-engine/src/review-diff-target.ts +13 -14
- package/.pi/extensions/pi-workflow-engine/src/text.ts +29 -0
- package/.pi/extensions/pi-workflow-engine/src/tool-capabilities.ts +2 -1
- package/.pi/extensions/pi-workflow-engine/src/tool-source-identity.ts +16 -28
- package/.pi/extensions/pi-workflow-engine/src/tree-fingerprint.ts +34 -14
- package/.pi/extensions/pi-workflow-engine/src/types.ts +9 -0
- package/.pi/extensions/pi-workflow-engine/src/ui/kit.ts +193 -0
- package/.pi/extensions/pi-workflow-engine/src/ui/research-result-renderer.ts +177 -0
- package/.pi/extensions/pi-workflow-engine/src/ui/result-layout.ts +209 -0
- package/.pi/extensions/pi-workflow-engine/src/ui/workflow-format.ts +17 -44
- package/.pi/extensions/pi-workflow-engine/src/ui/workflow-inspector.ts +703 -235
- package/.pi/extensions/pi-workflow-engine/src/ui/workflow-picker.ts +48 -0
- package/.pi/extensions/pi-workflow-engine/src/ui/workflow-result-renderer.ts +239 -126
- package/.pi/extensions/pi-workflow-engine/src/ui/workflow-runs-browser.ts +461 -0
- package/.pi/extensions/pi-workflow-engine/src/ui/workflow-tool-renderer.ts +91 -0
- package/.pi/extensions/pi-workflow-engine/src/ui/workflow-widget.ts +316 -98
- package/.pi/extensions/pi-workflow-engine/src/usage.ts +21 -32
- package/.pi/extensions/pi-workflow-engine/src/workflow-advisory-utils.ts +178 -188
- package/.pi/extensions/pi-workflow-engine/src/workflow-execution.ts +19 -24
- package/.pi/extensions/pi-workflow-engine/src/workflow-module.ts +17 -37
- package/.pi/extensions/pi-workflow-engine/src/workflow-result-context.ts +79 -0
- package/.pi/extensions/pi-workflow-engine/src/workflow-run-background.ts +4 -14
- package/.pi/extensions/pi-workflow-engine/src/workflow-run-controller.ts +77 -63
- package/.pi/extensions/pi-workflow-engine/src/workflow-run-history.ts +42 -29
- package/.pi/extensions/pi-workflow-engine/src/workflow-run-record.ts +15 -14
- package/.pi/extensions/pi-workflow-engine/src/workflow-run-store.ts +27 -31
- package/.pi/extensions/pi-workflow-engine/src/workflow-usage-limit-scheduler.ts +1 -5
- package/.pi/extensions/pi-workflow-engine/src/workflows.ts +3 -6
- package/.pi/extensions/pi-workflow-engine/src/worktree.ts +55 -110
- package/.pi/extensions/pi-workflow-engine/workflows/code-review.ts +101 -203
- package/.pi/extensions/pi-workflow-engine/workflows/diagnose.ts +51 -187
- package/.pi/extensions/pi-workflow-engine/workflows/perf-review.ts +33 -109
- package/.pi/extensions/pi-workflow-engine/workflows/refactor-scout.ts +36 -106
- package/.pi/extensions/pi-workflow-engine/workflows/research.ts +74 -66
- package/USAGE.md +90 -17
- package/package.json +1 -2
- package/skills/workflow-code-review-actions/SKILL.md +12 -23
- package/.pi/extensions/pi-workflow-engine/src/diff-capture.ts +0 -81
- package/.pi/extensions/pi-workflow-engine/src/review/code-review-orchestration.ts +0 -49
- package/.pi/extensions/pi-workflow-engine/src/review/review-results-flow.ts +0 -52
- package/.pi/settings.json +0 -3
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import type
|
|
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(
|
|
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
|
-
|
|
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
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
return typeof issue.file === "string" && issue.file.trim().length > 0 &&
|
|
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,
|
|
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
|
-
|
|
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 {
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
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
|
|
10
|
-
const
|
|
11
|
-
const
|
|
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
|
|
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
|
-
|
|
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
|
|
35
|
-
|
|
36
|
-
const
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
this.
|
|
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
|
|
47
|
-
if (
|
|
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
|
|
97
|
-
const
|
|
98
|
-
const
|
|
99
|
-
const
|
|
100
|
-
|
|
101
|
-
|
|
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
|
-
|
|
126
|
-
|
|
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(
|
|
131
|
-
|
|
132
|
-
const
|
|
133
|
-
const
|
|
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.
|
|
136
|
-
this.theme.fg("borderMuted", "─".repeat(
|
|
137
|
-
...
|
|
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
|
-
|
|
142
|
-
|
|
143
|
-
const
|
|
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 (
|
|
156
|
-
this.listScroll = Math.
|
|
157
|
-
const
|
|
158
|
-
const
|
|
159
|
-
|
|
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
|
|
170
|
-
const cursor = index === this.cursor ? this.theme.fg("accent",
|
|
171
|
-
const
|
|
172
|
-
|
|
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
|
|
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
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
const
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
this.
|
|
199
|
-
|
|
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
|
|
205
|
-
|
|
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
|
|
214
|
-
|
|
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
|
|
224
|
-
|
|
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
|
-
|
|
228
|
-
|
|
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
|
-
|
|
234
|
-
this.detailScroll = 0;
|
|
235
|
-
this.
|
|
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.
|
|
255
|
-
this.
|
|
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
|
|
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
|
|
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
|
|
279
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
}
|