pi-workflow-engine 0.13.0 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (92) hide show
  1. package/.pi/extensions/pi-workflow-engine/index.ts +106 -391
  2. package/.pi/extensions/pi-workflow-engine/src/advisory-challenge.ts +1 -1
  3. package/.pi/extensions/pi-workflow-engine/src/advisory-evidence.ts +8 -8
  4. package/.pi/extensions/pi-workflow-engine/src/advisory-schema.ts +7 -1
  5. package/.pi/extensions/pi-workflow-engine/src/agent-attempt.ts +48 -62
  6. package/.pi/extensions/pi-workflow-engine/src/agent-replay.ts +46 -67
  7. package/.pi/extensions/pi-workflow-engine/src/agent-retry.ts +14 -37
  8. package/.pi/extensions/pi-workflow-engine/src/agent-runner-types.ts +5 -11
  9. package/.pi/extensions/pi-workflow-engine/src/agent-runner.ts +15 -21
  10. package/.pi/extensions/pi-workflow-engine/src/agent-session-identity.ts +13 -43
  11. package/.pi/extensions/pi-workflow-engine/src/agent-session-providers.ts +17 -5
  12. package/.pi/extensions/pi-workflow-engine/src/agent-session.ts +93 -100
  13. package/.pi/extensions/pi-workflow-engine/src/agent-skills.ts +11 -43
  14. package/.pi/extensions/pi-workflow-engine/src/agent-workspace.ts +4 -18
  15. package/.pi/extensions/pi-workflow-engine/src/background-workflows.ts +33 -86
  16. package/.pi/extensions/pi-workflow-engine/src/cancellation.ts +1 -5
  17. package/.pi/extensions/pi-workflow-engine/src/concurrency.ts +25 -80
  18. package/.pi/extensions/pi-workflow-engine/src/discovery.ts +16 -23
  19. package/.pi/extensions/pi-workflow-engine/src/dynamax-shortcuts.ts +1 -4
  20. package/.pi/extensions/pi-workflow-engine/src/dynamax.ts +39 -98
  21. package/.pi/extensions/pi-workflow-engine/src/engine.ts +37 -80
  22. package/.pi/extensions/pi-workflow-engine/src/finalizers.ts +2 -10
  23. package/.pi/extensions/pi-workflow-engine/src/git.ts +55 -0
  24. package/.pi/extensions/pi-workflow-engine/src/guards.ts +8 -0
  25. package/.pi/extensions/pi-workflow-engine/src/identity-canonicalization.ts +7 -19
  26. package/.pi/extensions/pi-workflow-engine/src/inline-workflow.ts +171 -106
  27. package/.pi/extensions/pi-workflow-engine/src/journal.ts +0 -12
  28. package/.pi/extensions/pi-workflow-engine/src/model-profile-command.ts +37 -8
  29. package/.pi/extensions/pi-workflow-engine/src/model-profiles.ts +15 -66
  30. package/.pi/extensions/pi-workflow-engine/src/options.ts +97 -16
  31. package/.pi/extensions/pi-workflow-engine/src/perf.ts +43 -72
  32. package/.pi/extensions/pi-workflow-engine/src/process-runner.ts +14 -4
  33. package/.pi/extensions/pi-workflow-engine/src/progress-types.ts +2 -0
  34. package/.pi/extensions/pi-workflow-engine/src/progress.ts +79 -80
  35. package/.pi/extensions/pi-workflow-engine/src/provider-usage-limit.ts +28 -20
  36. package/.pi/extensions/pi-workflow-engine/src/replay-path-identity.ts +2 -7
  37. package/.pi/extensions/pi-workflow-engine/src/research-contract.ts +0 -1
  38. package/.pi/extensions/pi-workflow-engine/src/research-evidence.ts +27 -31
  39. package/.pi/extensions/pi-workflow-engine/src/resume-context.ts +75 -139
  40. package/.pi/extensions/pi-workflow-engine/src/review/github-pr-comments.ts +14 -31
  41. package/.pi/extensions/pi-workflow-engine/src/review/patch-validation.ts +3 -4
  42. package/.pi/extensions/pi-workflow-engine/src/review/review-actions.ts +1 -1
  43. package/.pi/extensions/pi-workflow-engine/src/review/review-diff-lines.ts +54 -0
  44. package/.pi/extensions/pi-workflow-engine/src/review/review-fix-workflow.ts +7 -9
  45. package/.pi/extensions/pi-workflow-engine/src/review/review-format.ts +51 -117
  46. package/.pi/extensions/pi-workflow-engine/src/review/review-handoff.ts +1 -1
  47. package/.pi/extensions/pi-workflow-engine/src/review/review-issues.ts +8 -13
  48. package/.pi/extensions/pi-workflow-engine/src/review/review-report.ts +3 -7
  49. package/.pi/extensions/pi-workflow-engine/src/review/review-results-viewer.ts +158 -241
  50. package/.pi/extensions/pi-workflow-engine/src/review/review-session-coordinator.ts +28 -38
  51. package/.pi/extensions/pi-workflow-engine/src/review/review-snapshot.ts +35 -44
  52. package/.pi/extensions/pi-workflow-engine/src/review-diff-target.ts +13 -14
  53. package/.pi/extensions/pi-workflow-engine/src/text.ts +29 -0
  54. package/.pi/extensions/pi-workflow-engine/src/tool-capabilities.ts +2 -1
  55. package/.pi/extensions/pi-workflow-engine/src/tool-source-identity.ts +16 -28
  56. package/.pi/extensions/pi-workflow-engine/src/tree-fingerprint.ts +34 -14
  57. package/.pi/extensions/pi-workflow-engine/src/types.ts +6 -0
  58. package/.pi/extensions/pi-workflow-engine/src/ui/kit.ts +193 -0
  59. package/.pi/extensions/pi-workflow-engine/src/ui/research-result-renderer.ts +177 -0
  60. package/.pi/extensions/pi-workflow-engine/src/ui/result-layout.ts +209 -0
  61. package/.pi/extensions/pi-workflow-engine/src/ui/workflow-format.ts +17 -44
  62. package/.pi/extensions/pi-workflow-engine/src/ui/workflow-inspector.ts +703 -235
  63. package/.pi/extensions/pi-workflow-engine/src/ui/workflow-picker.ts +48 -0
  64. package/.pi/extensions/pi-workflow-engine/src/ui/workflow-result-renderer.ts +239 -129
  65. package/.pi/extensions/pi-workflow-engine/src/ui/workflow-runs-browser.ts +461 -0
  66. package/.pi/extensions/pi-workflow-engine/src/ui/workflow-tool-renderer.ts +91 -0
  67. package/.pi/extensions/pi-workflow-engine/src/ui/workflow-widget.ts +316 -98
  68. package/.pi/extensions/pi-workflow-engine/src/usage.ts +21 -32
  69. package/.pi/extensions/pi-workflow-engine/src/workflow-advisory-utils.ts +56 -19
  70. package/.pi/extensions/pi-workflow-engine/src/workflow-execution.ts +19 -24
  71. package/.pi/extensions/pi-workflow-engine/src/workflow-module.ts +17 -37
  72. package/.pi/extensions/pi-workflow-engine/src/workflow-result-context.ts +79 -0
  73. package/.pi/extensions/pi-workflow-engine/src/workflow-run-background.ts +4 -14
  74. package/.pi/extensions/pi-workflow-engine/src/workflow-run-controller.ts +77 -63
  75. package/.pi/extensions/pi-workflow-engine/src/workflow-run-history.ts +42 -29
  76. package/.pi/extensions/pi-workflow-engine/src/workflow-run-record.ts +15 -14
  77. package/.pi/extensions/pi-workflow-engine/src/workflow-run-store.ts +27 -31
  78. package/.pi/extensions/pi-workflow-engine/src/workflow-usage-limit-scheduler.ts +1 -5
  79. package/.pi/extensions/pi-workflow-engine/src/workflows.ts +3 -6
  80. package/.pi/extensions/pi-workflow-engine/src/worktree.ts +48 -110
  81. package/.pi/extensions/pi-workflow-engine/workflows/code-review.ts +64 -93
  82. package/.pi/extensions/pi-workflow-engine/workflows/diagnose.ts +25 -67
  83. package/.pi/extensions/pi-workflow-engine/workflows/perf-review.ts +19 -53
  84. package/.pi/extensions/pi-workflow-engine/workflows/refactor-scout.ts +21 -49
  85. package/.pi/extensions/pi-workflow-engine/workflows/research.ts +74 -66
  86. package/USAGE.md +31 -17
  87. package/package.json +1 -2
  88. package/skills/workflow-code-review-actions/SKILL.md +12 -23
  89. package/.pi/extensions/pi-workflow-engine/src/diff-capture.ts +0 -81
  90. package/.pi/extensions/pi-workflow-engine/src/review/code-review-orchestration.ts +0 -24
  91. package/.pi/extensions/pi-workflow-engine/src/review/review-results-flow.ts +0 -52
  92. package/.pi/settings.json +0 -3
