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
@@ -0,0 +1,220 @@
1
+ import { sliceByColumn, visibleWidth, type EditorComponent } from "@earendil-works/pi-tui";
2
+
3
+ export type DynamaxEffect = "shine" | "static" | "off";
4
+
5
+ export interface DynamaxAnimationScheduler {
6
+ now(): number;
7
+ schedule(callback: () => void, delayMs: number): () => void;
8
+ }
9
+
10
+ export interface DynamaxEditorDecoration {
11
+ readonly editor: EditorComponent;
12
+ dispose(): void;
13
+ }
14
+
15
+ interface DynamaxEditorDecorationOptions {
16
+ effect: Exclude<DynamaxEffect, "off">;
17
+ scheduler?: DynamaxAnimationScheduler;
18
+ isActive?: () => boolean;
19
+ }
20
+
21
+ const DYNAMAX_RENDER_PATTERN = /(^|[^A-Za-z0-9_])(dynamax)(?=[^A-Za-z0-9_]|$)/gi;
22
+ const DYNAMAX_COLORS = [
23
+ [233, 137, 115],
24
+ [228, 186, 103],
25
+ [141, 192, 122],
26
+ [102, 194, 179],
27
+ [121, 157, 207],
28
+ [157, 134, 195],
29
+ [206, 130, 172],
30
+ ] as const;
31
+ const RESET_FOREGROUND = "\x1b[39m";
32
+ const DYNAMAX_EFFECT_ENV = "PI_DYNAMAX_EFFECT";
33
+ export const DYNAMAX_ANIMATION_FRAME_MS = 135;
34
+ const DYNAMAX_SHINE_FRAME_COUNT = DYNAMAX_COLORS.length;
35
+ const DEFAULT_DYNAMAX_ANIMATION_SCHEDULER: DynamaxAnimationScheduler = {
36
+ now: () => performance.now(),
37
+ schedule(callback, delayMs) {
38
+ const timer = setTimeout(callback, delayMs);
39
+ return () => clearTimeout(timer);
40
+ },
41
+ };
42
+
43
+ export function resolveDynamaxEffect(env: NodeJS.ProcessEnv = process.env): DynamaxEffect {
44
+ const configured = env[DYNAMAX_EFFECT_ENV]?.trim().toLowerCase();
45
+ if (configured === "shine" || configured === "static" || configured === "off") return configured;
46
+ if (env.NO_COLOR !== undefined && env.NO_COLOR !== "") return "off";
47
+ return "shine";
48
+ }
49
+
50
+ export function highlightDynamaxTokens(line: string, shinePosition?: number): string {
51
+ const { visibleText, rawPositions } = mapVisibleCharacters(line);
52
+ const insertions = new Map<number, string>();
53
+ for (const match of visibleText.matchAll(DYNAMAX_RENDER_PATTERN)) {
54
+ const token = match[2]!;
55
+ const visibleStart = match.index! + match[1]!.length;
56
+ for (let index = 0; index < token.length; index++) {
57
+ const rawPosition = rawPositions[visibleStart + index]!;
58
+ const color = DYNAMAX_COLORS[index % DYNAMAX_COLORS.length]!;
59
+ const shine = shinePosition === undefined ? (index === 0 ? 0.45 : 0) : shineFactor(index, shinePosition);
60
+ insertions.set(rawPosition, foregroundColor(color, shine));
61
+ }
62
+ const restorePosition = rawPositions[visibleStart + token.length] ?? line.length;
63
+ insertions.set(restorePosition, foregroundRestoreAt(line, restorePosition));
64
+ }
65
+ if (insertions.size === 0) return line;
66
+
67
+ let highlighted = "";
68
+ for (let rawPosition = 0; rawPosition <= line.length; rawPosition++) {
69
+ highlighted += insertions.get(rawPosition) ?? "";
70
+ if (rawPosition < line.length) highlighted += line[rawPosition];
71
+ }
72
+ return highlighted;
73
+ }
74
+
75
+ export function decorateDynamaxEditor(
76
+ editor: EditorComponent,
77
+ requestRender: () => void,
78
+ options: DynamaxEditorDecorationOptions,
79
+ ): DynamaxEditorDecoration {
80
+ const { effect } = options;
81
+ const originalRender = editor.render;
82
+ const scheduler = options.scheduler ?? DEFAULT_DYNAMAX_ANIMATION_SCHEDULER;
83
+ const isActive = options.isActive ?? (() => true);
84
+ let disposed = false;
85
+ let cancelScheduled: (() => void) | undefined;
86
+ let lastSignature: string | undefined;
87
+ let animationStartedAt: number | undefined;
88
+
89
+ const cancelAnimation = (): void => {
90
+ cancelScheduled?.();
91
+ cancelScheduled = undefined;
92
+ animationStartedAt = undefined;
93
+ };
94
+ const scheduleRender = (delayMs: number): void => {
95
+ if (cancelScheduled || disposed || !isActive()) return;
96
+ cancelScheduled = scheduler.schedule(() => {
97
+ cancelScheduled = undefined;
98
+ if (disposed || !isActive()) return;
99
+ requestRender();
100
+ }, Math.max(0, delayMs));
101
+ };
102
+
103
+ const decoratedRender = (width: number): string[] => {
104
+ const lines = originalRender.call(editor, width);
105
+ const signature = visibleDynamaxSignature(lines);
106
+ if (signature !== lastSignature) {
107
+ cancelAnimation();
108
+ lastSignature = signature;
109
+ if (signature && effect === "shine" && isActive()) animationStartedAt = scheduler.now();
110
+ }
111
+
112
+ let shinePosition: number | undefined;
113
+ if (animationStartedAt !== undefined && signature && effect === "shine" && isActive()) {
114
+ const now = scheduler.now();
115
+ const elapsed = Math.max(0, now - animationStartedAt);
116
+ const frame = Math.floor(elapsed / DYNAMAX_ANIMATION_FRAME_MS);
117
+ if (frame < DYNAMAX_SHINE_FRAME_COUNT) {
118
+ shinePosition = frame;
119
+ const nextFrameAt = animationStartedAt + (frame + 1) * DYNAMAX_ANIMATION_FRAME_MS;
120
+ scheduleRender(nextFrameAt - now);
121
+ } else {
122
+ cancelAnimation();
123
+ }
124
+ } else if (!signature || effect !== "shine" || !isActive()) {
125
+ cancelAnimation();
126
+ }
127
+
128
+ return lines.map((line) => highlightDynamaxTokens(line, shinePosition));
129
+ };
130
+
131
+ editor.render = decoratedRender;
132
+ return {
133
+ editor,
134
+ dispose() {
135
+ if (disposed) return;
136
+ disposed = true;
137
+ cancelAnimation();
138
+ if (editor.render === decoratedRender) editor.render = originalRender;
139
+ },
140
+ };
141
+ }
142
+
143
+ function shineFactor(index: number, shinePosition: number): number {
144
+ const distance = Math.abs(index - shinePosition);
145
+ if (distance === 0) return 0.7;
146
+ if (distance === 1) return 0.3;
147
+ return 0;
148
+ }
149
+
150
+ function foregroundColor(color: readonly [number, number, number], shine: number): string {
151
+ const red = Math.round(color[0] + (255 - color[0]) * shine);
152
+ const green = Math.round(color[1] + (255 - color[1]) * shine);
153
+ const blue = Math.round(color[2] + (255 - color[2]) * shine);
154
+ return `\x1b[38;2;${red};${green};${blue}m`;
155
+ }
156
+
157
+ function mapVisibleCharacters(line: string): { visibleText: string; rawPositions: number[] } {
158
+ let visibleText = "";
159
+ const rawPositions: number[] = [];
160
+ let rawPosition = 0;
161
+ while (rawPosition < line.length) {
162
+ const controlLength = terminalControlSequenceLength(line, rawPosition);
163
+ if (controlLength > 0) {
164
+ rawPosition += controlLength;
165
+ continue;
166
+ }
167
+ visibleText += line[rawPosition];
168
+ rawPositions.push(rawPosition);
169
+ rawPosition += 1;
170
+ }
171
+ return { visibleText, rawPositions };
172
+ }
173
+
174
+ function terminalControlSequenceLength(line: string, position: number): number {
175
+ if (line[position] !== "\x1b") return 0;
176
+ const introducer = line[position + 1];
177
+ if (introducer === "[") {
178
+ for (let index = position + 2; index < line.length; index++) {
179
+ const code = line.charCodeAt(index);
180
+ if (code >= 0x40 && code <= 0x7e) return index - position + 1;
181
+ }
182
+ return line.length - position;
183
+ }
184
+ if (introducer === "]" || introducer === "_") {
185
+ for (let index = position + 2; index < line.length; index++) {
186
+ if (line[index] === "\x07") return index - position + 1;
187
+ if (line[index] === "\x1b" && line[index + 1] === "\\") return index - position + 2;
188
+ }
189
+ return line.length - position;
190
+ }
191
+ return Math.min(2, line.length - position);
192
+ }
193
+
194
+ function foregroundRestoreAt(line: string, rawPosition: number): string {
195
+ const column = visibleWidth(line.slice(0, rawPosition));
196
+ // The extra cell makes Pi emit the active SGR prefix even when the token ends the line.
197
+ const sliced = sliceByColumn(`${line} `, column, 1);
198
+ let controls = "";
199
+ let position = 0;
200
+ while (position < sliced.length) {
201
+ const controlLength = terminalControlSequenceLength(sliced, position);
202
+ if (controlLength === 0) break;
203
+ const sequence = sliced.slice(position, position + controlLength);
204
+ if (sequence.startsWith("\x1b[") && sequence.endsWith("m")) controls += sequence;
205
+ position += controlLength;
206
+ }
207
+ return `${RESET_FOREGROUND}${controls}`;
208
+ }
209
+
210
+ function visibleDynamaxSignature(lines: string[]): string {
211
+ const matches: string[] = [];
212
+ for (let lineIndex = 0; lineIndex < lines.length; lineIndex++) {
213
+ const { visibleText } = mapVisibleCharacters(lines[lineIndex]!);
214
+ for (const match of visibleText.matchAll(DYNAMAX_RENDER_PATTERN)) {
215
+ const visibleStart = match.index! + match[1]!.length;
216
+ matches.push(`${lineIndex}:${visibleStart}:${match[2]!}`);
217
+ }
218
+ }
219
+ return matches.join("|");
220
+ }
@@ -1,7 +1,7 @@
1
1
  import type { Theme } from "@earendil-works/pi-coding-agent";
