pi-workflow-engine 0.10.3 → 0.12.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 +418 -213
- package/.pi/extensions/pi-workflow-engine/src/advisory-schema.ts +11 -4
- package/.pi/extensions/pi-workflow-engine/src/agent-attempt.ts +147 -0
- package/.pi/extensions/pi-workflow-engine/src/agent-limits.ts +35 -0
- package/.pi/extensions/pi-workflow-engine/src/agent-replay.ts +399 -0
- package/.pi/extensions/pi-workflow-engine/src/agent-retry.ts +116 -0
- package/.pi/extensions/pi-workflow-engine/src/agent-runner-types.ts +91 -0
- package/.pi/extensions/pi-workflow-engine/src/agent-runner.ts +154 -530
- package/.pi/extensions/pi-workflow-engine/src/agent-session-identity.ts +256 -0
- package/.pi/extensions/pi-workflow-engine/src/agent-session-providers.ts +50 -0
- package/.pi/extensions/pi-workflow-engine/src/agent-session.ts +368 -0
- package/.pi/extensions/pi-workflow-engine/src/agent-skills.ts +87 -39
- package/.pi/extensions/pi-workflow-engine/src/agent-workspace.ts +89 -0
- package/.pi/extensions/pi-workflow-engine/src/background-workflow-tool.ts +75 -0
- package/.pi/extensions/pi-workflow-engine/src/background-workflows.ts +492 -0
- package/.pi/extensions/pi-workflow-engine/src/budget.ts +10 -3
- package/.pi/extensions/pi-workflow-engine/src/cancellation.ts +55 -3
- package/.pi/extensions/pi-workflow-engine/src/command-completions.ts +36 -0
- package/.pi/extensions/pi-workflow-engine/src/concurrency.ts +230 -61
- package/.pi/extensions/pi-workflow-engine/src/diff-capture.ts +38 -55
- package/.pi/extensions/pi-workflow-engine/src/discovery.ts +70 -17
- package/.pi/extensions/pi-workflow-engine/src/dynamax-shortcuts.ts +59 -13
- package/.pi/extensions/pi-workflow-engine/src/dynamax.ts +123 -63
- package/.pi/extensions/pi-workflow-engine/src/engine.ts +357 -75
- package/.pi/extensions/pi-workflow-engine/src/filesystem-error.ts +4 -0
- package/.pi/extensions/pi-workflow-engine/src/finalizers.ts +66 -0
- package/.pi/extensions/pi-workflow-engine/src/identity-canonicalization.ts +138 -0
- package/.pi/extensions/pi-workflow-engine/src/identity-fingerprint.ts +15 -0
- package/.pi/extensions/pi-workflow-engine/src/inline-workflow.ts +10 -5
- package/.pi/extensions/pi-workflow-engine/src/journal.ts +156 -66
- package/.pi/extensions/pi-workflow-engine/src/model-profile-command.ts +167 -0
- package/.pi/extensions/pi-workflow-engine/src/model-profiles.ts +310 -0
- package/.pi/extensions/pi-workflow-engine/src/options.ts +96 -6
- package/.pi/extensions/pi-workflow-engine/src/pi-compat.ts +32 -0
- package/.pi/extensions/pi-workflow-engine/src/process-runner.ts +203 -39
- package/.pi/extensions/pi-workflow-engine/src/progress-types.ts +50 -0
- package/.pi/extensions/pi-workflow-engine/src/progress.ts +74 -122
- package/.pi/extensions/pi-workflow-engine/src/provider-usage-limit.ts +189 -0
- package/.pi/extensions/pi-workflow-engine/src/replay-path-identity.ts +48 -0
- package/.pi/extensions/pi-workflow-engine/src/research-contract.ts +89 -0
- package/.pi/extensions/pi-workflow-engine/src/research-evidence.ts +289 -0
- package/.pi/extensions/pi-workflow-engine/src/resume-context.ts +701 -0
- package/.pi/extensions/pi-workflow-engine/src/review/code-review-orchestration.ts +49 -0
- package/.pi/extensions/pi-workflow-engine/src/review/github-pr-comments.ts +75 -16
- package/.pi/extensions/pi-workflow-engine/src/review/review-actions.ts +83 -17
- package/.pi/extensions/pi-workflow-engine/src/review/review-budget.ts +45 -0
- package/.pi/extensions/pi-workflow-engine/src/review/review-fix-workflow.ts +117 -0
- package/.pi/extensions/pi-workflow-engine/src/review/review-handoff.ts +14 -66
- package/.pi/extensions/pi-workflow-engine/src/review/review-issues.ts +38 -12
- package/.pi/extensions/pi-workflow-engine/src/review/review-report.ts +42 -0
- package/.pi/extensions/pi-workflow-engine/src/review/review-results-flow.ts +18 -22
- package/.pi/extensions/pi-workflow-engine/src/review/review-results-viewer.ts +128 -56
- package/.pi/extensions/pi-workflow-engine/src/review/review-session-coordinator.ts +153 -0
- package/.pi/extensions/pi-workflow-engine/src/review/review-snapshot.ts +294 -0
- package/.pi/extensions/pi-workflow-engine/src/review-diff-target.ts +130 -0
- package/.pi/extensions/pi-workflow-engine/src/session-identity.ts +6 -0
- package/.pi/extensions/pi-workflow-engine/src/structured-output.ts +32 -0
- package/.pi/extensions/pi-workflow-engine/src/tool-capabilities.ts +44 -0
- package/.pi/extensions/pi-workflow-engine/src/tool-source-identity.ts +150 -0
- package/.pi/extensions/pi-workflow-engine/src/tree-fingerprint.ts +401 -0
- package/.pi/extensions/pi-workflow-engine/src/types.ts +87 -25
- package/.pi/extensions/pi-workflow-engine/src/ui/dynamax-editor-decoration.ts +220 -0
- package/.pi/extensions/pi-workflow-engine/src/ui/workflow-format.ts +26 -6
- package/.pi/extensions/pi-workflow-engine/src/ui/workflow-inspector.ts +88 -46
- package/.pi/extensions/pi-workflow-engine/src/ui/workflow-result-renderer.ts +7 -70
- package/.pi/extensions/pi-workflow-engine/src/ui/workflow-viewer-layout.ts +51 -0
- package/.pi/extensions/pi-workflow-engine/src/ui/workflow-widget.ts +12 -67
- package/.pi/extensions/pi-workflow-engine/src/unknown-error.ts +8 -0
- package/.pi/extensions/pi-workflow-engine/src/usage.ts +124 -16
- package/.pi/extensions/pi-workflow-engine/src/workflow-advisory-utils.ts +23 -3
- package/.pi/extensions/pi-workflow-engine/src/workflow-execution.ts +121 -0
- package/.pi/extensions/pi-workflow-engine/src/workflow-module.ts +19 -1
- package/.pi/extensions/pi-workflow-engine/src/workflow-run-background.ts +72 -0
- package/.pi/extensions/pi-workflow-engine/src/workflow-run-controller.ts +354 -0
- package/.pi/extensions/pi-workflow-engine/src/workflow-run-history.ts +162 -0
- package/.pi/extensions/pi-workflow-engine/src/workflow-run-record.ts +632 -0
- package/.pi/extensions/pi-workflow-engine/src/workflow-run-store.ts +172 -0
- package/.pi/extensions/pi-workflow-engine/src/workflow-usage-limit-scheduler.ts +80 -0
- package/.pi/extensions/pi-workflow-engine/src/workflows.ts +30 -3
- package/.pi/extensions/pi-workflow-engine/src/worktree.ts +321 -43
- package/.pi/extensions/pi-workflow-engine/workflows/code-review.ts +62 -64
- package/.pi/extensions/pi-workflow-engine/workflows/diagnose.ts +17 -17
- package/.pi/extensions/pi-workflow-engine/workflows/perf-review.ts +15 -16
- package/.pi/extensions/pi-workflow-engine/workflows/refactor-scout.ts +15 -15
- package/.pi/extensions/pi-workflow-engine/workflows/research.ts +170 -0
- package/README.md +82 -207
- package/USAGE.md +295 -21
- package/package.json +9 -9
|
@@ -7,14 +7,6 @@ export interface ReviewIssueSelection {
|
|
|
7
7
|
readonly issueIds: readonly string[];
|
|
8
8
|
}
|
|
9
9
|
|
|
10
|
-
export interface ReviewContext {
|
|
11
|
-
readonly workflowName: string;
|
|
12
|
-
readonly target: string;
|
|
13
|
-
readonly diffCommand: string;
|
|
14
|
-
readonly files: readonly string[];
|
|
15
|
-
readonly summary?: string;
|
|
16
|
-
}
|
|
17
|
-
|
|
18
10
|
export interface ReviewIssue {
|
|
19
11
|
readonly id: string;
|
|
20
12
|
readonly index: number;
|
|
@@ -25,9 +17,22 @@ export interface ReviewIssue {
|
|
|
25
17
|
readonly finding: AdvisoryFinding;
|
|
26
18
|
}
|
|
27
19
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
readonly
|
|
20
|
+
/** Stable prompt-facing representation shared by review follow-up actions. */
|
|
21
|
+
export interface SerializedReviewIssue {
|
|
22
|
+
readonly id: string;
|
|
23
|
+
readonly summary: string;
|
|
24
|
+
readonly category: string;
|
|
25
|
+
readonly severity: string;
|
|
26
|
+
readonly confidence: string;
|
|
27
|
+
readonly location: {
|
|
28
|
+
readonly file?: string;
|
|
29
|
+
readonly line?: number;
|
|
30
|
+
readonly symbol?: string;
|
|
31
|
+
readonly display: string;
|
|
32
|
+
};
|
|
33
|
+
readonly impact: string;
|
|
34
|
+
readonly evidence: readonly string[];
|
|
35
|
+
readonly recommendation: string;
|
|
31
36
|
}
|
|
32
37
|
|
|
33
38
|
export function toReviewIssues(name: string, report: Pick<AdvisoryReport, "findings">): ReviewIssue[] {
|
|
@@ -52,7 +57,28 @@ export function formatIssueLocation(issue: ReviewIssue): string {
|
|
|
52
57
|
return `${issue.file}${line}${symbol}`;
|
|
53
58
|
}
|
|
54
59
|
|
|
55
|
-
export function
|
|
60
|
+
export function serializeReviewIssue(issue: ReviewIssue): SerializedReviewIssue {
|
|
61
|
+
return {
|
|
62
|
+
id: issue.id,
|
|
63
|
+
summary: issue.finding.summary,
|
|
64
|
+
category: issue.finding.category,
|
|
65
|
+
severity: issue.finding.severity,
|
|
66
|
+
confidence: issue.finding.confidence,
|
|
67
|
+
location: {
|
|
68
|
+
file: issue.file,
|
|
69
|
+
line: issue.line,
|
|
70
|
+
symbol: issue.symbol,
|
|
71
|
+
display: formatIssueLocation(issue),
|
|
72
|
+
},
|
|
73
|
+
impact: issue.finding.impact,
|
|
74
|
+
evidence: issue.finding.evidence,
|
|
75
|
+
recommendation: issue.finding.recommendation,
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function isCommentableIssue(
|
|
80
|
+
issue: ReviewIssue,
|
|
81
|
+
): issue is ReviewIssue & { readonly file: string; readonly line: number } {
|
|
56
82
|
return typeof issue.file === "string" && issue.file.trim().length > 0 && typeof issue.line === "number" && Number.isFinite(issue.line);
|
|
57
83
|
}
|
|
58
84
|
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { Type, type Static } from "typebox";
|
|
2
|
+
import { Value } from "typebox/value";
|
|
3
|
+
import { AdvisoryReportWithStatsSchema } from "../advisory-schema.ts";
|
|
4
|
+
import { formatReviewDiffTarget, isReviewDiffTarget, ReviewDiffTargetSchema } from "../review-diff-target.ts";
|
|
5
|
+
|
|
6
|
+
/** Atomic identity of the exact diff and post-change snapshot that were reviewed. */
|
|
7
|
+
export const ReviewSnapshotIdentitySchema = Type.Object({
|
|
8
|
+
diffFingerprint: Type.String({ pattern: "^[0-9a-fA-F]{64}$" }),
|
|
9
|
+
baselineFingerprint: Type.String({ pattern: "^[0-9a-fA-F]{64}$" }),
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
export const ReviewContextSchema = Type.Object({
|
|
13
|
+
workflowName: Type.String(),
|
|
14
|
+
target: Type.String(),
|
|
15
|
+
diffTarget: ReviewDiffTargetSchema,
|
|
16
|
+
files: Type.Array(Type.String()),
|
|
17
|
+
summary: Type.Optional(Type.String()),
|
|
18
|
+
snapshot: Type.Optional(ReviewSnapshotIdentitySchema),
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
export const ReviewReportSchema = Type.Object({
|
|
22
|
+
...AdvisoryReportWithStatsSchema.properties,
|
|
23
|
+
reviewContext: Type.Optional(ReviewContextSchema),
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
export type ReviewSnapshotIdentity = Static<typeof ReviewSnapshotIdentitySchema>;
|
|
27
|
+
export type ReviewContext = Static<typeof ReviewContextSchema>;
|
|
28
|
+
export type ReviewReport = Static<typeof ReviewReportSchema>;
|
|
29
|
+
|
|
30
|
+
export function isReviewReport(value: unknown): value is ReviewReport {
|
|
31
|
+
if (!Value.Check(ReviewReportSchema, value)) return false;
|
|
32
|
+
return value.reviewContext === undefined || isReviewContext(value.reviewContext);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function isReviewContext(value: unknown): value is ReviewContext {
|
|
36
|
+
return Value.Check(ReviewContextSchema, value) && isReviewDiffTarget(value.diffTarget);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Prompt-facing context retains the canonical display command without persisting duplicate identity. */
|
|
40
|
+
export function serializeReviewContext(context: ReviewContext | undefined): (ReviewContext & { readonly diffCommand: string }) | undefined {
|
|
41
|
+
return context ? { ...context, diffCommand: formatReviewDiffTarget(context.diffTarget) } : undefined;
|
|
42
|
+
}
|
|
@@ -1,15 +1,16 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import type { WorkflowRunOptions } from "../types.ts";
|
|
3
|
-
import { isAdvisoryReport, type AdvisoryWorkflowResult } from "../ui/workflow-result-renderer.ts";
|
|
4
3
|
import { toReviewIssues, type ReviewIssue, type ReviewIssueSelection } from "./review-issues.ts";
|
|
4
|
+
import { isReviewReport, type ReviewReport } from "./review-report.ts";
|
|
5
5
|
import { ReviewResultsViewer } from "./review-results-viewer.ts";
|
|
6
|
+
import { WORKFLOW_VIEWER_OVERLAY_OPTIONS } from "../ui/workflow-viewer-layout.ts";
|
|
6
7
|
|
|
7
8
|
export type ReviewResultsPresentationDecision =
|
|
8
9
|
| {
|
|
9
10
|
readonly kind: "send";
|
|
10
|
-
readonly reason: "
|
|
11
|
+
readonly reason: "not-code-review" | "not-advisory" | "no-findings" | "disabled" | "not-tui" | "not-requested";
|
|
11
12
|
}
|
|
12
|
-
| { readonly kind: "open"; readonly
|
|
13
|
+
| { readonly kind: "open"; readonly issues: readonly ReviewIssue[] };
|
|
13
14
|
|
|
14
15
|
export interface ReviewResultsDecisionInput {
|
|
15
16
|
readonly workflowName: string;
|
|
@@ -17,40 +18,35 @@ export interface ReviewResultsDecisionInput {
|
|
|
17
18
|
readonly mode?: string;
|
|
18
19
|
readonly hasUI: boolean;
|
|
19
20
|
readonly resultViewer?: WorkflowRunOptions["resultViewer"];
|
|
20
|
-
readonly invocationKind?: "command" | "tool";
|
|
21
21
|
}
|
|
22
22
|
|
|
23
|
-
export
|
|
24
|
-
|
|
23
|
+
export function codeReviewReport(workflowName: string, result: unknown): ReviewReport | undefined {
|
|
24
|
+
return workflowName === "code-review" && isReviewReport(result) ? result : undefined;
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
-
export
|
|
28
|
-
|
|
29
|
-
return typeof candidate === "string" ? candidate : undefined;
|
|
27
|
+
export interface ReviewResultsViewerContext {
|
|
28
|
+
readonly ui: Pick<ExtensionContext["ui"], "custom">;
|
|
30
29
|
}
|
|
31
30
|
|
|
32
31
|
export function decideReviewResultsPresentation(input: ReviewResultsDecisionInput): ReviewResultsPresentationDecision {
|
|
33
|
-
if (input.invocationKind === "tool") return { kind: "send", reason: "tool-invocation" };
|
|
34
32
|
if (input.workflowName !== "code-review") return { kind: "send", reason: "not-code-review" };
|
|
35
|
-
|
|
36
|
-
if (
|
|
33
|
+
const report = codeReviewReport(input.workflowName, input.result);
|
|
34
|
+
if (!report) return { kind: "send", reason: "not-advisory" };
|
|
35
|
+
if (report.findings.length === 0) return { kind: "send", reason: "no-findings" };
|
|
37
36
|
if (input.resultViewer === "skip") return { kind: "send", reason: "disabled" };
|
|
38
37
|
if (input.mode !== "tui" || !input.hasUI) return { kind: "send", reason: "not-tui" };
|
|
39
38
|
|
|
40
39
|
if (input.resultViewer !== "open") return { kind: "send", reason: "not-requested" };
|
|
41
40
|
|
|
42
|
-
|
|
43
|
-
return { kind: "open", report: input.result, issues, findingCount: issues.length };
|
|
41
|
+
return { kind: "open", issues: toReviewIssues(input.workflowName, report) };
|
|
44
42
|
}
|
|
45
43
|
|
|
46
|
-
export async function
|
|
44
|
+
export async function showReviewResultsViewer(
|
|
47
45
|
ctx: ReviewResultsViewerContext,
|
|
48
|
-
|
|
49
|
-
): Promise<ReviewIssueSelection
|
|
50
|
-
if (decision.kind !== "open") return undefined;
|
|
51
|
-
|
|
46
|
+
issues: readonly ReviewIssue[],
|
|
47
|
+
): Promise<ReviewIssueSelection> {
|
|
52
48
|
return await ctx.ui.custom<ReviewIssueSelection>(
|
|
53
|
-
(tui, theme, _keybindings, done) => new ReviewResultsViewer(
|
|
54
|
-
|
|
49
|
+
(tui, theme, _keybindings, done) => new ReviewResultsViewer(issues, "code-review", tui, theme, done),
|
|
50
|
+
WORKFLOW_VIEWER_OVERLAY_OPTIONS,
|
|
55
51
|
);
|
|
56
52
|
}
|
|
@@ -1,18 +1,21 @@
|
|
|
1
1
|
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
2
|
-
import { matchesKey,
|
|
2
|
+
import { matchesKey, type Component, type TUI } from "@earendil-works/pi-tui";
|
|
3
3
|
import { renderIssueDetailLines } from "./review-format.ts";
|
|
4
4
|
import { formatIssueLocation, type ReviewIssue, type ReviewIssueSelection } from "./review-issues.ts";
|
|
5
5
|
import { truncateDisplay } from "../ui/workflow-format.ts";
|
|
6
|
+
import { fitWorkflowViewerRow, fitWorkflowViewerRows, workflowViewerHeight } from "../ui/workflow-viewer-layout.ts";
|
|
6
7
|
|
|
7
|
-
const MIN_WIDTH = 40;
|
|
8
8
|
const SPLIT_WIDTH = 96;
|
|
9
9
|
const LIST_MIN_WIDTH = 34;
|
|
10
10
|
const LIST_MAX_WIDTH = 48;
|
|
11
11
|
const DETAIL_SCROLL_STEP = 5;
|
|
12
12
|
|
|
13
|
+
type ViewerTui = Pick<TUI, "requestRender" | "terminal">;
|
|
14
|
+
|
|
13
15
|
export class ReviewResultsViewer implements Component {
|
|
14
16
|
private readonly selected = new Set<string>();
|
|
15
17
|
private cursor = 0;
|
|
18
|
+
private listScroll = 0;
|
|
16
19
|
private detailScroll = 0;
|
|
17
20
|
private detailsExpanded = true;
|
|
18
21
|
private warning: string | undefined;
|
|
@@ -20,18 +23,23 @@ export class ReviewResultsViewer implements Component {
|
|
|
20
23
|
constructor(
|
|
21
24
|
private readonly issues: readonly ReviewIssue[],
|
|
22
25
|
private readonly workflowName: string,
|
|
26
|
+
private readonly tui: ViewerTui,
|
|
23
27
|
private readonly theme: Theme,
|
|
24
|
-
private readonly requestRender: () => void,
|
|
25
28
|
private readonly done: (result: ReviewIssueSelection) => void,
|
|
26
29
|
) {}
|
|
27
30
|
|
|
28
31
|
invalidate(): void {}
|
|
29
32
|
|
|
30
33
|
render(width: number): string[] {
|
|
31
|
-
const outerWidth = Math.max(
|
|
32
|
-
const
|
|
33
|
-
const
|
|
34
|
-
|
|
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
|
+
];
|
|
35
43
|
}
|
|
36
44
|
|
|
37
45
|
handleInput(data: string): void {
|
|
@@ -85,73 +93,139 @@ export class ReviewResultsViewer implements Component {
|
|
|
85
93
|
}
|
|
86
94
|
}
|
|
87
95
|
|
|
88
|
-
private renderBody(width: number): string[] {
|
|
89
|
-
const
|
|
90
|
-
const
|
|
91
|
-
const
|
|
92
|
-
|
|
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
|
+
}
|
|
93
108
|
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
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
|
+
];
|
|
97
117
|
}
|
|
98
118
|
|
|
99
|
-
private renderSplit(width: number): string[] {
|
|
100
|
-
const listWidth = Math.min(LIST_MAX_WIDTH, Math.max(LIST_MIN_WIDTH, Math.floor(width * 0.
|
|
101
|
-
const detailWidth = Math.max(
|
|
102
|
-
const list = this.
|
|
103
|
-
const details = this.
|
|
104
|
-
const
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
}
|
|
109
|
-
return lines;
|
|
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);
|
|
124
|
+
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
|
+
);
|
|
110
128
|
}
|
|
111
129
|
|
|
112
|
-
private renderStacked(width: number): string[] {
|
|
113
|
-
|
|
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;
|
|
134
|
+
return [
|
|
135
|
+
...this.renderIssuePane(width, listHeight),
|
|
136
|
+
this.theme.fg("borderMuted", "─".repeat(width)),
|
|
137
|
+
...this.renderDetailPane(width, detailHeight),
|
|
138
|
+
];
|
|
114
139
|
}
|
|
115
140
|
|
|
116
|
-
private
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
return truncateDisplay(row, width);
|
|
123
|
-
});
|
|
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)];
|
|
124
147
|
}
|
|
125
148
|
|
|
126
|
-
private
|
|
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);
|
|
154
|
+
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);
|
|
167
|
+
}
|
|
168
|
+
|
|
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);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
private renderDetailPane(width: number, height: number): string[] {
|
|
178
|
+
if (height <= 0) return [];
|
|
127
179
|
const issue = this.issues[this.cursor];
|
|
128
|
-
if (!issue) return [this.theme.fg("dim", "No finding selected.")];
|
|
129
|
-
|
|
130
|
-
const
|
|
131
|
-
const
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
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);
|
|
137
202
|
}
|
|
138
203
|
|
|
139
204
|
private headerLine(width: number): string {
|
|
140
205
|
const count = `${this.issues.length} finding${this.issues.length === 1 ? "" : "s"}`;
|
|
141
206
|
const selected = `${this.selected.size} selected`;
|
|
142
|
-
return truncateDisplay(
|
|
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
|
+
);
|
|
143
211
|
}
|
|
144
212
|
|
|
145
213
|
private helpLine(width: number): string {
|
|
146
|
-
|
|
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
|
+
);
|
|
147
221
|
}
|
|
148
222
|
|
|
149
|
-
private
|
|
150
|
-
return
|
|
223
|
+
private borderedRow(content: string, width: number): string {
|
|
224
|
+
return `${this.theme.fg("border", "│")} ${fitWorkflowViewerRow(content, width)} ${this.theme.fg("border", "│")}`;
|
|
151
225
|
}
|
|
152
226
|
|
|
153
|
-
private
|
|
154
|
-
|
|
227
|
+
private requestRender(): void {
|
|
228
|
+
this.tui.requestRender();
|
|
155
229
|
}
|
|
156
230
|
|
|
157
231
|
private moveCursor(delta: number): void {
|
|
@@ -218,10 +292,8 @@ export class ReviewResultsViewer implements Component {
|
|
|
218
292
|
}
|
|
219
293
|
}
|
|
220
294
|
|
|
221
|
-
function
|
|
222
|
-
|
|
223
|
-
const padding = Math.max(0, width - visibleWidth(truncated));
|
|
224
|
-
return `${truncated}${" ".repeat(padding)}`;
|
|
295
|
+
function scrollArrows(hasAbove: boolean, hasBelow: boolean): string {
|
|
296
|
+
return `${hasAbove ? "↑" : ""}${hasBelow ? "↓" : ""}`;
|
|
225
297
|
}
|
|
226
298
|
|
|
227
299
|
function digitJumpIndex(data: string): number | undefined {
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { sessionKey } from "../session-identity.ts";
|
|
3
|
+
import { resolveWorkflowRunOptions, type ResolvedWorkflowRunOptions } from "../options.ts";
|
|
4
|
+
import type { LoadedWorkflow } from "../types.ts";
|
|
5
|
+
import type { WorkflowExecution } from "../workflow-execution.ts";
|
|
6
|
+
import { handleReviewViewerAction } from "./review-actions.ts";
|
|
7
|
+
import { ReviewFixBudgetLedger } from "./review-budget.ts";
|
|
8
|
+
import { toReviewIssues, type ReviewIssue, type ReviewIssueSelection } from "./review-issues.ts";
|
|
9
|
+
import { codeReviewReport, decideReviewResultsPresentation, showReviewResultsViewer } from "./review-results-flow.ts";
|
|
10
|
+
import type { ReviewReport } from "./review-report.ts";
|
|
11
|
+
|
|
12
|
+
interface RetainedCodeReviewResult {
|
|
13
|
+
readonly report: ReviewReport;
|
|
14
|
+
readonly concurrency: number;
|
|
15
|
+
readonly parallelSubmissionLimit?: number;
|
|
16
|
+
readonly perf: boolean;
|
|
17
|
+
readonly budget: ReviewFixBudgetLedger;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface ReviewSessionCoordinatorDependencies {
|
|
21
|
+
readonly runFollowUp: (
|
|
22
|
+
ctx: ExtensionContext,
|
|
23
|
+
workflow: LoadedWorkflow,
|
|
24
|
+
options: ResolvedWorkflowRunOptions,
|
|
25
|
+
) => Promise<WorkflowExecution>;
|
|
26
|
+
readonly publish: (execution: WorkflowExecution) => void;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Owns retained code-review state and every path that presents or acts on it. */
|
|
30
|
+
export class ReviewSessionCoordinator {
|
|
31
|
+
private readonly sessions = new Map<string, RetainedCodeReviewResult>();
|
|
32
|
+
|
|
33
|
+
constructor(
|
|
34
|
+
private readonly pi: Pick<ExtensionAPI, "sendUserMessage" | "exec">,
|
|
35
|
+
private readonly dependencies: ReviewSessionCoordinatorDependencies,
|
|
36
|
+
) {}
|
|
37
|
+
|
|
38
|
+
remember(
|
|
39
|
+
ctx: ExtensionContext,
|
|
40
|
+
execution: WorkflowExecution,
|
|
41
|
+
options: ResolvedWorkflowRunOptions,
|
|
42
|
+
): void {
|
|
43
|
+
const name = execution.envelope.name;
|
|
44
|
+
if (name !== "code-review") return;
|
|
45
|
+
const key = sessionKey(ctx);
|
|
46
|
+
const report = codeReviewReport(name, execution.envelope.result);
|
|
47
|
+
if (!report) {
|
|
48
|
+
this.sessions.delete(key);
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
this.sessions.set(key, {
|
|
52
|
+
report,
|
|
53
|
+
concurrency: options.concurrency,
|
|
54
|
+
parallelSubmissionLimit: options.parallelSubmissionLimit ?? undefined,
|
|
55
|
+
perf: options.perf,
|
|
56
|
+
budget: new ReviewFixBudgetLedger(options.budget, execution.envelope.usage),
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async present(
|
|
61
|
+
ctx: ExtensionContext,
|
|
62
|
+
execution: WorkflowExecution,
|
|
63
|
+
options: ResolvedWorkflowRunOptions,
|
|
64
|
+
): Promise<void> {
|
|
65
|
+
const name = execution.envelope.name;
|
|
66
|
+
const decision = decideReviewResultsPresentation({
|
|
67
|
+
workflowName: name,
|
|
68
|
+
result: execution.envelope.result,
|
|
69
|
+
mode: ctx.mode,
|
|
70
|
+
hasUI: ctx.hasUI,
|
|
71
|
+
resultViewer: options.resultViewer,
|
|
72
|
+
});
|
|
73
|
+
if (decision.kind !== "open") return;
|
|
74
|
+
|
|
75
|
+
const retained = this.sessions.get(sessionKey(ctx));
|
|
76
|
+
if (!retained) return;
|
|
77
|
+
await this.openAndHandle(ctx, retained, decision.issues);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async reopen(ctx: ExtensionContext): Promise<void> {
|
|
81
|
+
if (!ctx.hasUI || ctx.mode !== "tui") {
|
|
82
|
+
ctx.ui.notify("Code-review results viewer requires the TUI", "warning");
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
const retained = this.sessions.get(sessionKey(ctx));
|
|
86
|
+
if (!retained) {
|
|
87
|
+
ctx.ui.notify("No code-review result is available yet. Run /workflow code-review first.", "warning");
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
if (retained.report.findings.length === 0) {
|
|
91
|
+
ctx.ui.notify("The last code review had no findings", "info");
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
await this.openAndHandle(ctx, retained, toReviewIssues("code-review", retained.report));
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
dispose(ctx: ExtensionContext): void {
|
|
99
|
+
this.sessions.delete(sessionKey(ctx));
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
private async openAndHandle(
|
|
103
|
+
ctx: ExtensionContext,
|
|
104
|
+
retained: RetainedCodeReviewResult,
|
|
105
|
+
issues: readonly ReviewIssue[],
|
|
106
|
+
): Promise<void> {
|
|
107
|
+
let action: ReviewIssueSelection | undefined;
|
|
108
|
+
try {
|
|
109
|
+
action = await showReviewResultsViewer(ctx, issues);
|
|
110
|
+
} catch {
|
|
111
|
+
ctx.ui.notify("Review completed, but the findings viewer could not be opened.", "warning");
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
await this.runAction(ctx, retained, action, issues);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
private async runAction(
|
|
118
|
+
ctx: ExtensionContext,
|
|
119
|
+
retained: RetainedCodeReviewResult,
|
|
120
|
+
action: ReviewIssueSelection | undefined,
|
|
121
|
+
issues: readonly ReviewIssue[],
|
|
122
|
+
): Promise<void> {
|
|
123
|
+
const lease = action?.action === "fix" ? retained.budget.acquire() : undefined;
|
|
124
|
+
if (lease && !lease.ok) {
|
|
125
|
+
const message = lease.reason === "exhausted"
|
|
126
|
+
? "Review-fix previews are unavailable because this review's output-token budget is exhausted."
|
|
127
|
+
: "A review-fix preview is already running for this review.";
|
|
128
|
+
ctx.ui.notify(message, "warning");
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
try {
|
|
133
|
+
const followUp = await handleReviewViewerAction(this.pi, ctx, action, issues, retained.report.reviewContext);
|
|
134
|
+
if (!followUp) return;
|
|
135
|
+
const execution = await this.dependencies.runFollowUp(ctx, followUp, followUpOptions(retained, ctx.signal));
|
|
136
|
+
this.dependencies.publish(execution);
|
|
137
|
+
} finally {
|
|
138
|
+
if (lease?.ok) lease.release();
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function followUpOptions(retained: RetainedCodeReviewResult, signal: AbortSignal | undefined): ResolvedWorkflowRunOptions {
|
|
144
|
+
return resolveWorkflowRunOptions({
|
|
145
|
+
concurrency: retained.concurrency,
|
|
146
|
+
parallelSubmissionLimit: retained.parallelSubmissionLimit,
|
|
147
|
+
budget: retained.budget.remaining,
|
|
148
|
+
perf: retained.perf,
|
|
149
|
+
resultViewer: "skip",
|
|
150
|
+
signal,
|
|
151
|
+
onUsageSnapshot: (usage) => retained.budget.record(usage),
|
|
152
|
+
}, {});
|
|
153
|
+
}
|