@@ -0,0 +1,461 @@
1
+ import type { Theme } from "@earendil-works/pi-coding-agent";
2
+ import { decodeKittyPrintable, matchesKey, visibleWidth, type Component, type TUI } from "@earendil-works/pi-tui";
3
+ import { isRecord } from "../guards.ts";
4
+ import { formatCount, truncateText } from "../text.ts";
5
+ import {
6
+ availableWorkflowRunActions,
7
+ canRelaunchWorkflowRun,
8
+ formatWorkflowRunDuration,
9
+ type WorkflowRunActionContext,
10
+ type WorkflowRunLifecycleAction,
11
+ } from "../workflow-run-history.ts";
12
+ import type { WorkflowRunRecord, WorkflowRunState } from "../workflow-run-record.ts";
13
+ import {
14
+ fit,
15
+ formatCost,
16
+ frame,
17
+ GLYPH,
18
+ hangingWrap,
19
+ joinParts,
20
+ keyHints,
21
+ overlayHeight,
22
+ padStartVisible,
23
+ relativeTime,
24
+ scrollHint,
25
+ type ThemeColor,
26
+ } from "./kit.ts";
27
+ import { countAgents, formatDuration } from "./workflow-format.ts";
28
+
29
+ export interface WorkflowRunsBrowserChoice {
30
+ readonly action: WorkflowRunLifecycleAction;
31
+ readonly runId: string;
32
+ }
33
+
34
+ export interface WorkflowRunsListing {
35
+ readonly records: readonly WorkflowRunRecord[];
36
+ readonly runs: WorkflowRunActionContext;
37
+ }
38
+
39
+ export interface WorkflowRunsBrowserOptions {
40
+ /** Run to place the cursor on, e.g. the one just inspected. */
41
+ readonly initialRunId?: string;
42
+ /** Reloads the listing; polled while any run is queued or running so states and times stay live. */
43
+ readonly refresh?: () => Promise<WorkflowRunsListing>;
44
+ readonly refreshMs?: number;
45
+ readonly now?: () => number;
46
+ }
47
+
48
+ type BrowserTui = Pick<TUI, "requestRender" | "terminal">;
49
+ type Hint = readonly [key: string, description: string];
50
+
51
+ const STATE_STYLE: Record<WorkflowRunState, { readonly glyph: string; readonly color: ThemeColor }> = {
52
+ completed: { glyph: GLYPH.done, color: "success" },
53
+ failed: { glyph: GLYPH.failed, color: "error" },
54
+ running: { glyph: GLYPH.running, color: "accent" },
55
+ queued: { glyph: GLYPH.queued, color: "muted" },
56
+ paused: { glyph: GLYPH.warning, color: "warning" },
57
+ stopped: { glyph: "■", color: "muted" },
58
+ };
59
+
60
+ const ACTION_KEYS: Record<WorkflowRunLifecycleAction, Hint> = {
61
+ inspect: ["enter", "inspect"],
62
+ resume: ["r", "resume"],
63
+ stop: ["s", "stop"],
64
+ restart: ["R", "restart"],
65
+ };
66
+
67
+ const SHORT_ID = 8;
68
+ const NAME_MAX = 28;
69
+ const NAME_MIN = 10;
70
+ const LABEL_WIDTH = 10;
71
+ const OUTCOME_LINES = 3;
72
+ const OUTCOME_CHARS = 600;
73
+ const LIST_MIN = 3;
74
+ const REFRESH_MS = 1_000;
75
+
76
+ interface RunCells {
77
+ readonly name: string;
78
+ readonly state: string;
79
+ readonly duration: string;
80
+ readonly age: string;
81
+ readonly cost: string;
82
+ readonly id: string;
83
+ }
84
+
85
+ /**
86
+ * `/workflow:runs` overlay: aligned run rows above a details pane for the cursor run, with the
87
+ * run's lifecycle actions on single keys, gated by the same availability rules as the command.
88
+ */
89
+ export class WorkflowRunsBrowser implements Component {
90
+ private records: readonly WorkflowRunRecord[];
91
+ private runs: WorkflowRunActionContext;
92
+ private cursor = 0;
93
+ private scroll = 0;
94
+ private warning: string | undefined;
95
+ private pendingStop: string | undefined;
96
+ private refreshTimer: ReturnType<typeof setInterval> | undefined;
97
+ private refreshing = false;
98
+ private readonly now: () => number;
99
+
100
+ constructor(
101
+ listing: WorkflowRunsListing,
102
+ private readonly tui: BrowserTui,
103
+ private readonly theme: Theme,
104
+ private readonly done: (choice: WorkflowRunsBrowserChoice | undefined) => void,
105
+ private readonly options: WorkflowRunsBrowserOptions = {},
106
+ ) {
107
+ this.records = listing.records;
108
+ this.runs = listing.runs;
109
+ this.now = options.now ?? Date.now;
110
+ this.cursor = Math.max(0, this.records.findIndex((record) => record.runId === options.initialRunId));
111
+ this.updateRefreshTimer();
112
+ }
113
+
114
+ invalidate(): void {}
115
+
116
+ dispose(): void {
117
+ clearInterval(this.refreshTimer);
118
+ this.refreshTimer = undefined;
119
+ }
120
+
121
+ /** Swaps in a fresh listing, keeping the cursor on the same run. */
122
+ async refresh(): Promise<void> {
123
+ if (!this.options.refresh || this.refreshing) return;
124
+ this.refreshing = true;
125
+ try {
126
+ const cursorRunId = this.records[this.cursor]?.runId;
127
+ const listing = await this.options.refresh();
128
+ this.records = listing.records;
129
+ this.runs = listing.runs;
130
+ const index = this.records.findIndex((record) => record.runId === cursorRunId);
131
+ this.cursor = index >= 0 ? index : Math.min(this.cursor, Math.max(0, this.records.length - 1));
132
+ this.updateRefreshTimer();
133
+ this.tui.requestRender();
134
+ } catch {
135
+ // A failed reload keeps the last listing on screen; the next tick tries again.
136
+ } finally {
137
+ this.refreshing = false;
138
+ }
139
+ }
140
+
141
+ private updateRefreshTimer(): void {
142
+ const live = this.records.some((record) => record.state === "queued" || record.state === "running");
143
+ if (live && this.options.refresh && !this.refreshTimer) {
144
+ this.refreshTimer = setInterval(() => void this.refresh(), this.options.refreshMs ?? REFRESH_MS);
145
+ this.refreshTimer.unref?.();
146
+ } else if (!live) {
147
+ this.dispose();
148
+ }
149
+ }
150
+
151
+ render(width: number): string[] {
152
+ const inner = Math.max(1, width - 4);
153
+ const now = this.now();
154
+ const ceiling = Math.max(4, overlayHeight(this.tui.terminal.rows, Number.MAX_SAFE_INTEGER) - 2);
155
+ const detailRows = Math.min(
156
+ Math.max(0, ...this.records.map((record) => this.details(record, inner, now).length)),
157
+ Math.max(1, ceiling - 1 - Math.min(this.records.length, LIST_MIN)),
158
+ );
159
+ const listRows = Math.max(1, Math.min(this.records.length, ceiling - 1 - detailRows));
160
+ const record = this.records[this.cursor];
161
+ const details = record ? this.details(record, inner, now).slice(0, detailRows) : [];
162
+ const body = [
163
+ ...this.renderList(inner, listRows, now),
164
+ this.theme.fg("borderMuted", "─".repeat(inner)),
165
+ ...details,
166
+ ...Array.from({ length: detailRows - details.length }, () => ""),
167
+ ];
168
+ return frame(body, width, this.theme, {
169
+ title: this.theme.fg("accent", this.theme.bold("Workflow runs")),
170
+ right: this.counts(),
171
+ footer: this.footer(inner),
172
+ });
173
+ }
174
+
175
+ handleInput(data: string): void {
176
+ const key = decodeKittyPrintable(data) ?? data;
177
+ if (key === "q" || matchesKey(data, "escape")) {
178
+ this.finish(undefined);
179
+ return;
180
+ }
181
+ const stopArmed = this.pendingStop;
182
+ this.pendingStop = undefined;
183
+ this.warning = undefined;
184
+ if (matchesKey(data, "up") || key === "k") this.move(-1);
185
+ else if (matchesKey(data, "down") || key === "j") this.move(1);
186
+ else if (matchesKey(data, "home") || key === "g") this.move(-this.records.length);
187
+ else if (matchesKey(data, "end") || key === "G") this.move(this.records.length);
188
+ else if (matchesKey(data, "enter") || key === "i") this.choose("inspect");
189
+ else if (key === "r") this.choose("resume");
190
+ else if (key === "R") this.choose("restart");
191
+ else if (key === "s") this.chooseStop(stopArmed);
192
+ this.tui.requestRender();
193
+ }
194
+
195
+ private renderList(width: number, height: number, now: number): string[] {
196
+ const cells = this.records.map((record) => runCells(record, now));
197
+ const rows = this.records.length > height ? Math.max(1, height - 1) : height;
198
+ if (this.cursor < this.scroll) this.scroll = this.cursor;
199
+ if (this.cursor >= this.scroll + rows) this.scroll = this.cursor - rows + 1;
200
+ this.scroll = Math.max(0, Math.min(this.scroll, this.records.length - rows));
201
+ const layout = columnLayout(cells, width);
202
+ const visible = this.records
203
+ .slice(this.scroll, this.scroll + rows)
204
+ .map((record, offset) => this.renderRow(record, cells[this.scroll + offset], this.scroll + offset, layout));
205
+ if (this.records.length <= height) return visible;
206
+ const hint = scrollHint(this.scroll, this.records.length - this.scroll - visible.length, this.theme) ?? "";
207
+ return [...visible, ` ${hint}`];
208
+ }
209
+
210
+ private renderRow(record: WorkflowRunRecord, cells: RunCells | undefined, index: number, layout: ColumnLayout): string {
211
+ if (!cells) return "";
212
+ const style = STATE_STYLE[record.state];
213
+ const selected = index === this.cursor;
214
+ const cursor = selected ? this.theme.fg("accent", GLYPH.cursor) : " ";
215
+ const name = fit(cells.name, layout.name);
216
+ const parts = [
217
+ `${cursor} ${this.theme.fg(style.color, style.glyph)} ${selected ? this.theme.bold(name) : name}`,
218
+ this.theme.fg(style.color, fit(cells.state, layout.state)),
219
+ ];
220
+ if (layout.duration > 0) parts.push(this.theme.fg("muted", padStartVisible(cells.duration, layout.duration)));
221
+ if (layout.age > 0) parts.push(this.theme.fg("muted", padStartVisible(cells.age, layout.age)));
222
+ if (layout.cost > 0) parts.push(this.theme.fg("muted", padStartVisible(cells.cost, layout.cost)));
223
+ if (layout.id > 0) parts.push(this.theme.fg("dim", cells.id));
224
+ return parts.join(" ");
225
+ }
226
+
227
+ /** Facts about one run that its row does not already show; each value arrives already styled. */
228
+ private details(record: WorkflowRunRecord, width: number, now: number): string[] {
229
+ const theme = this.theme;
230
+ const muted = (text: string) => theme.fg("muted", text);
231
+ const lines: string[] = [];
232
+ const push = (label: string, value: string | undefined, maxLines = 1) => {
233
+ if (!value) return;
234
+ lines.push(...hangingWrap(value, width, theme.fg("dim", label.padEnd(LABEL_WIDTH, " "))).slice(0, maxLines));
235
+ };
236
+
237
+ // Age, duration and cost are in the row; the pane adds wall-clock times and the rest.
238
+ if (record.startedAt === undefined) push("Queued", muted(clockTime(record.createdAt, now)));
239
+ else {
240
+ const ended = record.endedAt ?? (record.state === "paused" ? record.updatedAt : undefined);
241
+ push("Started", joinParts([muted(clockTime(record.startedAt, now)), ended ? muted(`ended ${clockTime(ended, now)}`) : undefined], theme));
242
+ }
243
+
244
+ // The tracker's implicit opening phase stays empty unless a workflow never names one.
245
+ const current = record.state === "running" ? record.progress.currentPhase : undefined;
246
+ const phases = record.progress.phases
247
+ .filter((phase) => phase.agents.length > 0 || phase.title === current)
248
+ .map((phase) => (phase.title === current ? theme.fg("accent", phase.title) : muted(phase.title)));
249
+ push("Phases", phases.join(theme.fg("dim", ` ${GLYPH.arrow} `)));
250
+
251
+ const counts = countAgents(record.progress.phases);
252
+ if (counts.total > 0) {
253
+ push("Agents", joinParts([
254
+ muted(String(counts.total)),
255
+ counts.running > 0 ? theme.fg("accent", `${counts.running} running`) : undefined,
256
+ counts.failed > 0 ? theme.fg("error", `${counts.failed} failed`) : undefined,
257
+ ], theme));
258
+ }
259
+ const tokens = tokenUsage(record);
260
+ push("Tokens", tokens && muted(tokens));
261
+
262
+ const resumedFrom = record.options.resumeFromRunId;
263
+ const resumedAs = this.runs.resumedAs.get(record.runId);
264
+ push("Resumed", joinParts([
265
+ resumedFrom ? muted(`from ${shortId(resumedFrom)}`) : undefined,
266
+ resumedAs ? muted(`superseded by ${shortId(resumedAs)}`) : undefined,
267
+ ], theme));
268
+
269
+ if (record.state === "paused" && record.pause) {
270
+ const pause = record.pause;
271
+ push("Paused", joinParts([
272
+ theme.fg("warning", "provider usage limit"),
273
+ muted(`attempt ${pause.attempt}/${pause.maxAttempts}`),
274
+ muted(pause.nextEligibleAt > now ? `eligible in ${formatDuration(pause.nextEligibleAt - now)}` : "eligible now"),
275
+ pause.autoResume ? muted("auto-resume on") : undefined,
276
+ ], theme), 2);
277
+ }
278
+
279
+ const outcome = runOutcome(record);
280
+ const outcomeColor: ThemeColor = record.state === "failed" ? "error" : record.state === "completed" ? "text" : "warning";
281
+ push("Outcome", outcome && theme.fg(outcomeColor, outcome), OUTCOME_LINES);
282
+
283
+ const blocker = relaunchBlocker(record);
284
+ push(record.state === "paused" ? "Resume" : "Restart", blocker && theme.fg("dim", `unavailable · ${blocker}`));
285
+ return lines;
286
+ }
287
+
288
+ private counts(): string {
289
+ const total = `${this.records.length} run${this.records.length === 1 ? "" : "s"}`;
290
+ const byState = (state: WorkflowRunState) => this.records.filter((record) => record.state === state).length;
291
+ const running = byState("running") + byState("queued");
292
+ const failed = byState("failed");
293
+ const paused = byState("paused");
294
+ return joinParts([
295
+ this.theme.fg("muted", total),
296
+ running > 0 ? this.theme.fg("accent", `${running} active`) : undefined,
297
+ paused > 0 ? this.theme.fg("warning", `${paused} paused`) : undefined,
298
+ failed > 0 ? this.theme.fg("error", `${failed} failed`) : undefined,
299
+ ], this.theme);
300
+ }
301
+
302
+ private footer(inner: number): string {
303
+ if (this.warning) return this.theme.fg("warning", `${GLYPH.warning} ${this.warning}`);
304
+ const record = this.records[this.cursor];
305
+ const actions = record ? this.actions(record).map((action) => ACTION_KEYS[action]) : [];
306
+ const variants: Hint[][] = [
307
+ [["↑↓", "move"], ...actions, ["q", "close"]],
308
+ [...actions, ["q", "close"]],
309
+ [...actions],
310
+ ];
311
+ const rendered = variants.map((hints) => keyHints(hints, this.theme));
312
+ return rendered.find((hints) => visibleWidth(hints) <= inner - 2) ?? rendered[rendered.length - 1] ?? "";
313
+ }
314
+
315
+ private finish(choice: WorkflowRunsBrowserChoice | undefined): void {
316
+ this.dispose();
317
+ this.done(choice);
318
+ }
319
+
320
+ private actions(record: WorkflowRunRecord): readonly WorkflowRunLifecycleAction[] {
321
+ return availableWorkflowRunActions(record, this.runs);
322
+ }
323
+
324
+ private move(delta: number): void {
325
+ if (this.records.length === 0) return;
326
+ this.cursor = Math.min(this.records.length - 1, Math.max(0, this.cursor + delta));
327
+ }
328
+
329
+ private choose(action: WorkflowRunLifecycleAction): void {
330
+ const record = this.records[this.cursor];
331
+ if (!record) return;
332
+ if (!this.actions(record).includes(action)) {
333
+ this.warning = unavailableReason(action, record, this.runs);
334
+ return;
335
+ }
336
+ this.finish({ action, runId: record.runId });
337
+ }
338
+
339
+ /** Stop ends live work, so it takes a second press on the same run. */
340
+ private chooseStop(armed: string | undefined): void {
341
+ const record = this.records[this.cursor];
342
+ if (!record) return;
343
+ if (!this.actions(record).includes("stop")) {
344
+ this.warning = unavailableReason("stop", record, this.runs);
345
+ return;
346
+ }
347
+ if (armed === record.runId) {
348
+ this.finish({ action: "stop", runId: record.runId });
349
+ return;
350
+ }
351
+ this.pendingStop = record.runId;
352
+ this.warning = `press s again to stop ${record.workflow.name} ${shortId(record.runId)}`;
353
+ }
354
+ }
355
+
356
+ interface ColumnLayout {
357
+ readonly name: number;
358
+ readonly state: number;
359
+ readonly duration: number;
360
+ readonly age: number;
361
+ readonly cost: number;
362
+ readonly id: number;
363
+ }
364
+
365
+ /** Column widths from the widest cell; when the row is too wide the name shrinks, then cost, age and id drop. */
366
+ function columnLayout(cells: readonly RunCells[], width: number): ColumnLayout {
367
+ const widest = (pick: (cell: RunCells) => string) => Math.max(0, ...cells.map((cell) => visibleWidth(pick(cell))));
368
+ let layout: ColumnLayout = {
369
+ name: Math.min(NAME_MAX, widest((cell) => cell.name)),
370
+ state: widest((cell) => cell.state),
371
+ duration: widest((cell) => cell.duration),
372
+ age: widest((cell) => cell.age),
373
+ cost: widest((cell) => cell.cost),
374
+ id: SHORT_ID,
375
+ };
376
+ const total = (candidate: ColumnLayout) =>
377
+ 4 + candidate.name + 2 + candidate.state
378
+ + [candidate.duration, candidate.age, candidate.cost, candidate.id].reduce((sum, column) => sum + (column > 0 ? column + 2 : 0), 0);
379
+ const overflow = total(layout) - width;
380
+ if (overflow > 0) layout = { ...layout, name: Math.max(Math.min(NAME_MIN, layout.name), layout.name - overflow) };
381
+ for (const column of ["cost", "age", "id", "duration"] as const) {
382
+ if (total(layout) <= width) break;
383
+ layout = { ...layout, [column]: 0 };
384
+ }
385
+ return layout;
386
+ }
387
+
388
+ function runCells(record: WorkflowRunRecord, now: number): RunCells {
389
+ const cost = record.usage?.totals.cost.total ?? 0;
390
+ return {
391
+ name: record.workflow.name,
392
+ state: record.state,
393
+ duration: record.state === "queued" ? "" : formatWorkflowRunDuration(record, now),
394
+ age: relativeTime(record.createdAt, now),
395
+ cost: cost > 0 ? formatCost(cost) : "",
396
+ id: shortId(record.runId),
397
+ };
398
+ }
399
+
400
+ function shortId(runId: string): string {
401
+ return runId.slice(0, SHORT_ID);
402
+ }
403
+
404
+ /** `↑34k ↓1.2k`; cost is already a row column. */
405
+ function tokenUsage(record: WorkflowRunRecord): string | undefined {
406
+ const totals = record.usage?.totals;
407
+ if (!totals || totals.totalTokens <= 0) return undefined;
408
+ return `↑${formatCount(totals.input + totals.cacheRead + totals.cacheWrite)} ↓${formatCount(totals.output)}`;
409
+ }
410
+
411
+ /** `14:02:31` today, `Oct 4 14:02:31` otherwise, in local time. */
412
+ function clockTime(at: number, now: number): string {
413
+ const date = new Date(at);
414
+ const time = [date.getHours(), date.getMinutes(), date.getSeconds()].map((part) => String(part).padStart(2, "0")).join(":");
415
+ if (date.toDateString() === new Date(now).toDateString()) return time;
416
+ return `${date.toLocaleString("en", { month: "short" })} ${date.getDate()} ${time}`;
417
+ }
418
+
419
+ const outcomes = new WeakMap<WorkflowRunRecord, string | undefined>();
420
+
421
+ /**
422
+ * A short outcome line, memoised per record and bounded before wrapping: a stored result can be large,
423
+ * and every run's details are measured on each render to keep the overlay height stable.
424
+ */
425
+ function runOutcome(record: WorkflowRunRecord): string | undefined {
426
+ if (!outcomes.has(record)) {
427
+ const outcome = describeOutcome(record);
428
+ outcomes.set(record, outcome === undefined ? undefined : truncateText(outcome, OUTCOME_CHARS));
429
+ }
430
+ return outcomes.get(record);
431
+ }
432
+
433
+ function describeOutcome(record: WorkflowRunRecord): string | undefined {
434
+ if (record.state === "failed" || record.state === "stopped" || record.state === "paused") return record.message;
435
+ if (record.state !== "completed") return undefined;
436
+ if (record.result.kind === "unavailable") return `result unavailable: ${record.result.reason}`;
437
+ const value = record.result.value;
438
+ if (isRecord(value)) {
439
+ const findings = Array.isArray(value.findings) ? `${value.findings.length} finding${value.findings.length === 1 ? "" : "s"}` : undefined;
440
+ const summary = typeof value.summary === "string" ? value.summary : undefined;
441
+ if (findings || summary) return [findings, summary].filter(Boolean).join(" · ");
442
+ }
443
+ return typeof value === "string" ? value : JSON.stringify(value);
444
+ }
445
+
446
+ /** Why a finished or paused run cannot be relaunched, mirroring `canRelaunchWorkflowRun`. */
447
+ function relaunchBlocker(record: WorkflowRunRecord): string | undefined {
448
+ if (record.state === "queued" || record.state === "running" || canRelaunchWorkflowRun(record)) return undefined;
449
+ if (record.workflow.sourceKind !== "file" || typeof record.workflow.sourceFingerprint !== "string") {
450
+ return "the workflow source cannot be verified";
451
+ }
452
+ return record.options.argumentsPresent !== false ? "its arguments were not retained" : undefined;
453
+ }
454
+
455
+ function unavailableReason(action: WorkflowRunLifecycleAction, record: WorkflowRunRecord, runs: WorkflowRunActionContext): string {
456
+ const resumedAs = action === "resume" ? runs.resumedAs.get(record.runId) : undefined;
457
+ if (resumedAs) return `already resumed as ${shortId(resumedAs)}`;
458
+ const relaunch = (action === "resume" && record.state === "paused") || (action === "restart" && record.state !== "paused");
459
+ const blocker = relaunch ? relaunchBlocker(record) : undefined;
460
+ return blocker ? `${action} unavailable · ${blocker}` : `${action} is not available for a ${record.state} run`;
461
+ }
@@ -0,0 +1,91 @@
1
+ import type { Theme } from "@earendil-works/pi-coding-agent";
2
+ import { truncateToWidth, visibleWidth, type Component } from "@earendil-works/pi-tui";
3
+ import { isRecord } from "../guards.ts";
4
+ import { readInlineWorkflowMeta } from "../inline-workflow.ts";
5
+ import { GLYPH, hangingWrap, keyHints, spread } from "./kit.ts";
6
+ import { LinesComponent, shortRunId, type WorkflowResultHints } from "./result-layout.ts";
7
+ import { isWorkflowResult, workflowResultComponent } from "./workflow-result-renderer.ts";
8
+
9
+ /** The workflow tool arguments the call row reads; everything is optional while arguments stream in. */
10
+ export interface WorkflowToolCallArgs {
11
+ readonly name?: string;
12
+ readonly script?: string;
13
+ readonly args?: string;
14
+ readonly background?: boolean;
15
+ readonly resumeFromRunId?: string;
16
+ }
17
+
18
+ export interface WorkflowToolResultView {
19
+ readonly content: readonly ({ readonly type: "text"; readonly text: string } | { readonly type: "image" })[];
20
+ readonly details: unknown;
21
+ }
22
+
23
+ /** A named workflow, or an inline one identified by its literal meta (`inline` until that parses). */
24
+ export function workflowToolTitle(args: WorkflowToolCallArgs): { readonly name: string; readonly detail?: string } {
25
+ const detail = collapseWhitespace(args.args ?? "");
26
+ const named = args.name?.trim();
27
+ if (named) return { name: named, detail: detail || undefined };
28
+ const meta = args.script ? readInlineWorkflowMeta(args.script) : undefined;
29
+ return { name: meta?.name ?? "inline", detail: detail || collapseWhitespace(meta?.description ?? "") || undefined };
30
+ }
31
+
32
+ /** `▸ workflow code-review · HEAD~3 (background)`: the detail is shortened before the tags are. */
33
+ export function renderWorkflowToolCall(args: WorkflowToolCallArgs, theme: Theme): Component {
34
+ return new LinesComponent((width) => [workflowToolCallLine(args, width, theme)]);
35
+ }
36
+
37
+ export function workflowToolCallLine(args: WorkflowToolCallArgs, width: number, theme: Theme): string {
38
+ const { name, detail } = workflowToolTitle(args);
39
+ const head = `▸ ${theme.fg("toolTitle", theme.bold("workflow"))} ${theme.fg("accent", name)}`;
40
+ const resume = args.resumeFromRunId?.trim();
41
+ const tags = [args.background ? "background" : undefined, resume ? `resume ${shortRunId(resume)}` : undefined].filter(Boolean);
42
+ const tail = tags.length > 0 ? theme.fg("dim", ` (${tags.join(", ")})`) : "";
43
+ const room = width - visibleWidth(head) - visibleWidth(tail) - 3;
44
+ if (!detail || room < 8) return truncateToWidth(head + tail, width, "…");
45
+ return `${head}${theme.fg("dim", " · ")}${theme.fg("muted", truncateToWidth(detail, room, "…"))}${tail}`;
46
+ }
47
+
48
+ /**
49
+ * The tool row's result: `● running repo-scan` while the run is live, the full result renderer once it
50
+ * returns, a one-line receipt for a background start, and the error text for a rejected call.
51
+ */
52
+ export function renderWorkflowToolResult(
53
+ result: WorkflowToolResultView,
54
+ options: { readonly expanded: boolean; readonly isPartial: boolean },
55
+ theme: Theme,
56
+ args: WorkflowToolCallArgs,
57
+ hints: WorkflowResultHints,
58
+ ): Component {
59
+ const details = result.details;
60
+ if (options.isPartial) {
61
+ // The live widget below names the inspector shortcut for the whole run.
62
+ return new LinesComponent(() => [`${theme.fg("accent", GLYPH.running)} ${theme.fg("muted", "running")} ${theme.bold(workflowToolTitle(args).name)}`]);
63
+ }
64
+ if (isWorkflowResult(details)) return workflowResultComponent(details, options.expanded, theme, hints);
65
+ if (isRecord(details) && details.background === true && typeof details.runId === "string") {
66
+ const name = typeof details.name === "string" ? details.name : workflowToolTitle(args).name;
67
+ const runId = details.runId;
68
+ // A receipt, not a live status: it stays in the transcript after the run finishes.
69
+ return new LinesComponent((width) => [
70
+ spread(
71
+ `${theme.fg("accent", GLYPH.workflow)} ${theme.bold(name)} ${theme.fg("muted", "started in background")}`,
72
+ theme.fg("dim", `run ${shortRunId(runId)}`),
73
+ width,
74
+ ),
75
+ ` ${theme.fg("muted", "its result is posted here when it finishes")}${theme.fg("dim", " · ")}${keyHints([["/workflow:runs", "manage"]], theme)}`,
76
+ ]);
77
+ }
78
+ const text = result.content.flatMap((part) => (part.type === "text" ? [part.text] : [])).join("\n").trim() || "Workflow finished.";
79
+ const failed = isRecord(details) && typeof details.error === "string";
80
+ const gutter = failed ? " " : "";
81
+ return new LinesComponent((width) => text.split("\n").flatMap((line, index) => hangingWrap(
82
+ theme.fg(failed ? "error" : "muted", line),
83
+ width,
84
+ index === 0 && failed ? `${theme.fg("error", GLYPH.failed)} ` : gutter,
85
+ gutter,
86
+ )));
87
+ }
88
+
89
+ function collapseWhitespace(text: string): string {
90
+ return text.replace(/\s+/g, " ").trim();
91
+ }