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.
Files changed (88) hide show
  1. package/.pi/extensions/pi-workflow-engine/index.ts +418 -213
  2. package/.pi/extensions/pi-workflow-engine/src/advisory-schema.ts +11 -4
  3. package/.pi/extensions/pi-workflow-engine/src/agent-attempt.ts +147 -0
  4. package/.pi/extensions/pi-workflow-engine/src/agent-limits.ts +35 -0
  5. package/.pi/extensions/pi-workflow-engine/src/agent-replay.ts +399 -0
  6. package/.pi/extensions/pi-workflow-engine/src/agent-retry.ts +116 -0
  7. package/.pi/extensions/pi-workflow-engine/src/agent-runner-types.ts +91 -0
  8. package/.pi/extensions/pi-workflow-engine/src/agent-runner.ts +154 -530
  9. package/.pi/extensions/pi-workflow-engine/src/agent-session-identity.ts +256 -0
  10. package/.pi/extensions/pi-workflow-engine/src/agent-session-providers.ts +50 -0
  11. package/.pi/extensions/pi-workflow-engine/src/agent-session.ts +368 -0
  12. package/.pi/extensions/pi-workflow-engine/src/agent-skills.ts +87 -39
  13. package/.pi/extensions/pi-workflow-engine/src/agent-workspace.ts +89 -0
  14. package/.pi/extensions/pi-workflow-engine/src/background-workflow-tool.ts +75 -0
  15. package/.pi/extensions/pi-workflow-engine/src/background-workflows.ts +492 -0
  16. package/.pi/extensions/pi-workflow-engine/src/budget.ts +10 -3
  17. package/.pi/extensions/pi-workflow-engine/src/cancellation.ts +55 -3
  18. package/.pi/extensions/pi-workflow-engine/src/command-completions.ts +36 -0
  19. package/.pi/extensions/pi-workflow-engine/src/concurrency.ts +230 -61
  20. package/.pi/extensions/pi-workflow-engine/src/diff-capture.ts +38 -55
  21. package/.pi/extensions/pi-workflow-engine/src/discovery.ts +70 -17
  22. package/.pi/extensions/pi-workflow-engine/src/dynamax-shortcuts.ts +59 -13
  23. package/.pi/extensions/pi-workflow-engine/src/dynamax.ts +123 -63
  24. package/.pi/extensions/pi-workflow-engine/src/engine.ts +357 -75
  25. package/.pi/extensions/pi-workflow-engine/src/filesystem-error.ts +4 -0
  26. package/.pi/extensions/pi-workflow-engine/src/finalizers.ts +66 -0
  27. package/.pi/extensions/pi-workflow-engine/src/identity-canonicalization.ts +138 -0
  28. package/.pi/extensions/pi-workflow-engine/src/identity-fingerprint.ts +15 -0
  29. package/.pi/extensions/pi-workflow-engine/src/inline-workflow.ts +10 -5
  30. package/.pi/extensions/pi-workflow-engine/src/journal.ts +156 -66
  31. package/.pi/extensions/pi-workflow-engine/src/model-profile-command.ts +167 -0
  32. package/.pi/extensions/pi-workflow-engine/src/model-profiles.ts +310 -0
  33. package/.pi/extensions/pi-workflow-engine/src/options.ts +96 -6
  34. package/.pi/extensions/pi-workflow-engine/src/pi-compat.ts +32 -0
  35. package/.pi/extensions/pi-workflow-engine/src/process-runner.ts +203 -39
  36. package/.pi/extensions/pi-workflow-engine/src/progress-types.ts +50 -0
  37. package/.pi/extensions/pi-workflow-engine/src/progress.ts +74 -122
  38. package/.pi/extensions/pi-workflow-engine/src/provider-usage-limit.ts +189 -0
  39. package/.pi/extensions/pi-workflow-engine/src/replay-path-identity.ts +48 -0
  40. package/.pi/extensions/pi-workflow-engine/src/research-contract.ts +89 -0
  41. package/.pi/extensions/pi-workflow-engine/src/research-evidence.ts +289 -0
  42. package/.pi/extensions/pi-workflow-engine/src/resume-context.ts +701 -0
  43. package/.pi/extensions/pi-workflow-engine/src/review/code-review-orchestration.ts +49 -0
  44. package/.pi/extensions/pi-workflow-engine/src/review/github-pr-comments.ts +75 -16
  45. package/.pi/extensions/pi-workflow-engine/src/review/review-actions.ts +83 -17
  46. package/.pi/extensions/pi-workflow-engine/src/review/review-budget.ts +45 -0
  47. package/.pi/extensions/pi-workflow-engine/src/review/review-fix-workflow.ts +117 -0
  48. package/.pi/extensions/pi-workflow-engine/src/review/review-handoff.ts +14 -66
  49. package/.pi/extensions/pi-workflow-engine/src/review/review-issues.ts +38 -12
  50. package/.pi/extensions/pi-workflow-engine/src/review/review-report.ts +42 -0
  51. package/.pi/extensions/pi-workflow-engine/src/review/review-results-flow.ts +18 -22
  52. package/.pi/extensions/pi-workflow-engine/src/review/review-results-viewer.ts +128 -56
  53. package/.pi/extensions/pi-workflow-engine/src/review/review-session-coordinator.ts +153 -0
  54. package/.pi/extensions/pi-workflow-engine/src/review/review-snapshot.ts +294 -0
  55. package/.pi/extensions/pi-workflow-engine/src/review-diff-target.ts +130 -0
  56. package/.pi/extensions/pi-workflow-engine/src/session-identity.ts +6 -0
  57. package/.pi/extensions/pi-workflow-engine/src/structured-output.ts +32 -0
  58. package/.pi/extensions/pi-workflow-engine/src/tool-capabilities.ts +44 -0
  59. package/.pi/extensions/pi-workflow-engine/src/tool-source-identity.ts +150 -0
  60. package/.pi/extensions/pi-workflow-engine/src/tree-fingerprint.ts +401 -0
  61. package/.pi/extensions/pi-workflow-engine/src/types.ts +87 -25
  62. package/.pi/extensions/pi-workflow-engine/src/ui/dynamax-editor-decoration.ts +220 -0
  63. package/.pi/extensions/pi-workflow-engine/src/ui/workflow-format.ts +26 -6
  64. package/.pi/extensions/pi-workflow-engine/src/ui/workflow-inspector.ts +88 -46
  65. package/.pi/extensions/pi-workflow-engine/src/ui/workflow-result-renderer.ts +7 -70
  66. package/.pi/extensions/pi-workflow-engine/src/ui/workflow-viewer-layout.ts +51 -0
  67. package/.pi/extensions/pi-workflow-engine/src/ui/workflow-widget.ts +12 -67
  68. package/.pi/extensions/pi-workflow-engine/src/unknown-error.ts +8 -0
  69. package/.pi/extensions/pi-workflow-engine/src/usage.ts +124 -16
  70. package/.pi/extensions/pi-workflow-engine/src/workflow-advisory-utils.ts +23 -3
  71. package/.pi/extensions/pi-workflow-engine/src/workflow-execution.ts +121 -0
  72. package/.pi/extensions/pi-workflow-engine/src/workflow-module.ts +19 -1
  73. package/.pi/extensions/pi-workflow-engine/src/workflow-run-background.ts +72 -0
  74. package/.pi/extensions/pi-workflow-engine/src/workflow-run-controller.ts +354 -0
  75. package/.pi/extensions/pi-workflow-engine/src/workflow-run-history.ts +162 -0
  76. package/.pi/extensions/pi-workflow-engine/src/workflow-run-record.ts +632 -0
  77. package/.pi/extensions/pi-workflow-engine/src/workflow-run-store.ts +172 -0
  78. package/.pi/extensions/pi-workflow-engine/src/workflow-usage-limit-scheduler.ts +80 -0
  79. package/.pi/extensions/pi-workflow-engine/src/workflows.ts +30 -3
  80. package/.pi/extensions/pi-workflow-engine/src/worktree.ts +321 -43
  81. package/.pi/extensions/pi-workflow-engine/workflows/code-review.ts +62 -64
  82. package/.pi/extensions/pi-workflow-engine/workflows/diagnose.ts +17 -17
  83. package/.pi/extensions/pi-workflow-engine/workflows/perf-review.ts +15 -16
  84. package/.pi/extensions/pi-workflow-engine/workflows/refactor-scout.ts +15 -15
  85. package/.pi/extensions/pi-workflow-engine/workflows/research.ts +170 -0
  86. package/README.md +82 -207
  87. package/USAGE.md +295 -21
  88. 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