2
2
  import { truncateToWidth } from "@earendil-works/pi-tui";
3
- import type { AgentRowSnapshot, WorkflowProgressSnapshot } from "../progress.ts";
4
- import type { WorkflowLaneItemStatus } from "../types.ts";
3
+ import type { AgentRowSnapshot, WorkflowLaneItemStatus, WorkflowProgressSnapshot } from "../progress-types.ts";
4
+ import { formatWorkflowUsageLine } from "../usage.ts";
5
5
 
6
6
  export type WorkflowDisplayStatus = WorkflowLaneItemStatus | "queued" | "done" | "failed";
7
7
  export type WorkflowThemeColor = Parameters<Theme["fg"]>[0];
@@ -49,10 +49,6 @@ export function statusIcon(status: WorkflowDisplayStatus, theme: Theme): string
49
49
  }
50
50
  }
51
51
 
52
- export function badge(label: string, color: WorkflowThemeColor, theme: Theme): string {
53
- return theme.fg(color, `[${label}]`);
54
- }
55
-
56
52
  export function truncateDisplay(text: string, width: number): string {
57
53
  if (width <= 0) return "";
58
54
  return truncateToWidth(text, width);
@@ -97,6 +93,30 @@ export interface WorkflowStatusSource {
97
93
  readonly counters: readonly { readonly key: string; readonly label: string; readonly value: number }[];
98
94
  }
99
95
 
96
+ interface WorkflowInspectionSource {
97
+ readonly name: string;
98
+ readonly snapshot: WorkflowProgressSnapshot | (() => WorkflowProgressSnapshot);
99
+ }
100
+
101
+ export function workflowInspectionSnapshot(inspection: WorkflowInspectionSource): WorkflowProgressSnapshot {
102
+ return typeof inspection.snapshot === "function" ? inspection.snapshot() : inspection.snapshot;
103
+ }
104
+
105
+ export function formatWorkflowInspection(inspection: WorkflowInspectionSource): string {
106
+ const snapshot = workflowInspectionSnapshot(inspection);
107
+ const counts = countSnapshotAgents(snapshot);
108
+ const lines = [
109
+ `Workflow inspector: ${inspection.name}`,
110
+ `Run: ${snapshot.runId}`,
111
+ `Phase: ${snapshot.currentPhase}`,
112
+ `Agents: ${counts.running} running, ${counts.queued} queued, ${counts.done} done, ${counts.failed} failed`,
113
+ ];
114
+ const usage = formatWorkflowUsageLine(snapshot.usage);
115
+ if (usage) lines.push(usage);
116
+ if (snapshot.logs.length > 0) lines.push("Recent log:", ...snapshot.logs.slice(-8).map((entry) => `- ${entry}`));
117
+ return lines.join("\n");
118
+ }
119
+
100
120
  export function statusText(snapshot: WorkflowProgressSnapshot, theme: Theme): string | undefined {
101
121
  const counts = countSnapshotAgents(snapshot);
102
122
  return statusTextFromCounts(snapshot, counts, theme);
@@ -1,9 +1,16 @@
1
1
  import type { Theme } from "@earendil-works/pi-coding-agent";
2
2
  import { matchesKey, type TUI, visibleWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
3
- import type { AgentRowSnapshot, WorkflowLaneItemSnapshot, WorkflowProgressSnapshot } from "../progress.ts";
3
+ import type { AgentRowSnapshot, WorkflowLaneItemSnapshot, WorkflowProgressSnapshot } from "../progress-types.ts";
4
+ import { formatWorkflowUsageLine } from "../usage.ts";
4
5
  import { agentDetailParts, formatCount, formatDuration, statusIcon, truncateDisplay } from "./workflow-format.ts";
6
+ import {
7
+ centerWorkflowViewerViewport,
8
+ fitWorkflowViewerRow,
9
+ fitWorkflowViewerRows,
10
+ workflowViewerHeight,
11
+ } from "./workflow-viewer-layout.ts";
5
12
 
6
- type Section = "Overview" | "Agents" | "Findings" | "Logs";
13
+ type Section = "Overview" | "Agents" | "Findings" | "Logs" | "Result";
7
14
 
8
15
  interface InspectorLine {
9
16
  text: string;
@@ -11,11 +18,20 @@ interface InspectorLine {
11
18
  key?: string;
12
19
  }
13
20
 
14
- const SECTIONS: readonly Section[] = ["Overview", "Agents", "Findings", "Logs"];
21
+ const BASE_SECTIONS: readonly Section[] = ["Overview", "Agents", "Findings", "Logs"];
22
+ const RESULT_SECTIONS: readonly Section[] = [...BASE_SECTIONS, "Result"];
23
+ const MAX_RESULT_SOURCE_LINES = 200;
24
+ const MAX_RESULT_RENDERED_LINES = 400;
25
+
26
+ export interface WorkflowInspectorOutcome {
27
+ readonly label: string;
28
+ readonly text: string;
29
+ }
15
30
 
16
31
  export class WorkflowInspector {
17
32
  private sectionIndex = 0;
18
- private readonly selected: Record<Section, number> = { Overview: 0, Agents: 0, Findings: 0, Logs: 0 };
33
+ private contentWidth = 80;
34
+ private readonly selected: Record<Section, number> = { Overview: 0, Agents: 0, Findings: 0, Logs: 0, Result: 0 };
19
35
  private readonly expanded = new Set<string>();
20
36
 
21
37
  constructor(
@@ -23,6 +39,7 @@ export class WorkflowInspector {
23
39
  private readonly tui: Pick<TUI, "requestRender" | "terminal">,
24
40
  private readonly theme: Theme,
25
41
  private readonly close: () => void,
42
+ private readonly outcome?: WorkflowInspectorOutcome,
26
43
  ) {}
27
44
 
28
45
  handleInput(data: string): void {
@@ -32,7 +49,7 @@ export class WorkflowInspector {
32
49
  }
33
50
 
34
51
  if (matchesKey(data, "tab")) {
35
- this.sectionIndex = (this.sectionIndex + 1) % SECTIONS.length;
52
+ this.sectionIndex = (this.sectionIndex + 1) % this.sections().length;
36
53
  this.requestRender();
37
54
  return;
38
55
  }
@@ -60,47 +77,52 @@ export class WorkflowInspector {
60
77
  }
61
78
 
62
79
  render(width: number): string[] {
63
- const w = Math.max(24, width);
80
+ const w = Math.max(4, width);
64
81
  const inner = Math.max(1, w - 4);
82
+ this.contentWidth = inner;
65
83
  const th = this.theme;
66
84
  const snapshot = this.snapshotProvider();
67
- const body = this.sectionLines(this.currentSection(), snapshot, inner);
68
- const maxBody = Math.max(6, Math.floor(this.tui.terminal.rows * 0.8) - 6);
69
- const selectedLine = this.selectedLineIndex(body);
70
- const maxStart = Math.max(0, body.length - maxBody);
71
- const start = Math.min(maxStart, Math.max(0, selectedLine - Math.floor(maxBody / 2)));
72
- const visible = body.slice(start, start + maxBody);
73
- const lines: string[] = [];
74
-
75
- lines.push(th.fg("border", `╭${"─".repeat(w - 2)}╮`));
76
- lines.push(this.row(` ${th.fg("accent", th.bold("Workflow Inspector"))} ${th.fg("dim", snapshot.title)}`, inner));
77
- lines.push(this.row(` ${this.tabs()}`, inner));
78
- lines.push(this.row(th.fg("dim", "─".repeat(inner)), inner));
79
-
80
- for (const line of visible) lines.push(this.row(line.text, inner));
81
- while (visible.length < maxBody) {
82
- visible.push({ text: "" });
83
- lines.push(this.row("", inner));
85
+ const section = this.currentSection();
86
+ let body = this.sectionLines(section, snapshot, inner);
87
+ const selectableCount = body.filter((line) => line.selectable).length;
88
+ const clampedSelection = Math.min(Math.max(0, selectableCount - 1), this.selected[section]);
89
+ if (clampedSelection !== this.selected[section]) {
90
+ this.selected[section] = clampedSelection;
91
+ body = this.sectionLines(section, snapshot, inner);
84
92
  }
85
-
86
- lines.push(this.row(th.fg("dim", "─".repeat(inner)), inner));
87
- const pct = body.length <= maxBody ? "100%" : `${Math.round(((start + visible.length) / body.length) * 100)}%`;
88
- const selected = this.selected[this.currentSection()] + 1;
89
- const count = Math.max(1, this.itemCount(this.currentSection()));
90
- lines.push(
91
- this.row(
93
+ const innerHeight = Math.max(1, workflowViewerHeight(this.tui.terminal.rows) - 2);
94
+ const maxBody = Math.max(1, innerHeight - 5);
95
+ const viewport = centerWorkflowViewerViewport(body, maxBody, this.selectedLineIndex(body));
96
+ const pct = `${viewport.percentage}%`;
97
+ const selected = this.selected[section] + 1;
98
+ const count = Math.max(1, selectableCount);
99
+ const content = fitWorkflowViewerRows(viewport.visible.map((line) => line.text), maxBody);
100
+ const interior = fitWorkflowViewerRows(
101
+ [
102
+ ` ${th.fg("accent", th.bold("Workflow Inspector"))} ${th.fg("dim", snapshot.title)}`,
103
+ ` ${this.tabs()}`,
104
+ th.fg("dim", "─".repeat(inner)),
105
+ ...content,
106
+ th.fg("dim", "─".repeat(inner)),
92
107
  `${th.fg("dim", `${body.length} lines · ${pct} · ${selected}/${count}`)} ${th.fg("dim", "· tab sections · ↑↓ select · enter expand/collapse · q/esc close")}`,
93
- inner,
94
- ),
108
+ ],
109
+ innerHeight,
95
110
  );
96
- lines.push(th.fg("border", `╰${"─".repeat(w - 2)}╯`));
97
- return lines.map((line) => truncateDisplay(line, w));
111
+ return [
112
+ th.fg("border", `╭${"─".repeat(Math.max(0, w - 2))}╮`),
113
+ ...interior.map((line) => this.row(line, inner)),
114
+ th.fg("border", `╰${"─".repeat(Math.max(0, w - 2))}╯`),
115
+ ].map((line) => truncateDisplay(line, w));
98
116
  }
99
117
 
100
118
  invalidate(): void {}
101
119
 
102
120
  private currentSection(): Section {
103
- return SECTIONS[this.sectionIndex] ?? "Overview";
121
+ return this.sections()[this.sectionIndex] ?? "Overview";
122
+ }
123
+
124
+ private sections(): readonly Section[] {
125
+ return this.outcome ? RESULT_SECTIONS : BASE_SECTIONS;
104
126
  }
105
127
 
106
128
  private requestRender(): void {
@@ -108,24 +130,23 @@ export class WorkflowInspector {
108
130
  }
109
131
 
110
132
  private tabs(): string {
111
- return SECTIONS.map((section, index) => {
112
- const label = index === this.sectionIndex ? this.theme.fg("accent", this.theme.bold(section)) : this.theme.fg("muted", section);
113
- return label;
133
+ return this.sections().map((section, index) => {
134
+ return index === this.sectionIndex ? this.theme.fg("accent", this.theme.bold(section)) : this.theme.fg("muted", section);
114
135
  }).join(this.theme.fg("dim", " | "));
115
136
  }
116
137
 
117
138
  private row(content: string, innerWidth: number): string {
118
- const padded = padRight(truncateDisplay(content, innerWidth), innerWidth);
119
- return this.theme.fg("border", "│") + " " + padded + " " + this.theme.fg("border", "│");
139
+ const fitted = fitWorkflowViewerRow(content, innerWidth);
140
+ return this.theme.fg("border", "│") + " " + fitted + " " + this.theme.fg("border", "│");
120
141
  }
121
142
 
122
143
  private itemCount(section: Section): number {
123
- return this.sectionLines(section, this.snapshotProvider(), 80).filter((line) => line.selectable).length;
144
+ return this.sectionLines(section, this.snapshotProvider(), this.contentWidth).filter((line) => line.selectable).length;
124
145
  }
125
146
 
126
147
  private selectedKey(section: Section): string | undefined {
127
148
  let index = -1;
128
- for (const line of this.sectionLines(section, this.snapshotProvider(), 80)) {
149
+ for (const line of this.sectionLines(section, this.snapshotProvider(), this.contentWidth)) {
129
150
  if (!line.selectable) continue;
130
151
  index++;
131
152
  if (index === this.selected[section]) return line.key;
@@ -153,6 +174,8 @@ export class WorkflowInspector {
153
174
  return this.findingLines(snapshot, width);
154
175
  case "Logs":
155
176
  return this.logLines(snapshot);
177
+ case "Result":
178
+ return this.resultLines(width);
156
179
  }
157
180
  }
158
181
 
@@ -162,6 +185,8 @@ export class WorkflowInspector {
162
185
  { text: `${this.theme.fg("muted", "Phase")} ${this.theme.fg("accent", snapshot.currentPhase)}`, selectable: true, key: "overview:phase" },
163
186
  { text: `${this.theme.fg("muted", "Elapsed")} ${elapsed}`, selectable: true, key: "overview:elapsed" },
164
187
  ];
188
+ const usage = formatWorkflowUsageLine(snapshot.usage);
189
+ if (usage) lines.push({ text: usage, selectable: true, key: "overview:usage" });
165
190
  if (snapshot.counters.length > 0) {
166
191
  lines.push({ text: this.theme.fg("dim", "Counters") });
167
192
  for (const counter of snapshot.counters) {
@@ -260,9 +285,26 @@ export class WorkflowInspector {
260
285
  return { text, selectable: true, key };
261
286
  });
262
287
  }
263
- }
264
288
 
265
- function padRight(text: string, width: number): string {
266
- const visible = visibleWidth(text);
267
- return text + " ".repeat(Math.max(0, width - visible));
289
+ private resultLines(width: number): InspectorLine[] {
290
+ if (!this.outcome) return [{ text: this.theme.fg("dim", "No retained outcome is available for this run.") }];
291
+ const source = this.outcome.text.split("\n");
292
+ const lines: InspectorLine[] = [{ text: this.theme.fg("dim", this.outcome.label) }];
293
+ const shown = source.slice(0, MAX_RESULT_SOURCE_LINES);
294
+ for (const sourceLine of shown) {
295
+ const wrapped = wrapTextWithAnsi(sourceLine || " ", Math.max(10, width - 2));
296
+ for (const line of wrapped) {
297
+ if (lines.length >= MAX_RESULT_RENDERED_LINES) break;
298
+ const index = lines.length;
299
+ const selected = this.currentSection() === "Result" && this.selected.Result === index - 1;
300
+ const text = selected ? this.theme.bg("selectedBg", line) : line;
301
+ lines.push({ text, selectable: true, key: `result:${index}` });
302
+ }
303
+ if (lines.length >= MAX_RESULT_RENDERED_LINES) break;
304
+ }
305
+ if (shown.length < source.length || lines.length >= MAX_RESULT_RENDERED_LINES) {
306
+ lines.push({ text: this.theme.fg("dim", "… retained result truncated for display") });
307
+ }
308
+ return lines;
309
+ }
268
310
  }
@@ -1,68 +1,18 @@
1
1
  import type { Theme } from "@earendil-works/pi-coding-agent";
2
2
  import { Box, type Component, Text } from "@earendil-works/pi-tui";
3
- import type { AdvisoryFinding, AdvisoryLocation, AdvisoryReport } from "../advisory-schema.ts";
4
- import type { PerfAggregate } from "../perf.ts";
3
+ import { isAdvisoryReport, type AdvisoryReportWithStats } from "../advisory-schema.ts";
5
4
  import { renderIssueDetails, renderIssuesTable } from "../review/review-format.ts";
6
- import { toReviewIssues, type ReviewContext } from "../review/review-issues.ts";
5
+ import { toReviewIssues } from "../review/review-issues.ts";
7
6
  import { formatCount } from "./workflow-format.ts";
8
- import { formatWorkflowUsageLine, type WorkflowUsageSnapshot } from "../usage.ts";
9
-
10
- export interface WorkflowPerfDetails {
11
- readonly enabled: boolean;
12
- readonly startedAt: number;
13
- readonly aggregates: readonly PerfAggregate[];
14
- }
15
-
16
- export interface WorkflowResultEnvelope {
17
- name: string;
18
- result: unknown;
19
- completedAt: number;
20
- usage?: WorkflowUsageSnapshot;
21
- perf?: WorkflowPerfDetails;
22
- runId?: string;
23
- resumedFromRunId?: string;
24
- }
25
-
26
- export interface AdvisoryWorkflowResult extends AdvisoryReport {
27
- stats?: Record<string, string | number>;
28
- reviewContext?: ReviewContext;
29
- }
7
+ import { formatWorkflowUsageLine } from "../usage.ts";
8
+ import type { WorkflowPerfDetails, WorkflowResultEnvelope } from "../workflow-execution.ts";
9
+ import { unknownErrorMessage } from "../unknown-error.ts";
30
10
 
31
11
  export function isWorkflowResult(value: unknown): value is WorkflowResultEnvelope {
32
12
  if (!isRecord(value)) return false;
33
13
  return typeof value.name === "string" && "result" in value && typeof value.completedAt === "number";
34
14
  }
35
15
 
36
- function isAdvisoryLocation(value: unknown): value is AdvisoryLocation {
37
- if (!isRecord(value)) return false;
38
- if (typeof value.file !== "string") return false;
39
- if (value.line !== undefined && typeof value.line !== "number") return false;
40
- if (value.symbol !== undefined && typeof value.symbol !== "string") return false;
41
- return true;
42
- }
43
-
44
- function isAdvisoryFinding(value: unknown): value is AdvisoryFinding {
45
- if (!isRecord(value)) return false;
46
- if (typeof value.summary !== "string") return false;
47
- if (typeof value.category !== "string") return false;
48
- if (!isSeverity(value.severity)) return false;
49
- if (!isConfidence(value.confidence)) return false;
50
- if (!Array.isArray(value.locations) || !value.locations.every(isAdvisoryLocation)) return false;
51
- if (!Array.isArray(value.evidence) || !value.evidence.every((entry) => typeof entry === "string")) return false;
52
- if (typeof value.impact !== "string") return false;
53
- if (typeof value.recommendation !== "string") return false;
54
- return true;
55
- }
56
-
57
- export function isAdvisoryReport(value: unknown): value is AdvisoryWorkflowResult {
58
- if (!isRecord(value)) return false;
59
- if (typeof value.summary !== "string" || !Array.isArray(value.findings)) return false;
60
- if (!value.findings.every(isAdvisoryFinding)) return false;
61
- if (!Array.isArray(value.nextSteps) || !value.nextSteps.every((entry) => typeof entry === "string")) return false;
62
- if (value.stats !== undefined && !isStats(value.stats)) return false;
63
- return true;
64
- }
65
-
66
16
  export interface WorkflowRunDisplayMetadata {
67
17
  readonly runId?: string;
68
18
  readonly resumedFromRunId?: string;
@@ -105,7 +55,7 @@ export function renderWorkflowResultText(
105
55
 
106
56
  function renderAdvisoryResult(
107
57
  name: string,
108
- result: AdvisoryWorkflowResult,
58
+ result: AdvisoryReportWithStats,
109
59
  expanded: boolean,
110
60
  theme: Theme,
111
61
  usage?: unknown,
@@ -209,23 +159,10 @@ function safeJson(value: unknown): string {
209
159
  try {
210
160
  return JSON.stringify(value, null, 2) ?? String(value);
211
161
  } catch (error) {
212
- return error instanceof Error ? error.message : String(error);
162
+ return unknownErrorMessage(error);
213
163
  }
214
164
  }
215
165
 
216
- function isSeverity(value: unknown): value is AdvisoryFinding["severity"] {
217
- return value === "low" || value === "medium" || value === "high";
218
- }
219
-
220
- function isConfidence(value: unknown): value is AdvisoryFinding["confidence"] {
221
- return value === "low" || value === "medium" || value === "high";
222
- }
223
-
224
- function isStats(value: unknown): value is Record<string, string | number> {
225
- if (!isRecord(value)) return false;
226
- return Object.values(value).every((entry) => typeof entry === "string" || typeof entry === "number");
227
- }
228
-
229
166
  function isRecord(value: unknown): value is Record<string, unknown> {
230
167
  return typeof value === "object" && value !== null;
231
168
  }
@@ -0,0 +1,51 @@
1
+ import { truncateToWidth } from "@earendil-works/pi-tui";
2
+
3
+ const VIEWPORT_HEIGHT_RATIO = 0.8;
4
+ const VIEWPORT_MARGIN_ROWS = 2;
5
+
6
+ export const WORKFLOW_VIEWER_OVERLAY_OPTIONS = {
7
+ overlay: true,
8
+ overlayOptions: {
9
+ anchor: "center",
10
+ width: "80%",
11
+ minWidth: 40,
12
+ maxHeight: "80%",
13
+ margin: 1,
14
+ },
15
+ } as const;
16
+
17
+ export function workflowViewerHeight(terminalRows: number): number {
18
+ const rows = Math.max(1, terminalRows);
19
+ const availableRows = Math.max(1, rows - VIEWPORT_MARGIN_ROWS);
20
+ const proportionalRows = Math.floor(rows * VIEWPORT_HEIGHT_RATIO);
21
+ return Math.min(availableRows, Math.max(3, proportionalRows));
22
+ }
23
+
24
+ export function fitWorkflowViewerRow(text: string, width: number): string {
25
+ return truncateToWidth(text, Math.max(0, width), "...", true);
26
+ }
27
+
28
+ export function fitWorkflowViewerRows(lines: readonly string[], height: number): string[] {
29
+ const visible = lines.slice(0, Math.max(0, height));
30
+ return [...visible, ...Array.from({ length: Math.max(0, height - visible.length) }, () => "")];
31
+ }
32
+
33
+ interface WorkflowViewerViewport<T> {
34
+ readonly visible: readonly T[];
35
+ readonly percentage: number;
36
+ }
37
+
38
+ export function centerWorkflowViewerViewport<T>(
39
+ rows: readonly T[],
40
+ height: number,
41
+ selectedIndex: number,
42
+ ): WorkflowViewerViewport<T> {
43
+ const visibleHeight = Math.max(0, Math.floor(height));
44
+ const clampedSelection = Math.min(Math.max(0, rows.length - 1), Math.max(0, selectedIndex));
45
+ const maxStart = Math.max(0, rows.length - visibleHeight);
46
+ const start = Math.min(maxStart, Math.max(0, clampedSelection - Math.floor(visibleHeight / 2)));
47
+ const visible = rows.slice(start, start + visibleHeight);
48
+ const end = Math.min(rows.length, start + visible.length);
49
+ const percentage = rows.length <= visibleHeight ? 100 : Math.round((end / rows.length) * 100);
50
+ return { visible, percentage };
51
+ }