@jameslovespancakes/pi-plus 1.0.11 → 1.0.13

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 (133) hide show
  1. package/README.md +67 -22
  2. package/config/skills/workflow-code-review-actions/SKILL.md +44 -0
  3. package/package.json +9 -4
  4. package/src/core/accounts/oauth-pool.ts +137 -0
  5. package/src/core/accounts/registry.ts +12 -25
  6. package/src/core/accounts/routing.ts +144 -0
  7. package/src/core/anthropic/client-identity.ts +9 -64
  8. package/src/core/anthropic/identity.ts +54 -0
  9. package/src/core/anthropic/oauth.ts +3 -0
  10. package/src/core/anthropic/quota.ts +232 -78
  11. package/src/core/anthropic/routing.ts +40 -115
  12. package/src/core/anthropic/store.ts +11 -10
  13. package/src/core/catalog/quality.ts +33 -15
  14. package/src/core/codex/quota.ts +46 -8
  15. package/src/core/codex/store.ts +29 -16
  16. package/src/core/config.ts +16 -17
  17. package/src/core/store.ts +13 -12
  18. package/src/domains/agents/format.ts +97 -0
  19. package/src/domains/agents/index.ts +58 -47
  20. package/src/domains/compact/archive.ts +140 -0
  21. package/src/domains/compact/chunking.ts +166 -0
  22. package/src/domains/compact/index.ts +452 -0
  23. package/src/domains/compact/jev.ts +242 -0
  24. package/src/domains/compact/policy.ts +255 -0
  25. package/src/domains/compact/types.ts +79 -0
  26. package/src/domains/models/provider-picker.ts +1 -1
  27. package/src/domains/setup/index.ts +23 -23
  28. package/src/domains/subscriptions/accounts-picker.ts +61 -40
  29. package/src/domains/subscriptions/accounts.ts +53 -36
  30. package/src/domains/subscriptions/footer.ts +48 -29
  31. package/src/domains/subscriptions/index.ts +11 -27
  32. package/src/domains/subscriptions/provider.ts +79 -103
  33. package/src/domains/subscriptions/providers/anthropic.ts +42 -22
  34. package/src/domains/subscriptions/providers/codex.ts +146 -113
  35. package/src/domains/subscriptions/providers/hosted.ts +18 -0
  36. package/src/domains/subscriptions/providers/oauth-pool.ts +325 -0
  37. package/src/domains/subscriptions/routing.ts +19 -11
  38. package/src/domains/workflows/LICENSE.md +21 -0
  39. package/src/domains/workflows/index.ts +836 -0
  40. package/src/domains/workflows/runtime/advisory-challenge.ts +75 -0
  41. package/src/domains/workflows/runtime/advisory-evidence.ts +90 -0
  42. package/src/domains/workflows/runtime/advisory-schema.ts +83 -0
  43. package/src/domains/workflows/runtime/agent-attempt.ts +177 -0
  44. package/src/domains/workflows/runtime/agent-failure.ts +14 -0
  45. package/src/domains/workflows/runtime/agent-limits.ts +66 -0
  46. package/src/domains/workflows/runtime/agent-replay.ts +399 -0
  47. package/src/domains/workflows/runtime/agent-retry.ts +116 -0
  48. package/src/domains/workflows/runtime/agent-runner-types.ts +96 -0
  49. package/src/domains/workflows/runtime/agent-runner.ts +225 -0
  50. package/src/domains/workflows/runtime/agent-session-identity.ts +256 -0
  51. package/src/domains/workflows/runtime/agent-session-providers.ts +50 -0
  52. package/src/domains/workflows/runtime/agent-session.ts +382 -0
  53. package/src/domains/workflows/runtime/agent-skills.ts +270 -0
  54. package/src/domains/workflows/runtime/agent-workspace.ts +79 -0
  55. package/src/domains/workflows/runtime/background-workflow-tool.ts +75 -0
  56. package/src/domains/workflows/runtime/background-workflows.ts +492 -0
  57. package/src/domains/workflows/runtime/budget.ts +53 -0
  58. package/src/domains/workflows/runtime/cancellation.ts +87 -0
  59. package/src/domains/workflows/runtime/command-completions.ts +36 -0
  60. package/src/domains/workflows/runtime/concurrency.ts +403 -0
  61. package/src/domains/workflows/runtime/debug.ts +3 -0
  62. package/src/domains/workflows/runtime/diff-capture.ts +81 -0
  63. package/src/domains/workflows/runtime/discovery.ts +137 -0
  64. package/src/domains/workflows/runtime/dynamax-shortcuts.ts +122 -0
  65. package/src/domains/workflows/runtime/dynamax.ts +330 -0
  66. package/src/domains/workflows/runtime/engine.ts +579 -0
  67. package/src/domains/workflows/runtime/filesystem-error.ts +4 -0
  68. package/src/domains/workflows/runtime/finalizers.ts +66 -0
  69. package/src/domains/workflows/runtime/identity-canonicalization.ts +138 -0
  70. package/src/domains/workflows/runtime/identity-fingerprint.ts +15 -0
  71. package/src/domains/workflows/runtime/inline-workflow.ts +403 -0
  72. package/src/domains/workflows/runtime/journal.ts +313 -0
  73. package/src/domains/workflows/runtime/model-profiles.ts +310 -0
  74. package/src/domains/workflows/runtime/options.ts +157 -0
  75. package/src/domains/workflows/runtime/perf.ts +146 -0
  76. package/src/domains/workflows/runtime/pi-compat.ts +32 -0
  77. package/src/domains/workflows/runtime/process-runner.ts +260 -0
  78. package/src/domains/workflows/runtime/progress-types.ts +59 -0
  79. package/src/domains/workflows/runtime/progress.ts +400 -0
  80. package/src/domains/workflows/runtime/provider-usage-limit.ts +191 -0
  81. package/src/domains/workflows/runtime/replay-path-identity.ts +48 -0
  82. package/src/domains/workflows/runtime/research-contract.ts +89 -0
  83. package/src/domains/workflows/runtime/research-evidence.ts +289 -0
  84. package/src/domains/workflows/runtime/resume-context.ts +751 -0
  85. package/src/domains/workflows/runtime/review/code-review-orchestration.ts +24 -0
  86. package/src/domains/workflows/runtime/review/github-pr-comments.ts +249 -0
  87. package/src/domains/workflows/runtime/review/patch-validation.ts +120 -0
  88. package/src/domains/workflows/runtime/review/review-actions.ts +180 -0
  89. package/src/domains/workflows/runtime/review/review-budget.ts +45 -0
  90. package/src/domains/workflows/runtime/review/review-fix-workflow.ts +153 -0
  91. package/src/domains/workflows/runtime/review/review-format.ts +133 -0
  92. package/src/domains/workflows/runtime/review/review-handoff.ts +38 -0
  93. package/src/domains/workflows/runtime/review/review-issues.ts +87 -0
  94. package/src/domains/workflows/runtime/review/review-report.ts +42 -0
  95. package/src/domains/workflows/runtime/review/review-results-flow.ts +52 -0
  96. package/src/domains/workflows/runtime/review/review-results-viewer.ts +312 -0
  97. package/src/domains/workflows/runtime/review/review-session-coordinator.ts +153 -0
  98. package/src/domains/workflows/runtime/review/review-snapshot.ts +294 -0
  99. package/src/domains/workflows/runtime/review-diff-target.ts +130 -0
  100. package/src/domains/workflows/runtime/session-identity.ts +6 -0
  101. package/src/domains/workflows/runtime/structured-output.ts +32 -0
  102. package/src/domains/workflows/runtime/tool-capabilities.ts +44 -0
  103. package/src/domains/workflows/runtime/tool-source-identity.ts +150 -0
  104. package/src/domains/workflows/runtime/tree-fingerprint.ts +404 -0
  105. package/src/domains/workflows/runtime/types.ts +255 -0
  106. package/src/domains/workflows/runtime/ui/display-text.ts +28 -0
  107. package/src/domains/workflows/runtime/ui/dynamax-editor-decoration.ts +220 -0
  108. package/src/domains/workflows/runtime/ui/workflow-format.ts +165 -0
  109. package/src/domains/workflows/runtime/ui/workflow-inspector.ts +435 -0
  110. package/src/domains/workflows/runtime/ui/workflow-result-renderer.ts +171 -0
  111. package/src/domains/workflows/runtime/ui/workflow-viewer-layout.ts +51 -0
  112. package/src/domains/workflows/runtime/ui/workflow-widget.ts +78 -0
  113. package/src/domains/workflows/runtime/unknown-error.ts +8 -0
  114. package/src/domains/workflows/runtime/usage.ts +341 -0
  115. package/src/domains/workflows/runtime/workflow-advisory-utils.ts +245 -0
  116. package/src/domains/workflows/runtime/workflow-execution.ts +121 -0
  117. package/src/domains/workflows/runtime/workflow-module.ts +75 -0
  118. package/src/domains/workflows/runtime/workflow-run-background.ts +72 -0
  119. package/src/domains/workflows/runtime/workflow-run-controller.ts +342 -0
  120. package/src/domains/workflows/runtime/workflow-run-history.ts +164 -0
  121. package/src/domains/workflows/runtime/workflow-run-record.ts +636 -0
  122. package/src/domains/workflows/runtime/workflow-run-store.ts +176 -0
  123. package/src/domains/workflows/runtime/workflow-usage-limit-scheduler.ts +80 -0
  124. package/src/domains/workflows/runtime/workflows.ts +40 -0
  125. package/src/domains/workflows/runtime/worktree.ts +615 -0
  126. package/src/domains/workflows/workflows/code-review.ts +232 -0
  127. package/src/domains/workflows/workflows/diagnose.ts +154 -0
  128. package/src/domains/workflows/workflows/perf-review.ts +149 -0
  129. package/src/domains/workflows/workflows/refactor-scout.ts +143 -0
  130. package/src/domains/workflows/workflows/research.ts +169 -0
  131. package/src/services/usage-service.ts +6 -30
  132. package/src/ui/usage-bars.ts +1 -2
  133. package/src/core/codex/oauth.ts +0 -129