- export interface ReviewReportWithContext extends AdvisoryReport {
29
- readonly stats?: Record<string, string | number>;
30
- readonly reviewContext?: ReviewContext;
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 isCommentableIssue(issue: ReviewIssue): boolean {
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 { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
1
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
2
  import type { WorkflowRunOptions } from "../types.ts";
3
- import { isAdvisoryReport, type AdvisoryWorkflowResult } from "../ui/workflow-result-renderer.ts";
4
3
  import { toReviewIssues, type ReviewIssue, type ReviewIssueSelection } from "./review-issues.ts";
4
+ import { isReviewReport, type ReviewReport } from "./review-report.ts";
5
5
  import { ReviewResultsViewer } from "./review-results-viewer.ts";
6
+ 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: "tool-invocation" | "not-code-review" | "not-advisory" | "no-findings" | "disabled" | "not-tui" | "not-requested";
11
+ readonly reason: "not-code-review" | "not-advisory" | "no-findings" | "disabled" | "not-tui" | "not-requested";
11
12
  }
12
- | { readonly kind: "open"; readonly report: AdvisoryWorkflowResult; readonly issues: readonly ReviewIssue[]; readonly findingCount: number };
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 interface ReviewResultsViewerContext {
24
- readonly ui: Pick<ExtensionCommandContext["ui"], "custom">;
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 function extensionContextMode(ctx: ExtensionCommandContext): string | undefined {
28
- const candidate = (ctx as ExtensionCommandContext & { readonly mode?: unknown }).mode;
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
- if (!isAdvisoryReport(input.result)) return { kind: "send", reason: "not-advisory" };
36
- if (input.result.findings.length === 0) return { kind: "send", reason: "no-findings" };
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
- const issues = toReviewIssues(input.workflowName, input.result);
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 maybeShowReviewResultsViewer(
44
+ export async function showReviewResultsViewer(
47
45
  ctx: ReviewResultsViewerContext,
48
- decision: ReviewResultsPresentationDecision,
49
- ): Promise<ReviewIssueSelection | undefined> {
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(decision.issues, "code-review", theme, () => tui.requestRender(), done),
54
- { overlay: true, overlayOptions: { anchor: "right-center", width: "80%", maxHeight: "90%", margin: 1 } },
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, visibleWidth, type Component } from "@earendil-works/pi-tui";
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(MIN_WIDTH, width);
32
- const innerWidth = Math.max(1, outerWidth - 2);
33
- const body = this.renderBody(innerWidth);
34
- return [this.topBorder(innerWidth), ...body.map((line) => `│${padAnsi(line, innerWidth)}│`), this.bottomBorder(innerWidth)];
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 header = this.headerLine(width);
90
- const help = this.helpLine(width);
91
- const warning = this.warning ? [this.theme.fg("warning", this.warning)] : [];
92
- if (this.issues.length === 0) return [header, this.theme.fg("success", "No findings."), ...warning, help];
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
- const split = width >= SPLIT_WIDTH;
95
- const content = split ? this.renderSplit(width) : this.renderStacked(width);
96
- return [header, ...warning, ...content, help];
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.42)));
101
- const detailWidth = Math.max(20, width - listWidth - 3);
102
- const list = this.renderIssueList(listWidth);
103
- const details = this.renderDetail(detailWidth);
104
- const rows = Math.max(list.length, details.length);
105
- const lines: string[] = [];
106
- for (let index = 0; index < rows; index++) {
107
- lines.push(`${padAnsi(list[index] ?? "", listWidth)} ${this.theme.fg("dim", "│")} ${padAnsi(details[index] ?? "", detailWidth)}`);
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
- return [...this.renderIssueList(width), this.theme.fg("dim", "─".repeat(Math.max(1, width))), ...this.renderDetail(width)];
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 renderIssueList(width: number): string[] {
117
- return this.issues.map((issue, index) => {
118
- const cursor = index === this.cursor ? this.theme.fg("accent", ">") : " ";
119
- const checked = this.selected.has(issue.id) ? this.theme.fg("success", "x") : " ";
120
- const severity = this.theme.fg(severityColor(issue.finding.severity), issue.finding.severity);
121
- const row = `${cursor} [${checked}] ${issue.id} ${severity} ${issue.finding.category} ${formatIssueLocation(issue)} — ${issue.finding.summary}`;
122
- return truncateDisplay(row, width);
123
- });
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 renderDetail(width: number): string[] {
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
- if (!this.detailsExpanded) return [this.theme.fg("accent", issue.id), issue.finding.summary, this.theme.fg("dim", "Press enter to expand details.")];
130
- const lines = renderIssueDetailLines(issue, this.theme, width);
131
- const maxScroll = Math.max(0, lines.length - 1);
132
- this.detailScroll = Math.min(this.detailScroll, maxScroll);
133
- const visible = lines.slice(this.detailScroll, this.detailScroll + 14);
134
- if (this.detailScroll > 0) visible.unshift(this.theme.fg("dim", "↑ more"));
135
- if (this.detailScroll + 14 < lines.length) visible.push(this.theme.fg("dim", "↓ more"));
136
- return visible.map((line) => truncateDisplay(line, width));
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(`${this.theme.fg("accent", this.theme.bold("Review results"))} ${this.theme.fg("muted", this.workflowName)} · ${count} · ${selected}`, width);
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
- return truncateDisplay(this.theme.fg("dim", "↑/↓ move · 1-9 jump · space tag · a all · enter expand/collapse · ←/→ scroll · f fix · c comment · q close"), width);
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 topBorder(innerWidth: number): string {
150
- return `┌${this.theme.fg("dim", "─".repeat(innerWidth))}┐`;
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 bottomBorder(innerWidth: number): string {
154
- return `└${this.theme.fg("dim", "─".repeat(innerWidth))}┘`;
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 padAnsi(text: string, width: number): string {
222
- const truncated = truncateDisplay(text, width);
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
+ }