@@ -0,0 +1,435 @@
1
+ import type { Theme } from "@earendil-works/pi-coding-agent";
2
+ import {
3
+ Box,
4
+ Input,
5
+ Markdown,
6
+ matchesKey,
7
+ Text,
8
+ type Focusable,
9
+ type MarkdownTheme,
10
+ type TUI,
11
+ type TuiMouseEvent,
12
+ type TuiMouseEventResult,
13
+ visibleWidth,
14
+ wrapTextWithAnsi,
15
+ } from "@earendil-works/pi-tui";
16
+ import type { AgentChatMessage, AgentRowSnapshot, WorkflowProgressSnapshot } from "../progress-types.ts";
17
+ import type { WorkflowProgressSource } from "../types.ts";
18
+ import { unknownErrorMessage } from "../unknown-error.ts";
19
+ import { formatWorkflowUsageLine } from "../usage.ts";
20
+ import { formatDuration, statusIcon, truncateDisplay } from "./workflow-format.ts";
21
+ import {
22
+ centerWorkflowViewerViewport,
23
+ fitWorkflowViewerRow,
24
+ fitWorkflowViewerRows,
25
+ workflowViewerHeight,
26
+ } from "./workflow-viewer-layout.ts";
27
+
28
+ export interface WorkflowInspectorOutcome {
29
+ readonly label: string;
30
+ readonly text: string;
31
+ }
32
+
33
+ type WorkflowInspectorLiveSource = Pick<WorkflowProgressSource, "conversation" | "followUp">;
34
+
35
+ interface BoardAgent {
36
+ readonly agent: AgentRowSnapshot;
37
+ readonly phase: string;
38
+ }
39
+
40
+ interface BoardRow {
41
+ readonly text: string;
42
+ readonly agentIndex?: number;
43
+ }
44
+
45
+ /** Modal board for inspecting workflow agents and sending targeted follow-ups. */
46
+ export class WorkflowInspector implements Focusable {
47
+ private selected = 0;
48
+ private detailAgentId: number | undefined;
49
+ private detailScroll = 0;
50
+ private clickRows = new Map<number, number>();
51
+ private inputRow: number | undefined;
52
+ private followUpError: string | undefined;
53
+ private sending = false;
54
+ private _focused = false;
55
+ private readonly input: Input;
56
+ private readonly snapshotProvider: () => WorkflowProgressSnapshot;
57
+ private readonly tui: Pick<TUI, "requestRender" | "terminal">;
58
+ private readonly theme: Theme;
59
+ private readonly close: () => void;
60
+ private readonly outcome?: WorkflowInspectorOutcome;
61
+ private readonly live?: WorkflowInspectorLiveSource;
62
+
63
+ constructor(
64
+ snapshotProvider: () => WorkflowProgressSnapshot,
65
+ tui: Pick<TUI, "requestRender" | "terminal">,
66
+ theme: Theme,
67
+ close: () => void,
68
+ outcome?: WorkflowInspectorOutcome,
69
+ live?: WorkflowInspectorLiveSource,
70
+ ) {
71
+ this.snapshotProvider = snapshotProvider;
72
+ this.tui = tui;
73
+ this.theme = theme;
74
+ this.close = close;
75
+ this.outcome = outcome;
76
+ this.live = live;
77
+ this.input = new Input({
78
+ prompt: this.theme.fg("accent", "› "),
79
+ placeholder: "Send a follow-up to this agent…",
80
+ placeholderStyle: (text) => this.theme.fg("dim", text),
81
+ });
82
+ this.input.onSubmit = (value) => void this.submitFollowUp(value);
83
+ }
84
+
85
+ get focused(): boolean {
86
+ return this._focused;
87
+ }
88
+
89
+ set focused(value: boolean) {
90
+ this._focused = value;
91
+ this.input.focused = value && this.detailAgentId !== undefined;
92
+ }
93
+
94
+ handleInput(data: string): void {
95
+ if (matchesKey(data, "escape") || (data === "q" && this.detailAgentId === undefined)) {
96
+ this.close();
97
+ return;
98
+ }
99
+
100
+ if (this.detailAgentId !== undefined) {
101
+ if (matchesKey(data, "pageUp")) this.scrollChat(6);
102
+ else if (matchesKey(data, "pageDown")) this.scrollChat(-6);
103
+ else if (matchesKey(data, "backspace") && this.input.getValue().length === 0) this.closeDetails();
104
+ else if (this.canMessageSelectedAgent()) this.input.handleInput(data);
105
+ this.tui.requestRender();
106
+ return;
107
+ }
108
+
109
+ const count = this.agents().length;
110
+ if (matchesKey(data, "up") || data === "k") this.select(this.selected - 1, count);
111
+ else if (matchesKey(data, "down") || data === "j") this.select(this.selected + 1, count);
112
+ else if (matchesKey(data, "pageUp")) this.select(this.selected - 8, count);
113
+ else if (matchesKey(data, "pageDown")) this.select(this.selected + 8, count);
114
+ else if (matchesKey(data, "home") || data === "g") this.select(0, count);
115
+ else if (matchesKey(data, "end") || data === "G") this.select(count - 1, count);
116
+ else if (matchesKey(data, "return") || matchesKey(data, "enter") || data === " ") this.openDetails(count);
117
+ }
118
+
119
+ handleMouse(event: TuiMouseEvent): TuiMouseEventResult | undefined {
120
+ if (this.detailAgentId !== undefined) {
121
+ if (event.type === "wheel") {
122
+ this.scrollChat(event.wheelDelta && event.wheelDelta < 0 ? 3 : -3);
123
+ return { handled: true, focus: true, render: true };
124
+ }
125
+ if (event.type === "click" && event.button === "left" && event.y === this.inputRow && this.canMessageSelectedAgent()) {
126
+ const result = this.input.handleMouse({ ...event, y: 0 });
127
+ return { handled: true, focus: true, render: true, ...result };
128
+ }
129
+ return undefined;
130
+ }
131
+
132
+ const count = this.agents().length;
133
+ if (event.type === "wheel") {
134
+ this.select(this.selected + (event.wheelDelta && event.wheelDelta < 0 ? -3 : 3), count);
135
+ return { handled: true, focus: true, render: true };
136
+ }
137
+ if (event.type !== "click" || event.button !== "left") return undefined;
138
+ const index = this.clickRows.get(event.y);
139
+ if (index === undefined) return undefined;
140
+ this.selected = index;
141
+ this.openDetails(count);
142
+ return { handled: true, focus: true, render: true };
143
+ }
144
+
145
+ render(width: number): string[] {
146
+ const outerWidth = Math.max(4, width);
147
+ const innerWidth = Math.max(1, outerWidth - 4);
148
+ const snapshot = this.snapshotProvider();
149
+ const agents = this.agents(snapshot);
150
+ this.selected = Math.min(Math.max(0, agents.length - 1), this.selected);
151
+
152
+ const selected = this.detailAgentId === undefined
153
+ ? undefined
154
+ : agents.find((entry) => entry.agent.id === this.detailAgentId);
155
+ if (this.detailAgentId !== undefined && !selected) this.closeDetails();
156
+
157
+ const totalHeight = workflowViewerHeight(this.tui.terminal.rows);
158
+ const innerHeight = Math.max(1, totalHeight - 2);
159
+ const interior = selected
160
+ ? this.chatInterior(selected, innerWidth, innerHeight)
161
+ : this.boardInterior(snapshot, agents, innerWidth, innerHeight);
162
+
163
+ return [
164
+ this.theme.fg("border", `╭${"─".repeat(Math.max(0, outerWidth - 2))}╮`),
165
+ ...interior.map((line) => this.frame(line, innerWidth)),
166
+ this.theme.fg("border", `╰${"─".repeat(Math.max(0, outerWidth - 2))}╯`),
167
+ ].map((line) => truncateDisplay(line, outerWidth));
168
+ }
169
+
170
+ invalidate(): void {
171
+ this.input.invalidate();
172
+ }
173
+
174
+ private boardInterior(
175
+ snapshot: WorkflowProgressSnapshot,
176
+ agents: readonly BoardAgent[],
177
+ width: number,
178
+ height: number,
179
+ ): string[] {
180
+ this.inputRow = undefined;
181
+ this.clickRows.clear();
182
+ const bodyHeight = Math.max(0, height - 6);
183
+ const rows = this.boardRows(snapshot, agents, width);
184
+ const selectedRow = Math.max(0, rows.findIndex((row) => row.agentIndex === this.selected));
185
+ const viewport = centerWorkflowViewerViewport(rows, bodyHeight, selectedRow);
186
+ const body = fitWorkflowViewerRows(viewport.visible.map((row) => row.text), bodyHeight);
187
+
188
+ viewport.visible.forEach((row, index) => {
189
+ if (row.agentIndex !== undefined) this.clickRows.set(5 + index, row.agentIndex);
190
+ });
191
+
192
+ return fitWorkflowViewerRows([
193
+ ` ${this.theme.fg("accent", this.theme.bold("Workflow"))} ${this.theme.fg("dim", snapshot.title)}`,
194
+ ` ${this.summary(snapshot, agents)}`,
195
+ this.theme.fg("dim", "─".repeat(width)),
196
+ ` ${this.columns(width)}`,
197
+ ...body,
198
+ this.theme.fg("dim", "─".repeat(width)),
199
+ ` ${this.theme.fg("dim", `↑↓ select · click/enter inspect · ${viewport.percentage}% · esc close`)}`,
200
+ ], height);
201
+ }
202
+
203
+ private chatInterior(
204
+ entry: BoardAgent,
205
+ width: number,
206
+ height: number,
207
+ ): string[] {
208
+ this.clickRows.clear();
209
+ const { agent } = entry;
210
+ const bodyHeight = Math.max(0, height - 6);
211
+ const rows = this.chatRows(agent, width);
212
+ const maxStart = Math.max(0, rows.length - bodyHeight);
213
+ const start = Math.max(0, maxStart - this.detailScroll);
214
+ const body = fitWorkflowViewerRows(rows.slice(start, start + bodyHeight), bodyHeight);
215
+ const elapsed = agent.startedAt === undefined ? "queued" : formatDuration((agent.doneAt ?? Date.now()) - agent.startedAt);
216
+ const details = [
217
+ shortModel(agent.model ?? "host default"),
218
+ entry.phase,
219
+ elapsed,
220
+ `${agent.toolUses} tool${agent.toolUses === 1 ? "" : "s"}`,
221
+ ].join(this.theme.fg("dim", " · "));
222
+ const canMessage = agent.status === "running" && this.live !== undefined;
223
+ this.input.focused = this._focused && canMessage;
224
+ this.inputRow = canMessage ? height - 1 : undefined;
225
+ const inputLine = canMessage
226
+ ? this.input.render(Math.max(1, width - 1))[0] ?? ""
227
+ : this.theme.fg("dim", agent.status === "queued" ? "Agent has not started yet." : "Agent has finished.");
228
+ const help = this.followUpError
229
+ ? this.theme.fg("error", truncateDisplay(this.followUpError, Math.max(1, width - 1)))
230
+ : this.theme.fg("dim", "enter send · page up/down scroll · backspace agents · esc close");
231
+
232
+ return fitWorkflowViewerRows([
233
+ ` ${statusIcon(agent.status, this.theme)} ${this.theme.fg("accent", this.theme.bold(agent.label))}`,
234
+ ` ${details}`,
235
+ this.theme.fg("dim", "─".repeat(width)),
236
+ ...body,
237
+ this.theme.fg("dim", "─".repeat(width)),
238
+ ` ${inputLine}`,
239
+ ` ${help}`,
240
+ ], height);
241
+ }
242
+
243
+ private agents(snapshot = this.snapshotProvider()): BoardAgent[] {
244
+ return snapshot.phases.flatMap((phase) => phase.agents.map((agent) => ({ phase: phase.title, agent })));
245
+ }
246
+
247
+ private select(index: number, count: number): void {
248
+ if (count === 0) return;
249
+ this.selected = Math.min(count - 1, Math.max(0, index));
250
+ this.tui.requestRender();
251
+ }
252
+
253
+ private openDetails(count: number): void {
254
+ if (count === 0) return;
255
+ const entry = this.agents()[this.selected];
256
+ if (!entry) return;
257
+ this.detailAgentId = entry.agent.id;
258
+ this.detailScroll = 0;
259
+ this.followUpError = undefined;
260
+ this.input.focused = this._focused;
261
+ this.tui.requestRender();
262
+ }
263
+
264
+ private closeDetails(): void {
265
+ this.detailAgentId = undefined;
266
+ this.detailScroll = 0;
267
+ this.followUpError = undefined;
268
+ this.input.setValue("");
269
+ this.input.focused = false;
270
+ this.tui.requestRender();
271
+ }
272
+
273
+ private scrollChat(delta: number): void {
274
+ this.detailScroll = Math.max(0, this.detailScroll + delta);
275
+ this.tui.requestRender();
276
+ }
277
+
278
+ private canMessageSelectedAgent(): boolean {
279
+ if (this.detailAgentId === undefined || !this.live || this.sending) return false;
280
+ return this.agents().some((entry) => entry.agent.id === this.detailAgentId && entry.agent.status === "running");
281
+ }
282
+
283
+ private async submitFollowUp(value: string): Promise<void> {
284
+ const message = value.trim();
285
+ const agentId = this.detailAgentId;
286
+ if (!message || agentId === undefined || !this.live || this.sending) return;
287
+ this.sending = true;
288
+ this.followUpError = undefined;
289
+ this.input.setValue("");
290
+ this.tui.requestRender();
291
+ try {
292
+ await this.live.followUp(agentId, message);
293
+ this.detailScroll = 0;
294
+ } catch (error) {
295
+ this.followUpError = unknownErrorMessage(error);
296
+ this.input.setValue(message);
297
+ } finally {
298
+ this.sending = false;
299
+ this.tui.requestRender();
300
+ }
301
+ }
302
+
303
+ private boardRows(snapshot: WorkflowProgressSnapshot, agents: readonly BoardAgent[], width: number): BoardRow[] {
304
+ if (agents.length === 0) {
305
+ return [{ text: ` ${this.theme.fg("dim", "No agents yet. Tasks appear here when the workflow starts.")}` }];
306
+ }
307
+
308
+ const rows: BoardRow[] = agents.map((entry, index) => ({
309
+ text: this.agentRow(entry, index, width),
310
+ agentIndex: index,
311
+ }));
312
+ const latestLog = snapshot.logs.at(-1);
313
+ if (latestLog) rows.push({ text: ` ${this.theme.fg("dim", `Latest · ${latestLog}`)}` });
314
+ if (this.outcome) {
315
+ rows.push({ text: ` ${this.theme.fg("accent", this.outcome.label)}` });
316
+ rows.push(...wrapTextWithAnsi(this.outcome.text, Math.max(1, width - 2)).slice(0, 12).map((text) => ({ text: ` ${text}` })));
317
+ }
318
+ return rows;
319
+ }
320
+
321
+ private chatRows(agent: AgentRowSnapshot, width: number): string[] {
322
+ const messages = this.live?.conversation(agent.id) ?? [];
323
+ if (messages.length === 0) return [` ${this.theme.fg("dim", "Waiting for agent activity…")}`];
324
+ return messages.flatMap((message) => this.chatMessageRows(message, width));
325
+ }
326
+
327
+ private chatMessageRows(message: AgentChatMessage, width: number): string[] {
328
+ if (message.role === "task" || message.role === "user") {
329
+ const box = new Box(1, 1, (text) => this.theme.bg("userMessageBg", text));
330
+ box.addChild(new Markdown(
331
+ message.text,
332
+ 0,
333
+ 0,
334
+ this.markdownTheme(),
335
+ { color: (text) => this.theme.fg("userMessageText", text) },
336
+ { preserveOrderedListMarkers: true, preserveBackslashEscapes: true },
337
+ ));
338
+ return box.render(width);
339
+ }
340
+
341
+ if (message.role === "assistant") {
342
+ return ["", ...new Markdown(message.text, 1, 0, this.markdownTheme()).render(width)];
343
+ }
344
+
345
+ if (message.role === "tool") {
346
+ const box = new Box(1, 0, (text) => this.theme.bg("toolPendingBg", text));
347
+ box.addChild(new Text(this.theme.fg("toolTitle", this.theme.bold(message.text)), 0, 0));
348
+ return box.render(width);
349
+ }
350
+
351
+ return [
352
+ "",
353
+ ...new Text(this.theme.fg("error", message.text), 1, 0).render(width),
354
+ ];
355
+ }
356
+
357
+ private markdownTheme(): MarkdownTheme {
358
+ return {
359
+ heading: (text) => this.theme.fg("mdHeading", text),
360
+ link: (text) => this.theme.fg("mdLink", text),
361
+ linkUrl: (text) => this.theme.fg("mdLinkUrl", text),
362
+ code: (text) => this.theme.fg("mdCode", text),
363
+ codeBlock: (text) => this.theme.fg("mdCodeBlock", text),
364
+ codeBlockBorder: (text) => this.theme.fg("mdCodeBlockBorder", text),
365
+ quote: (text) => this.theme.fg("mdQuote", text),
366
+ quoteBorder: (text) => this.theme.fg("mdQuoteBorder", text),
367
+ hr: (text) => this.theme.fg("mdHr", text),
368
+ listBullet: (text) => this.theme.fg("mdListBullet", text),
369
+ bold: (text) => this.theme.bold(text),
370
+ italic: (text) => this.theme.italic(text),
371
+ strikethrough: (text) => this.theme.strikethrough(text),
372
+ underline: (text) => this.theme.underline(text),
373
+ };
374
+ }
375
+
376
+ private agentRow(entry: BoardAgent, index: number, width: number): string {
377
+ const { agent } = entry;
378
+ const selected = index === this.selected;
379
+ const marker = selected ? this.theme.fg("accent", "▸") : " ";
380
+ const taskWidth = Math.max(12, Math.floor(width * 0.36));
381
+ const modelWidth = Math.max(12, Math.floor(width * 0.28));
382
+ const activityWidth = Math.max(8, width - taskWidth - modelWidth - 7);
383
+ const task = this.cell(agent.label, taskWidth, agent.status === "failed" ? "error" : "text");
384
+ const model = this.cell(shortModel(agent.model ?? "host default"), modelWidth, "muted");
385
+ const activity = this.cell(agentActivity(agent), activityWidth, agent.status === "failed" ? "error" : "dim");
386
+ const row = `${marker} ${statusIcon(agent.status, this.theme)} ${task} ${model} ${activity}`;
387
+ return selected ? this.theme.bg("selectedBg", fitWorkflowViewerRow(row, width)) : fitWorkflowViewerRow(row, width);
388
+ }
389
+
390
+ private columns(width: number): string {
391
+ const taskWidth = Math.max(12, Math.floor(width * 0.36));
392
+ const modelWidth = Math.max(12, Math.floor(width * 0.28));
393
+ return this.theme.fg("dim", ` ${"Task".padEnd(taskWidth + 2)}${"Model".padEnd(modelWidth + 1)}Activity`);
394
+ }
395
+
396
+ private summary(snapshot: WorkflowProgressSnapshot, agents: readonly BoardAgent[]): string {
397
+ const counts = { queued: 0, running: 0, done: 0, failed: 0 };
398
+ for (const { agent } of agents) counts[agent.status]++;
399
+ const parts = [
400
+ this.theme.fg("accent", snapshot.currentPhase),
401
+ `${counts.running} running`,
402
+ `${counts.queued} queued`,
403
+ this.theme.fg("success", `${counts.done} done`),
404
+ ...(counts.failed > 0 ? [this.theme.fg("error", `${counts.failed} failed`)] : []),
405
+ formatDuration((snapshot.doneAt ?? Date.now()) - snapshot.startedAt),
406
+ formatWorkflowUsageLine(snapshot.usage) ?? "",
407
+ ].filter(Boolean);
408
+ return parts.join(this.theme.fg("dim", " · "));
409
+ }
410
+
411
+ private cell(text: string, width: number, color: Parameters<Theme["fg"]>[0]): string {
412
+ const value = truncateDisplay(text, width);
413
+ return this.theme.fg(color, value + " ".repeat(Math.max(0, width - visibleWidth(value))));
414
+ }
415
+
416
+ private frame(content: string, width: number): string {
417
+ const fitted = fitWorkflowViewerRow(content, width);
418
+ const padding = " ".repeat(Math.max(0, width - visibleWidth(fitted)));
419
+ return `${this.theme.fg("border", "│")} ${fitted}${padding} ${this.theme.fg("border", "│")}`;
420
+ }
421
+ }
422
+
423
+ function shortModel(model: string): string {
424
+ return model.replace(/^openai-codex\//, "codex/").replace(/^anthropic\//, "");
425
+ }
426
+
427
+ function agentActivity(agent: AgentRowSnapshot): string {
428
+ if (agent.status === "queued") return "queued";
429
+ if (agent.status === "failed") return agent.error ?? "failed";
430
+ const parts: string[] = [];
431
+ if (agent.lastTool) parts.push(agent.lastTool);
432
+ if (agent.toolUses > 0) parts.push(`${agent.toolUses} tool${agent.toolUses === 1 ? "" : "s"}`);
433
+ if (agent.startedAt !== undefined) parts.push(formatDuration((agent.doneAt ?? Date.now()) - agent.startedAt));
434
+ return parts.join(" · ") || agent.status;
435
+ }
@@ -0,0 +1,171 @@
1
+ import type { Theme } from "@earendil-works/pi-coding-agent";
2
+ import { Box, type Component, Text } from "@earendil-works/pi-tui";
3
+ import { isAdvisoryReport, type AdvisoryReportWithStats } from "../advisory-schema.ts";
4
+ import { renderIssueDetails, renderIssuesTable } from "../review/review-format.ts";
5
+ import { toReviewIssues } from "../review/review-issues.ts";
6
+ import { formatCount } from "./workflow-format.ts";
7
+ import { formatWorkflowUsageLine } from "../usage.ts";
8
+ import type { WorkflowPerfDetails, WorkflowResultEnvelope } from "../workflow-execution.ts";
9
+ import { unknownErrorMessage } from "../unknown-error.ts";
10
+
11
+ export function isWorkflowResult(value: unknown): value is WorkflowResultEnvelope {
12
+ if (!isRecord(value)) return false;
13
+ return typeof value.name === "string" && "result" in value && typeof value.completedAt === "number";
14
+ }
15
+
16
+ export interface WorkflowRunDisplayMetadata {
17
+ readonly runId?: string;
18
+ readonly resumedFromRunId?: string;
19
+ }
20
+
21
+ export interface WorkflowDetailLineInput {
22
+ readonly usage?: unknown;
23
+ readonly perf?: WorkflowPerfDetails;
24
+ readonly metadata?: WorkflowRunDisplayMetadata;
25
+ }
26
+
27
+ export function renderWorkflowResult(
28
+ name: string,
29
+ result: unknown,
30
+ expanded: boolean,
31
+ theme: Theme,
32
+ usage?: unknown,
33
+ metadata?: WorkflowRunDisplayMetadata,
34
+ perf?: WorkflowPerfDetails,
35
+ ): Component {
36
+ const box = new Box(1, 1, (text) => theme.bg("customMessageBg", text));
37
+ box.addChild(new Text(renderWorkflowResultText(name, result, expanded, theme, usage, metadata, perf), 0, 0));
38
+ return box;
39
+ }
40
+
41
+ export function renderWorkflowResultText(
42
+ name: string,
43
+ result: unknown,
44
+ expanded: boolean,
45
+ theme: Theme,
46
+ usage?: unknown,
47
+ metadata?: WorkflowRunDisplayMetadata,
48
+ perf?: WorkflowPerfDetails,
49
+ ): string {
50
+ if (isAdvisoryReport(result)) {
51
+ return renderAdvisoryResult(name, result, expanded, theme, usage, metadata, perf);
52
+ }
53
+ return renderGenericWorkflowResult(name, result, expanded, theme, usage, metadata, perf);
54
+ }
55
+
56
+ function renderAdvisoryResult(
57
+ name: string,
58
+ result: AdvisoryReportWithStats,
59
+ expanded: boolean,
60
+ theme: Theme,
61
+ usage?: unknown,
62
+ metadata?: WorkflowRunDisplayMetadata,
63
+ perf?: WorkflowPerfDetails,
64
+ ): string {
65
+ const incomplete = result.status === "incomplete";
66
+ const icon = incomplete ? theme.fg("warning", "⚠") : theme.fg("success", "✓");
67
+ const title = theme.fg("accent", theme.bold(`Workflow: ${name}`));
68
+ const lines = [`${icon} ${title}`, theme.fg("muted", result.summary)];
69
+ if (result.coverage?.length) lines.push(theme.fg("dim", result.coverage.map((stage) => `${stage.stage}: ${stage.completed}/${stage.expected} complete, ${stage.failed} failed`).join(" · ")));
70
+ if (expanded) for (const gap of result.gaps ?? []) lines.push(theme.fg("warning", gap));
71
+ const stats = statsLine(result.stats, theme);
72
+ if (stats) lines.push(stats);
73
+ pushWorkflowDetailLines(lines, theme, { usage, metadata, perf });
74
+
75
+ if (result.findings.length === 0) {
76
+ lines.push(incomplete ? theme.fg("warning", "No verified findings; coverage is incomplete.") : theme.fg("success", "No findings."));
77
+ if (expanded && result.nextSteps.length > 0) renderNextSteps(result.nextSteps, lines, theme);
78
+ return lines.join("\n");
79
+ }
80
+
81
+ const issues = toReviewIssues(name, result);
82
+ lines.push(theme.fg("dim", "Findings:"));
83
+ lines.push(renderIssuesTable(issues, theme, { maxRows: expanded ? issues.length : 12 }));
84
+ if (expanded) {
85
+ for (const issue of issues) {
86
+ lines.push(renderIssueDetails(issue, theme));
87
+ }
88
+ }
89
+ if (expanded && result.nextSteps.length > 0) renderNextSteps(result.nextSteps, lines, theme);
90
+ return lines.join("\n");
91
+ }
92
+
93
+ function renderNextSteps(nextSteps: string[], lines: string[], theme: Theme): void {
94
+ lines.push(theme.fg("dim", "Next steps:"));
95
+ for (const step of nextSteps) {
96
+ lines.push(` - ${theme.fg("muted", step)}`);
97
+ }
98
+ }
99
+
100
+ function renderGenericWorkflowResult(
101
+ name: string,
102
+ result: unknown,
103
+ expanded: boolean,
104
+ theme: Theme,
105
+ usage?: unknown,
106
+ metadata?: WorkflowRunDisplayMetadata,
107
+ perf?: WorkflowPerfDetails,
108
+ ): string {
109
+ const lines = [`${theme.fg("success", "✓")} ${theme.fg("accent", theme.bold(`Workflow: ${name}`))}`];
110
+ const summary = extractSummary(result);
111
+ if (summary) lines.push(theme.fg("muted", summary));
112
+ pushWorkflowDetailLines(lines, theme, { usage, metadata, perf });
113
+ if (expanded) lines.push(theme.fg("dim", safeJson(result)));
114
+ else if (!summary) lines.push(theme.fg("dim", "Result available in expanded view."));
115
+ return lines.join("\n");
116
+ }
117
+
118
+ function pushWorkflowDetailLines(lines: string[], theme: Theme, input: WorkflowDetailLineInput): void {
119
+ for (const line of formatWorkflowDetailLines(input)) {
120
+ lines.push(theme.fg("dim", line));
121
+ }
122
+ }
123
+
124
+ export function formatWorkflowDetailLines(input: WorkflowDetailLineInput): string[] {
125
+ return [
126
+ formatWorkflowRunLine(input.metadata),
127
+ formatWorkflowUsageLine(input.usage),
128
+ formatWorkflowPerfLine(input.perf),
129
+ ].filter((line): line is string => line !== undefined);
130
+ }
131
+
132
+ export function formatWorkflowRunLine(metadata: WorkflowRunDisplayMetadata | undefined): string | undefined {
133
+ if (!metadata?.runId) return undefined;
134
+ return metadata.resumedFromRunId ? `Run: ${metadata.runId} (resumed from ${metadata.resumedFromRunId})` : `Run: ${metadata.runId}`;
135
+ }
136
+
137
+ export function formatWorkflowPerfLine(perf: WorkflowPerfDetails | undefined): string | undefined {
138
+ if (!perf) return undefined;
139
+ const parts = perf.aggregates.slice(0, 4).map((aggregate) => `${aggregate.name} ${Math.round(aggregate.total)}ms`);
140
+ return parts.length > 0 ? `Perf: ${parts.join(" · ")}` : "Perf: no samples";
141
+ }
142
+
143
+ function statsLine(stats: Record<string, string | number> | undefined, theme: Theme): string | undefined {
144
+ if (!stats) return undefined;
145
+ const ordered = ["files", "candidates", "dropped", "verified", "kept"];
146
+ const parts = ordered.flatMap((key) => {
147
+ const value = stats[key];
148
+ if (value === undefined) return [];
149
+ return [`${key} ${typeof value === "number" ? formatCount(value) : value}`];
150
+ });
151
+ if (parts.length === 0) return undefined;
152
+ return theme.fg("dim", parts.join(" · "));
153
+ }
154
+
155
+ function extractSummary(value: unknown): string | undefined {
156
+ if (typeof value === "string") return value;
157
+ if (isRecord(value) && typeof value.summary === "string") return value.summary;
158
+ return undefined;
159
+ }
160
+
161
+ function safeJson(value: unknown): string {
162
+ try {
163
+ return JSON.stringify(value, null, 2) ?? String(value);
164
+ } catch (error) {
165
+ return unknownErrorMessage(error);
166
+ }
167
+ }
168
+
169
+ function isRecord(value: unknown): value is Record<string, unknown> {
170
+ return typeof value === "object" && value !== null;
171
+ }
@@ -0,0 +1,51 @@
1
+ import { truncateToWidth } from "@earendil-works/pi-tui";
2
+
3
+ const VIEWPORT_HEIGHT_RATIO = 0.7;
4
+ const VIEWPORT_MARGIN_ROWS = 4;
5
+
6
+ export const WORKFLOW_VIEWER_OVERLAY_OPTIONS = {
7
+ overlay: true,
8
+ overlayOptions: {
9
+ anchor: "center",
10
+ width: "70%",
11
+ minWidth: 48,
12
+ maxHeight: "70%",
13
+ margin: 2,
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
+ }