@yycholla/pi-dynamic-workflows 3.3.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 (179) hide show
  1. package/LICENSE +22 -0
  2. package/README.md +325 -0
  3. package/assets/readme/hero.png +0 -0
  4. package/assets/readme/hero.svg +70 -0
  5. package/assets/readme/package-cover.png +0 -0
  6. package/assets/readme/package-cover.svg +66 -0
  7. package/assets/readme/workflow.png +0 -0
  8. package/assets/readme/workflow.svg +77 -0
  9. package/dist/accept-workflow-guidance.d.ts +14 -0
  10. package/dist/accept-workflow-guidance.js +53 -0
  11. package/dist/adversarial-review.d.ts +30 -0
  12. package/dist/adversarial-review.js +107 -0
  13. package/dist/agent-history.d.ts +20 -0
  14. package/dist/agent-history.js +122 -0
  15. package/dist/agent-registry.d.ts +83 -0
  16. package/dist/agent-registry.js +190 -0
  17. package/dist/agent.d.ts +364 -0
  18. package/dist/agent.js +714 -0
  19. package/dist/builtin-commands.d.ts +19 -0
  20. package/dist/builtin-commands.js +251 -0
  21. package/dist/builtin-workflows.d.ts +45 -0
  22. package/dist/builtin-workflows.js +121 -0
  23. package/dist/code-review.d.ts +26 -0
  24. package/dist/code-review.js +181 -0
  25. package/dist/config.d.ts +37 -0
  26. package/dist/config.js +44 -0
  27. package/dist/deep-research.d.ts +30 -0
  28. package/dist/deep-research.js +124 -0
  29. package/dist/display.d.ts +134 -0
  30. package/dist/display.js +248 -0
  31. package/dist/effort-command.d.ts +28 -0
  32. package/dist/effort-command.js +68 -0
  33. package/dist/enums.d.ts +69 -0
  34. package/dist/enums.js +78 -0
  35. package/dist/errors.d.ts +113 -0
  36. package/dist/errors.js +140 -0
  37. package/dist/extension-reload.d.ts +37 -0
  38. package/dist/extension-reload.js +78 -0
  39. package/dist/fs-persistence.d.ts +63 -0
  40. package/dist/fs-persistence.js +102 -0
  41. package/dist/index.d.ts +57 -0
  42. package/dist/index.js +35 -0
  43. package/dist/logger.d.ts +21 -0
  44. package/dist/logger.js +66 -0
  45. package/dist/model-routing.d.ts +30 -0
  46. package/dist/model-routing.js +50 -0
  47. package/dist/model-spec.d.ts +29 -0
  48. package/dist/model-spec.js +252 -0
  49. package/dist/model-tier-config.d.ts +133 -0
  50. package/dist/model-tier-config.js +249 -0
  51. package/dist/run-persistence.d.ts +180 -0
  52. package/dist/run-persistence.js +294 -0
  53. package/dist/saved-commands.d.ts +28 -0
  54. package/dist/saved-commands.js +100 -0
  55. package/dist/shared-store.d.ts +98 -0
  56. package/dist/shared-store.js +212 -0
  57. package/dist/structured-output.d.ts +19 -0
  58. package/dist/structured-output.js +30 -0
  59. package/dist/task-panel.d.ts +61 -0
  60. package/dist/task-panel.js +422 -0
  61. package/dist/usage-limit-scheduler.d.ts +145 -0
  62. package/dist/usage-limit-scheduler.js +368 -0
  63. package/dist/web-tools.d.ts +20 -0
  64. package/dist/web-tools.js +120 -0
  65. package/dist/workflow-authoring-coverage.d.ts +70 -0
  66. package/dist/workflow-authoring-coverage.js +421 -0
  67. package/dist/workflow-authoring-reference.d.ts +20 -0
  68. package/dist/workflow-authoring-reference.js +156 -0
  69. package/dist/workflow-capability-contract.d.ts +131 -0
  70. package/dist/workflow-capability-contract.js +604 -0
  71. package/dist/workflow-commands.d.ts +18 -0
  72. package/dist/workflow-commands.js +260 -0
  73. package/dist/workflow-comprehension.d.ts +133 -0
  74. package/dist/workflow-comprehension.js +1321 -0
  75. package/dist/workflow-context-measurement.d.ts +72 -0
  76. package/dist/workflow-context-measurement.js +213 -0
  77. package/dist/workflow-control-tool.d.ts +30 -0
  78. package/dist/workflow-control-tool.js +176 -0
  79. package/dist/workflow-delivery-choice.d.ts +20 -0
  80. package/dist/workflow-delivery-choice.js +48 -0
  81. package/dist/workflow-editor.d.ts +93 -0
  82. package/dist/workflow-editor.js +363 -0
  83. package/dist/workflow-manager.d.ts +492 -0
  84. package/dist/workflow-manager.js +1124 -0
  85. package/dist/workflow-paths.d.ts +22 -0
  86. package/dist/workflow-paths.js +46 -0
  87. package/dist/workflow-release-gate.d.ts +39 -0
  88. package/dist/workflow-release-gate.js +309 -0
  89. package/dist/workflow-saved.d.ts +38 -0
  90. package/dist/workflow-saved.js +126 -0
  91. package/dist/workflow-settings.d.ts +70 -0
  92. package/dist/workflow-settings.js +131 -0
  93. package/dist/workflow-tool.d.ts +71 -0
  94. package/dist/workflow-tool.js +367 -0
  95. package/dist/workflow-ui.d.ts +182 -0
  96. package/dist/workflow-ui.js +1587 -0
  97. package/dist/workflow.d.ts +333 -0
  98. package/dist/workflow.js +1151 -0
  99. package/dist/workflows-models-command.d.ts +31 -0
  100. package/dist/workflows-models-command.js +156 -0
  101. package/dist/worktree.d.ts +25 -0
  102. package/dist/worktree.js +61 -0
  103. package/extensions/workflow.ts +151 -0
  104. package/package.json +104 -0
  105. package/skills/workflow-authoring/SKILL.md +30 -0
  106. package/skills/workflow-authoring/examples/adversarial-verification.js +63 -0
  107. package/skills/workflow-authoring/examples/bounded-semantic-retry.js +56 -0
  108. package/skills/workflow-authoring/examples/classify-and-act.js +67 -0
  109. package/skills/workflow-authoring/examples/fan-out-and-synthesize.js +47 -0
  110. package/skills/workflow-authoring/examples/generate-and-filter.js +68 -0
  111. package/skills/workflow-authoring/examples/loop-until-done.js +68 -0
  112. package/skills/workflow-authoring/examples/phased-budgets.js +75 -0
  113. package/skills/workflow-authoring/examples/saved-nested-workflows.js +44 -0
  114. package/skills/workflow-authoring/examples/structured-output.js +37 -0
  115. package/skills/workflow-authoring/examples/tournament.js +81 -0
  116. package/skills/workflow-authoring/examples/validated-gate.js +63 -0
  117. package/skills/workflow-authoring/references/capabilities.md +41 -0
  118. package/skills/workflow-authoring/references/capability-details.md +357 -0
  119. package/skills/workflow-authoring/references/common-helpers.md +4 -0
  120. package/skills/workflow-authoring/references/debugging.md +27 -0
  121. package/skills/workflow-authoring/references/focused-recipes.md +11 -0
  122. package/skills/workflow-authoring/references/helpers.md +9 -0
  123. package/skills/workflow-authoring/references/lifecycle.md +33 -0
  124. package/skills/workflow-authoring/references/pattern-selection.md +15 -0
  125. package/skills/workflow-authoring/references/quality-helpers.md +8 -0
  126. package/skills/workflow-authoring/references/registry-ownership.md +15 -0
  127. package/skills/workflow-authoring/references/retry-helper.md +5 -0
  128. package/skills/workflow-authoring/references/review.md +43 -0
  129. package/skills/workflow-authoring/references/runtime.md +27 -0
  130. package/skills/workflow-authoring/references/specialized-helpers.md +19 -0
  131. package/skills/workflow-authoring/references/versions.md +13 -0
  132. package/skills/workflow-patterns/SKILL.md +51 -0
  133. package/src/accept-workflow-guidance.ts +71 -0
  134. package/src/adversarial-review.ts +120 -0
  135. package/src/agent-history.ts +157 -0
  136. package/src/agent-registry.ts +221 -0
  137. package/src/agent.ts +929 -0
  138. package/src/builtin-commands.ts +286 -0
  139. package/src/builtin-workflows.ts +155 -0
  140. package/src/code-review.ts +183 -0
  141. package/src/config.ts +55 -0
  142. package/src/deep-research.ts +135 -0
  143. package/src/display.ts +367 -0
  144. package/src/effort-command.ts +87 -0
  145. package/src/enums.ts +77 -0
  146. package/src/errors.ts +199 -0
  147. package/src/extension-reload.ts +100 -0
  148. package/src/fs-persistence.ts +124 -0
  149. package/src/index.ts +176 -0
  150. package/src/logger.ts +88 -0
  151. package/src/model-routing.ts +73 -0
  152. package/src/model-spec.ts +309 -0
  153. package/src/model-tier-config.ts +296 -0
  154. package/src/run-persistence.ts +484 -0
  155. package/src/saved-commands.ts +115 -0
  156. package/src/shared-store.ts +228 -0
  157. package/src/structured-output.ts +47 -0
  158. package/src/task-panel.ts +490 -0
  159. package/src/usage-limit-scheduler.ts +432 -0
  160. package/src/web-tools.ts +124 -0
  161. package/src/workflow-authoring-coverage.ts +486 -0
  162. package/src/workflow-authoring-reference.ts +186 -0
  163. package/src/workflow-capability-contract.ts +806 -0
  164. package/src/workflow-commands.ts +287 -0
  165. package/src/workflow-comprehension.ts +1673 -0
  166. package/src/workflow-context-measurement.ts +262 -0
  167. package/src/workflow-control-tool.ts +238 -0
  168. package/src/workflow-delivery-choice.ts +69 -0
  169. package/src/workflow-editor.ts +444 -0
  170. package/src/workflow-manager.ts +1405 -0
  171. package/src/workflow-paths.ts +63 -0
  172. package/src/workflow-release-gate.ts +529 -0
  173. package/src/workflow-saved.ts +180 -0
  174. package/src/workflow-settings.ts +194 -0
  175. package/src/workflow-tool.ts +464 -0
  176. package/src/workflow-ui.ts +1789 -0
  177. package/src/workflow.ts +1615 -0
  178. package/src/workflows-models-command.ts +211 -0
  179. package/src/worktree.ts +76 -0
@@ -0,0 +1,1789 @@
1
+ /**
2
+ * Interactive `/workflows` navigator, modeled on Claude Code's view:
3
+ *
4
+ * runs ──enter──▶ phases ──enter──▶ agents ──enter──▶ agent detail
5
+ * ◀──esc─── ◀──esc──── ◀──esc────
6
+ * ◀── (saved items in runs view) ──enter──▶ saved detail
7
+ *
8
+ * Keys: ↑/↓ (or j/k) select · enter/→ drill in · esc/← back (esc at top closes)
9
+ * On runs: p pause · x stop · r restart · s save · q quit
10
+ * On saved: x delete · q quit
11
+ *
12
+ * The state machine and line rendering are pure and unit-tested; the pi-tui
13
+ * Component shell (openWorkflowNavigator) wires them to live manager events.
14
+ */
15
+
16
+ import {
17
+ type ExtensionAPI,
18
+ type ExtensionUIContext,
19
+ getLanguageFromPath,
20
+ getMarkdownTheme,
21
+ renderDiff,
22
+ type Theme,
23
+ } from "@earendil-works/pi-coding-agent";
24
+ import type { Component, Focusable, MarkdownTheme, TUI } from "@earendil-works/pi-tui";
25
+ import { Markdown, parseKey, truncateToWidth, visibleWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
26
+ import type { AgentUsage } from "./agent.js";
27
+ import type { ThemeLike, WorkflowAgentSnapshot, WorkflowSnapshot } from "./display.js";
28
+ import { aggregateAgentUsage, fmtCost, fmtTokenSegment, tokenFigures } from "./display.js";
29
+ import type { PersistedRunState } from "./run-persistence.js";
30
+ import { registerSavedWorkflow } from "./saved-commands.js";
31
+ import type { WorkflowManager } from "./workflow-manager.js";
32
+ import type { SavedWorkflow, WorkflowStorage } from "./workflow-saved.js";
33
+
34
+ const STATUS_ICON: Record<string, string> = {
35
+ pending: "·",
36
+ queued: "·",
37
+ running: "◆",
38
+ paused: "⏸",
39
+ completed: "✓",
40
+ done: "✓",
41
+ failed: "✗",
42
+ error: "✗",
43
+ aborted: "⊘",
44
+ skipped: "⊘",
45
+ };
46
+
47
+ const PLAIN: ThemeLike = { fg: (_c, t) => t, bold: (t) => t };
48
+
49
+ /** Bounded per-overlay cache for expensive Markdown parsing and highlighting. */
50
+ class NavigatorTextRenderCache {
51
+ private readonly entries = new Map<string, { lines: string[]; weight: number }>();
52
+ private readonly resultJson = new WeakMap<object, string>();
53
+ private weight = 0;
54
+
55
+ get(key: string): string[] | undefined {
56
+ const hit = this.entries.get(key);
57
+ if (!hit) return undefined;
58
+ // Refresh insertion order so eviction behaves like a small LRU.
59
+ this.entries.delete(key);
60
+ this.entries.set(key, hit);
61
+ return hit.lines;
62
+ }
63
+
64
+ stringify(result: object): string {
65
+ const cached = this.resultJson.get(result);
66
+ if (cached !== undefined) return cached;
67
+ let json: string;
68
+ try {
69
+ json = JSON.stringify(result, null, 2) ?? String(result);
70
+ } catch {
71
+ json = String(result);
72
+ }
73
+ this.resultJson.set(result, json);
74
+ return json;
75
+ }
76
+
77
+ set(key: string, lines: string[], weight: number): string[] {
78
+ const MAX_ENTRIES = 96;
79
+ const MAX_WEIGHT = 4_000_000;
80
+ if (weight > MAX_WEIGHT) return lines;
81
+ const previous = this.entries.get(key);
82
+ if (previous) this.weight -= previous.weight;
83
+ this.entries.delete(key);
84
+ this.entries.set(key, { lines, weight });
85
+ this.weight += weight;
86
+ while (this.entries.size > MAX_ENTRIES || this.weight > MAX_WEIGHT) {
87
+ const oldest = this.entries.entries().next().value as [string, { lines: string[]; weight: number }] | undefined;
88
+ if (!oldest) break;
89
+ this.entries.delete(oldest[0]);
90
+ this.weight -= oldest[1].weight;
91
+ }
92
+ return lines;
93
+ }
94
+ }
95
+
96
+ // Border characters for the overlay box
97
+ const BOX_BORDER_LEFT = "│ ";
98
+ const BOX_BORDER_RIGHT = " │";
99
+ const BOX_BORDER_OVERHEAD = BOX_BORDER_LEFT.length + BOX_BORDER_RIGHT.length;
100
+
101
+ export type ViewKind = "runs" | "phases" | "agents" | "detail" | "savedDetail";
102
+
103
+ export type ItemKind = "run" | "saved";
104
+
105
+ interface RunRow {
106
+ runId: string;
107
+ name: string;
108
+ status: string;
109
+ done: number;
110
+ total: number;
111
+ /** Fresh tokens for the whole run (see tokenFigures for the fallback rule). */
112
+ fresh: number;
113
+ /** Cache-read tokens for the whole run. */
114
+ cacheRead: number;
115
+ cost: number;
116
+ }
117
+ interface PhaseRow {
118
+ title: string;
119
+ done: number;
120
+ total: number;
121
+ /** Fresh tokens summed across the phase's agents. */
122
+ fresh: number;
123
+ /** Cache-read tokens summed across the phase's agents. */
124
+ cacheRead: number;
125
+ }
126
+ interface AgentRow {
127
+ id: number;
128
+ label: string;
129
+ status: string;
130
+ phase?: string;
131
+ tokens?: number;
132
+ tokenUsage?: AgentUsage;
133
+ model?: string;
134
+ }
135
+
136
+ /** Short, human-friendly model label: drop the provider prefix for display. */
137
+ /**
138
+ * Coerce a possibly-non-string value from a (corrupt) persisted run to a string,
139
+ * so it can never reach a downstream truncateToWidth()/visibleWidth() as a
140
+ * non-string and crash the whole /workflows overlay via text.slice() (#110).
141
+ * Applied at every Model read boundary that feeds the renderer: phase titles,
142
+ * agent labels/phases, and run names.
143
+ */
144
+ function asText(v: unknown): string {
145
+ return typeof v === "string" ? v : String(v ?? "");
146
+ }
147
+
148
+ /** The (coerced) phase an agent belongs to; "(no phase)" when unset. Shared by
149
+ * agents()/agentsByPhase() so grouping and the drilled-in filter always agree. */
150
+ function agentPhaseKey(a: WorkflowAgentSnapshot): string {
151
+ return a.phase != null ? asText(a.phase) : "(no phase)";
152
+ }
153
+
154
+ /** Build a render-safe AgentRow: coerce label/phase so a non-string value from a
155
+ * corrupt run can't crash the agent row's truncateToWidth() (#110). */
156
+ function toAgentRow(a: WorkflowAgentSnapshot): AgentRow {
157
+ return {
158
+ id: a.id,
159
+ label: asText(a.label),
160
+ status: a.status,
161
+ phase: a.phase != null ? asText(a.phase) : a.phase,
162
+ tokens: a.tokens,
163
+ tokenUsage: a.tokenUsage,
164
+ model: a.model,
165
+ };
166
+ }
167
+
168
+ export function shortModel(model: string | undefined): string | undefined {
169
+ if (!model) return undefined;
170
+ const m = asText(model);
171
+ const slash = m.indexOf("/");
172
+ return slash > 0 ? m.slice(slash + 1) : m;
173
+ }
174
+
175
+ /** Reads run/phase/agent data from the manager, preferring live snapshots. */
176
+ export class NavigatorModel {
177
+ private frameDepth = 0;
178
+ private frameRuns: PersistedRunState[] | undefined;
179
+ private readonly frameSnapshots = new Map<string, { snapshot: WorkflowSnapshot; status: string } | undefined>();
180
+
181
+ constructor(
182
+ private readonly manager: Pick<WorkflowManager, "listRuns" | "getRun">,
183
+ private readonly storage?: { list(): SavedWorkflow[]; delete(name: string, location?: string): boolean },
184
+ ) {}
185
+
186
+ /** Share persisted data across all model lookups performed by one render. */
187
+ withRenderFrame<T>(render: () => T): T {
188
+ const outermost = this.frameDepth === 0;
189
+ this.frameDepth++;
190
+ try {
191
+ return render();
192
+ } finally {
193
+ this.frameDepth--;
194
+ if (outermost) {
195
+ this.frameRuns = undefined;
196
+ this.frameSnapshots.clear();
197
+ }
198
+ }
199
+ }
200
+
201
+ private persistedRuns(): PersistedRunState[] {
202
+ if (this.frameDepth === 0) return this.manager.listRuns();
203
+ if (!this.frameRuns) this.frameRuns = this.manager.listRuns();
204
+ return this.frameRuns;
205
+ }
206
+
207
+ private snapshot(runId: string): { snapshot: WorkflowSnapshot; status: string } | undefined {
208
+ if (this.frameDepth > 0 && this.frameSnapshots.has(runId)) return this.frameSnapshots.get(runId);
209
+ const live = this.manager.getRun(runId);
210
+ const value = live
211
+ ? { snapshot: live.snapshot, status: live.status }
212
+ : (() => {
213
+ const p = this.persistedRuns().find((r) => r.runId === runId);
214
+ return p ? { snapshot: persistedToSnapshot(p), status: p.status } : undefined;
215
+ })();
216
+ if (this.frameDepth > 0) this.frameSnapshots.set(runId, value);
217
+ return value;
218
+ }
219
+
220
+ runs(): RunRow[] {
221
+ return this.persistedRuns().map((p) => {
222
+ const live = this.manager.getRun(p.runId);
223
+ // Array guard (#110): a structurally corrupt persisted run (agents not an
224
+ // array) would otherwise throw "agents is not iterable" here and crash the
225
+ // runs list itself — i.e. /workflows would fail to open at all.
226
+ const rawAgents = live?.snapshot.agents ?? p.agents;
227
+ const agents = (Array.isArray(rawAgents) ? rawAgents : []) as WorkflowAgentSnapshot[];
228
+ const usage = live?.snapshot.tokenUsage ?? p.tokenUsage;
229
+ // The run-level aggregate is authoritative but only lands when the run
230
+ // ends; per-agent figures update live. Use whichever accounts for more
231
+ // tokens, so live runs show a count in the list (agreeing with the phase
232
+ // view) and finished/legacy runs keep the final aggregate.
233
+ const fromUsage = tokenFigures(usage);
234
+ const fromAgents = aggregateAgentUsage(agents);
235
+ const figures =
236
+ fromAgents.fresh + fromAgents.cacheRead > fromUsage.fresh + fromUsage.cacheRead ? fromAgents : fromUsage;
237
+ return {
238
+ runId: p.runId,
239
+ name: asText(live?.snapshot.name ?? p.workflowName),
240
+ status: live?.status ?? p.status,
241
+ done: agents.filter((a) => a.status === "done").length,
242
+ total: agents.length,
243
+ fresh: figures.fresh,
244
+ cacheRead: figures.cacheRead,
245
+ cost: usage?.cost ?? 0,
246
+ };
247
+ });
248
+ }
249
+
250
+ /** Return saved workflows sorted by name, or [] when no storage configured. */
251
+ saved(): SavedWorkflow[] {
252
+ if (!this.storage) return [];
253
+ return this.storage.list().sort((a, b) => a.name.localeCompare(b.name));
254
+ }
255
+
256
+ /** Delete a saved workflow by name. */
257
+ deleteSaved(name: string): boolean {
258
+ if (!this.storage) return false;
259
+ return this.storage.delete(name);
260
+ }
261
+
262
+ runName(runId: string): string {
263
+ return asText(this.snapshot(runId)?.snapshot.name ?? runId);
264
+ }
265
+
266
+ runStatus(runId: string): string {
267
+ // Coerce (#110): a corrupt persisted run can carry a non-string status, which
268
+ // would otherwise crash twoPaneHeader's truncateToWidth() with text.slice().
269
+ return asText(this.snapshot(runId)?.status ?? "unknown");
270
+ }
271
+
272
+ phases(runId: string): PhaseRow[] {
273
+ const snap = this.snapshot(runId)?.snapshot;
274
+ if (!snap) return [];
275
+ // Coerce phase keys up front (#110): a non-string phase — from a corrupt
276
+ // persisted run or a script that passed a non-string to phase() — would
277
+ // otherwise reach truncateToWidth() and crash the overlay. Grouping through
278
+ // the shared agentPhaseKey() (not an inline copy) locks the invariant that
279
+ // agents land under the same string the drilled-in agents() filter compares
280
+ // against; the Array.isArray guards mirror agents()/agentsByPhase().
281
+ const order = Array.isArray(snap.phases) ? snap.phases.map(asText) : [];
282
+ const byPhase = new Map<string, AgentRow[]>();
283
+ const agents = Array.isArray(snap.agents) ? snap.agents : [];
284
+ for (const a of agents) {
285
+ const key = agentPhaseKey(a);
286
+ if (!byPhase.has(key)) byPhase.set(key, []);
287
+ byPhase.get(key)?.push(a);
288
+ if (!order.includes(key)) order.push(key);
289
+ }
290
+ return order.map((title) => {
291
+ const agents = byPhase.get(title) ?? [];
292
+ const usage = aggregateAgentUsage(agents);
293
+ return {
294
+ title, // already coerced to a string above
295
+ done: agents.filter((a) => a.status === "done").length,
296
+ total: agents.length,
297
+ fresh: usage.fresh,
298
+ cacheRead: usage.cacheRead,
299
+ };
300
+ });
301
+ }
302
+
303
+ agents(runId: string, phase: string): AgentRow[] {
304
+ const snap = this.snapshot(runId)?.snapshot;
305
+ if (!snap || !Array.isArray(snap.agents)) return [];
306
+ return snap.agents.filter((a) => agentPhaseKey(a) === phase).map((a) => toAgentRow(a));
307
+ }
308
+
309
+ /**
310
+ * All agents grouped by their (coerced) phase in a SINGLE pass — O(agents).
311
+ * The navigator's phase pane needs each phase's agents (status colour + the
312
+ * selected phase's rows); calling agents() once per phase row was O(phases ×
313
+ * agents) per frame. Callers that render every phase use this instead.
314
+ */
315
+ agentsByPhase(runId: string): Map<string, AgentRow[]> {
316
+ const out = new Map<string, AgentRow[]>();
317
+ const snap = this.snapshot(runId)?.snapshot;
318
+ if (!snap || !Array.isArray(snap.agents)) return out;
319
+ for (const a of snap.agents) {
320
+ const key = agentPhaseKey(a);
321
+ let arr = out.get(key);
322
+ if (!arr) {
323
+ arr = [];
324
+ out.set(key, arr);
325
+ }
326
+ arr.push(toAgentRow(a));
327
+ }
328
+ return out;
329
+ }
330
+
331
+ agentDetail(runId: string, agentId: number): WorkflowAgentSnapshot | undefined {
332
+ return this.snapshot(runId)?.snapshot.agents.find((a) => a.id === agentId);
333
+ }
334
+ }
335
+
336
+ type StackFrame = {
337
+ kind: ViewKind;
338
+ cursor: number;
339
+ runId?: string;
340
+ phase?: string;
341
+ agentId?: number;
342
+ savedName?: string;
343
+ };
344
+
345
+ function persistedToSnapshot(p: PersistedRunState): WorkflowSnapshot {
346
+ // Array guards (#110): structurally corrupt persisted arrays must not crash
347
+ // the overlay. Resumable runs also avoid duplicating full results in agents[]
348
+ // and the journal, so rehydrate done agents by namespaced call identity. The
349
+ // positional index remains a fallback for files written before callId existed.
350
+ const agents = (Array.isArray(p.agents) ? p.agents : []).filter((agent) => agent && typeof agent === "object");
351
+ const journalByIndex = new Map<number, unknown>();
352
+ const journalByCallId = new Map<string, unknown>();
353
+ for (const entry of Array.isArray(p.journal) ? p.journal : []) {
354
+ if (entry && typeof entry === "object" && typeof entry.index === "number") {
355
+ journalByIndex.set(entry.index, entry.result);
356
+ journalByCallId.set(`${entry.runId ?? p.runId}:${entry.index}`, entry.result);
357
+ }
358
+ }
359
+ const snapshotAgents = agents.map((a, callIndex) => {
360
+ const journalResult = a.callId ? journalByCallId.get(a.callId) : journalByIndex.get(callIndex);
361
+ const result = a.result === undefined && a.status === "done" ? journalResult : a.result;
362
+ return {
363
+ id: a.id,
364
+ callId: a.callId,
365
+ label: a.label,
366
+ phase: a.phase,
367
+ prompt: a.prompt,
368
+ status: a.status,
369
+ result,
370
+ resultPreview:
371
+ result === undefined ? a.resultPreview : String(typeof result === "string" ? result : JSON.stringify(result)),
372
+ error: a.error,
373
+ errorCode: a.errorCode,
374
+ recoverable: a.recoverable,
375
+ history: a.history,
376
+ tokens: a.tokens,
377
+ tokenUsage: a.tokenUsage,
378
+ model: a.model,
379
+ };
380
+ });
381
+ return {
382
+ name: asText(p.workflowName),
383
+ phases: Array.isArray(p.phases) ? p.phases : [],
384
+ currentPhase: p.currentPhase,
385
+ logs: Array.isArray(p.logs) ? p.logs : [],
386
+ agents: snapshotAgents,
387
+ agentCount: snapshotAgents.length,
388
+ runningCount: snapshotAgents.filter((a) => a.status === "running").length,
389
+ doneCount: snapshotAgents.filter((a) => a.status === "done").length,
390
+ errorCount: snapshotAgents.filter((a) => a.status === "error").length,
391
+ tokenUsage: p.tokenUsage ? { ...p.tokenUsage } : undefined,
392
+ runId: p.runId,
393
+ };
394
+ }
395
+
396
+ /** Navigation state machine: a stack of (view, cursor) frames plus detail scroll. */
397
+ export class NavigatorState {
398
+ private stack: StackFrame[] = [{ kind: "runs", cursor: 0 }];
399
+ scroll = 0;
400
+ tailing = false;
401
+ pagerOpen = false;
402
+ private pageSize = 1;
403
+
404
+ private top(): StackFrame {
405
+ return this.stack[this.stack.length - 1];
406
+ }
407
+ get kind(): ViewKind {
408
+ return this.top().kind;
409
+ }
410
+ get cursor(): number {
411
+ return this.top().cursor;
412
+ }
413
+ set cursor(val: number) {
414
+ this.top().cursor = val;
415
+ }
416
+ get runId(): string | undefined {
417
+ return this.top().runId;
418
+ }
419
+ get phase(): string | undefined {
420
+ return this.top().phase;
421
+ }
422
+ get agentId(): number | undefined {
423
+ return this.top().agentId;
424
+ }
425
+ /** The saved workflow name at the cursor in savedDetail view */
426
+ get savedName(): string | undefined {
427
+ return this.top().savedName;
428
+ }
429
+ get depth(): number {
430
+ return this.stack.length;
431
+ }
432
+
433
+ /**
434
+ * Determine what kind of item is at the given cursor position in the
435
+ * runs view. Positions before runs.length are "run"; after are "saved".
436
+ */
437
+ itemKindAt(model: NavigatorModel, cursor: number): ItemKind {
438
+ const runCount = model.runs().length;
439
+ return cursor < runCount ? "run" : "saved";
440
+ }
441
+
442
+ /** Clamp the cursor to [0, count). */
443
+ clamp(count: number) {
444
+ const t = this.top();
445
+ t.cursor = count <= 0 ? 0 : Math.max(0, Math.min(t.cursor, count - 1));
446
+ }
447
+
448
+ move(delta: number, count: number) {
449
+ if (this.kind === "detail" || this.kind === "savedDetail") {
450
+ if (this.kind === "detail") this.pagerOpen = true;
451
+ if (delta < 0) this.tailing = false;
452
+ this.scroll = Math.max(0, this.scroll + delta);
453
+ return;
454
+ }
455
+ if (count <= 0) return;
456
+ const t = this.top();
457
+ t.cursor = (t.cursor + delta + count) % count;
458
+ }
459
+
460
+ /** Update the amount moved by page keys to match the rendered viewport. */
461
+ setPageSize(rows: number) {
462
+ this.pageSize = Math.max(1, rows);
463
+ }
464
+
465
+ /** Move by almost one viewport, retaining one line of reading context. */
466
+ movePage(direction: -1 | 1, count: number) {
467
+ const delta = direction * Math.max(1, this.pageSize - 1);
468
+ if (this.kind === "detail" || this.kind === "savedDetail") {
469
+ if (this.kind === "detail") this.pagerOpen = true;
470
+ if (direction < 0) this.tailing = false;
471
+ this.scroll = Math.max(0, this.scroll + delta);
472
+ return;
473
+ }
474
+ if (count > 0) this.cursor = Math.max(0, Math.min(count - 1, this.cursor + delta));
475
+ }
476
+
477
+ /** Jump to the beginning or end of the current list/detail. End also enables
478
+ * follow mode for a live agent detail; start disables it. */
479
+ jump(edge: "start" | "end", count: number) {
480
+ if (this.kind === "detail" || this.kind === "savedDetail") {
481
+ if (this.kind === "detail") this.pagerOpen = true;
482
+ this.tailing = this.kind === "detail" && edge === "end";
483
+ // renderNavigator knows the body length and clamps this sentinel.
484
+ this.scroll = edge === "start" ? 0 : Number.MAX_SAFE_INTEGER;
485
+ return;
486
+ }
487
+ this.cursor = edge === "start" || count <= 0 ? 0 : count - 1;
488
+ }
489
+
490
+ /** Open the full pager without closing an already-open pager. */
491
+ openPager(): boolean {
492
+ if (this.kind !== "detail") return false;
493
+ if (!this.pagerOpen) {
494
+ this.pagerOpen = true;
495
+ this.scroll = 0;
496
+ }
497
+ return true;
498
+ }
499
+
500
+ /** Toggle the full pager while retaining the compact agent summary view. */
501
+ togglePager(): boolean {
502
+ if (this.kind !== "detail") return false;
503
+ if (!this.pagerOpen) return this.openPager();
504
+ this.pagerOpen = false;
505
+ this.scroll = 0;
506
+ this.tailing = false;
507
+ return false;
508
+ }
509
+
510
+ /** Toggle live follow mode in an agent detail pager. */
511
+ toggleTail(): boolean {
512
+ if (this.kind !== "detail") return false;
513
+ this.pagerOpen = true;
514
+ this.tailing = !this.tailing;
515
+ if (this.tailing) this.scroll = Number.MAX_SAFE_INTEGER;
516
+ return this.tailing;
517
+ }
518
+
519
+ /** Drill into the selected item. Returns true if the view changed. */
520
+ drill(model: NavigatorModel): boolean {
521
+ const t = this.top();
522
+ if (t.kind === "runs") {
523
+ const runs = model.runs();
524
+ const saved = model.saved();
525
+ if (t.cursor < runs.length) {
526
+ // Drilling into a run
527
+ const run = runs[t.cursor];
528
+ if (!run) return false;
529
+ this.stack.push({ kind: "phases", cursor: 0, runId: run.runId });
530
+ return true;
531
+ }
532
+ // Drilling into a saved workflow
533
+ const item = saved[t.cursor - runs.length];
534
+ if (!item) return false;
535
+ this.scroll = 0;
536
+ this.tailing = false;
537
+ this.pagerOpen = false;
538
+ this.stack.push({ kind: "savedDetail", cursor: 0, savedName: item.name });
539
+ return true;
540
+ }
541
+ if (t.kind === "phases" && t.runId) {
542
+ const phases = model.phases(t.runId);
543
+ const ph = phases[t.cursor];
544
+ if (!ph) return false;
545
+ this.stack.push({ kind: "agents", cursor: 0, runId: t.runId, phase: ph.title });
546
+ return true;
547
+ }
548
+ if (t.kind === "agents" && t.runId && t.phase) {
549
+ const agents = model.agents(t.runId, t.phase);
550
+ const ag = agents[t.cursor];
551
+ if (!ag) return false;
552
+ this.scroll = 0;
553
+ this.tailing = false;
554
+ this.pagerOpen = false;
555
+ this.stack.push({ kind: "detail", cursor: 0, runId: t.runId, phase: t.phase, agentId: ag.id });
556
+ return true;
557
+ }
558
+ return false;
559
+ }
560
+
561
+ /** Pop one level. Returns false when already at the top (caller should close). */
562
+ back(): boolean {
563
+ if (this.kind === "detail" && this.pagerOpen) {
564
+ this.pagerOpen = false;
565
+ this.scroll = 0;
566
+ this.tailing = false;
567
+ return true;
568
+ }
569
+ if (this.stack.length <= 1) return false;
570
+ this.stack.pop();
571
+ this.scroll = 0;
572
+ this.tailing = false;
573
+ this.pagerOpen = false;
574
+ return true;
575
+ }
576
+
577
+ /** The runId at cursor, or undefined when on a saved item. */
578
+ activeRunId(model: NavigatorModel): string | undefined {
579
+ if (this.runId) return this.runId;
580
+ if (this.kind === "runs") {
581
+ const runs = model.runs();
582
+ if (this.cursor < runs.length) return runs[this.cursor]?.runId;
583
+ }
584
+ return undefined;
585
+ }
586
+ }
587
+
588
+ function pad(n: number): string {
589
+ return n.toLocaleString();
590
+ }
591
+
592
+ // ───────────────────────────────────────────────────────────────────────────
593
+ // Two-pane (Phases | agents) renderer — Claude-Code parity.
594
+ //
595
+ // Draws a single combined frame that shares one top rule and one full-height
596
+ // vertical divider between a left "Phases" box and a right "<phase> · N agent"
597
+ // box. Pure: depends only on state + model + theme + width. All measuring is
598
+ // ANSI-aware (visibleWidth) and all padding/truncation goes through
599
+ // truncateToWidth so colored cells still align.
600
+ // ───────────────────────────────────────────────────────────────────────────
601
+
602
+ // Light box-drawing glyphs (no heavy/double variants).
603
+ const BX = { h: "─", v: "│", tl: "┌", tr: "┐", bl: "└", br: "┘", tj: "┬", bj: "┴" } as const;
604
+ const CARET = "›";
605
+ const DOT = "●";
606
+ const ELLIPSIS = "…";
607
+
608
+ // Tunables (exposed for clarity / future tuning) — see spec §0/§10.
609
+ const LW_MIN = 14;
610
+ const RW_MIN = 24;
611
+ const GAP_NM = 2; // min spaces between agent name and model columns
612
+
613
+ /** Compact token count: 842, 35k, 35.7k, 1.3M (trailing .0 trimmed). */
614
+ function compactTokens(t: number): string {
615
+ if (!t || t <= 0) return "0";
616
+ if (t < 1000) return String(Math.round(t));
617
+ if (t < 1_000_000) {
618
+ const k = t / 1000;
619
+ const s = k >= 100 ? Math.round(k).toString() : trimZero(k.toFixed(1));
620
+ return `${s}k`;
621
+ }
622
+ const m = t / 1_000_000;
623
+ return `${trimZero(m.toFixed(1))}M`;
624
+ }
625
+ function trimZero(s: string): string {
626
+ return s.endsWith(".0") ? s.slice(0, -2) : s;
627
+ }
628
+
629
+ function pluralize(word: string, n: number): string {
630
+ return n === 1 ? word : `${word}s`;
631
+ }
632
+
633
+ /** Aggregate phase status precedence: ERR > RUN > all-done(OK) > PEND. */
634
+ function phaseStatusColor(p: { done: number; total: number }, agents: AgentRow[]): string {
635
+ if (agents.some((a) => a.status === "error" || a.status === "failed")) return "error";
636
+ if (agents.some((a) => a.status === "running")) return "warning";
637
+ if (p.total > 0 && p.done === p.total) return "success";
638
+ return "dim";
639
+ }
640
+
641
+ const AGENT_DOT_COLOR: Record<string, string> = {
642
+ running: "warning",
643
+ queued: "dim",
644
+ pending: "dim",
645
+ paused: "dim",
646
+ done: "success",
647
+ completed: "success",
648
+ error: "error",
649
+ failed: "error",
650
+ skipped: "dim",
651
+ aborted: "dim",
652
+ };
653
+
654
+ /** Compute the left ("Phases") box outer width, clamped per spec §3.1. */
655
+ function computeLeftWidth(phases: PhaseRow[], width: number): number {
656
+ const titleNeed = visibleWidth("Phases") + 2 /*spaces*/ + 1 /*┌*/ + 1 /*┬*/ + 3 /*min dashes*/;
657
+ let contentMax = 0;
658
+ phases.forEach((p, i) => {
659
+ const idx = String(i + 1);
660
+ const hasAgents = p.total > 0;
661
+ const need =
662
+ 2 /*marker*/ +
663
+ visibleWidth(idx) +
664
+ 1 /*sp*/ +
665
+ visibleWidth(p.title) +
666
+ (hasAgents ? 1 + visibleWidth(`${p.done}/${p.total}`) : 0);
667
+ if (need > contentMax) contentMax = need;
668
+ });
669
+ const innerNeed = Math.max(contentMax, titleNeed - 2);
670
+ const lwNatural = innerNeed + 2; // + left │ + shared │
671
+ const lwMax = Math.min(40, Math.floor(width * 0.45));
672
+ return Math.max(LW_MIN, Math.min(lwNatural, Math.max(LW_MIN, lwMax)));
673
+ }
674
+
675
+ /** Build a left-pane phase row (content field, exact width = innerW). */
676
+ function leftPhaseRow(
677
+ p: PhaseRow,
678
+ i: number,
679
+ selected: boolean,
680
+ agents: AgentRow[],
681
+ innerW: number,
682
+ theme: ThemeLike,
683
+ ): string {
684
+ const idx = String(i + 1);
685
+ const hasAgents = p.total > 0;
686
+ const progress = hasAgents ? `${p.done}/${p.total}` : "";
687
+ const marker = selected ? `${CARET} ` : " ";
688
+ // Fixed parts width: marker + idx + space + (space+progress if shown)
689
+ const fixed = 2 + visibleWidth(idx) + 1 + (progress ? 1 + visibleWidth(progress) : 0);
690
+ const nameRoom = Math.max(0, innerW - fixed);
691
+ const name = truncateToWidth(p.title, nameRoom, ELLIPSIS, false);
692
+
693
+ const styleMain = (s: string) => (selected ? theme.fg("accent", theme.bold(s)) : hasAgents ? s : theme.fg("dim", s));
694
+ const progStyle = (s: string) =>
695
+ selected ? theme.fg("accent", theme.bold(s)) : theme.fg(phaseStatusColor(p, agents), s);
696
+
697
+ const caret = selected ? theme.fg("accent", theme.bold(marker)) : marker;
698
+ let row = caret + styleMain(`${idx} ${name}`);
699
+ if (progress) row += ` ${progStyle(progress)}`;
700
+ return truncateToWidth(row, innerW, "", true); // pad to exact innerW
701
+ }
702
+
703
+ /** Build a right-pane agent row (content field, exact width = innerW). */
704
+ function rightAgentRow(
705
+ a: AgentRow,
706
+ selected: boolean,
707
+ modelColStart: number,
708
+ innerW: number,
709
+ theme: ThemeLike,
710
+ ): string {
711
+ const dotColor = AGENT_DOT_COLOR[a.status] ?? "dim";
712
+ const stats = fmtTokenSegment(tokenFigures(a.tokenUsage, a.tokens), compactTokens);
713
+ const model = shortModel(a.model) ?? "";
714
+
715
+ // Stable 2-cell marker so columns never shift on selection: "› " | " ".
716
+ // Layout: <marker:2><dot><sp><name> … <model> … <stats(right-aligned)>.
717
+ const markerW = 2;
718
+ const statsW = visibleWidth(stats);
719
+ const nameStart = markerW + 2; // marker + dot + space
720
+ let modelStart = Math.max(nameStart + visibleWidth(a.label) + GAP_NM, markerW + modelColStart);
721
+ const statsStart = innerW - statsW;
722
+
723
+ // Available room for the model block (between modelStart and stats, min 1 gap).
724
+ let modelRoom = statsStart - 1 - modelStart;
725
+ let nameOut = a.label;
726
+ let modelOut = model;
727
+ if (modelRoom < 0) {
728
+ // No room for model: drop it (spec §4.4 step 1/2), possibly truncate name.
729
+ modelOut = "";
730
+ modelStart = nameStart;
731
+ modelRoom = 0;
732
+ const nameRoom = Math.max(0, statsStart - 1 - nameStart);
733
+ nameOut = truncateToWidth(a.label, nameRoom, ELLIPSIS, false);
734
+ } else {
735
+ modelOut = truncateToWidth(model, modelRoom, ELLIPSIS, false);
736
+ const nameRoom = Math.max(0, modelStart - GAP_NM - nameStart);
737
+ nameOut = truncateToWidth(a.label, nameRoom, ELLIPSIS, false);
738
+ }
739
+
740
+ const marker = selected ? theme.fg("accent", theme.bold(`${CARET} `)) : " ";
741
+ const dot = theme.fg(dotColor, DOT);
742
+ const nameStyled = selected ? theme.fg("accent", theme.bold(nameOut)) : theme.fg("accent", nameOut);
743
+ const modelStyled = modelOut ? theme.fg("dim", modelOut) : "";
744
+ const statsStyled = theme.fg("dim", stats);
745
+
746
+ // Assemble with explicit cell padding (visibleWidth-driven gaps).
747
+ let out = marker + dot + " " + nameStyled;
748
+ const afterName = nameStart + visibleWidth(nameOut);
749
+ if (modelOut) {
750
+ out += " ".repeat(Math.max(0, modelStart - afterName)) + modelStyled;
751
+ const afterModel = modelStart + visibleWidth(modelOut);
752
+ out += " ".repeat(Math.max(0, statsStart - afterModel)) + statsStyled;
753
+ } else {
754
+ out += " ".repeat(Math.max(0, statsStart - afterName)) + statsStyled;
755
+ }
756
+ return truncateToWidth(out, innerW, "", true);
757
+ }
758
+
759
+ /** Compose a titled top rule for one box side (between two join chars). */
760
+ function topTitleSegment(title: string, innerW: number, leading: boolean, theme: ThemeLike): string {
761
+ // leading=true → right box (one ─ before the title); leading=false → left box.
762
+ const label = ` ${title} `;
763
+ const lead = leading ? BX.h : "";
764
+ let labelOut = label;
765
+ const fixed = visibleWidth(lead) + 1; // + at least one trailing dash
766
+ if (visibleWidth(label) > innerW - fixed) {
767
+ labelOut = truncateToWidth(label, Math.max(0, innerW - fixed), ELLIPSIS, false);
768
+ }
769
+ const used = visibleWidth(lead) + visibleWidth(labelOut);
770
+ const dashes = BX.h.repeat(Math.max(0, innerW - used));
771
+ return theme.fg("muted", lead) + theme.fg("dim", labelOut) + theme.fg("muted", dashes);
772
+ }
773
+
774
+ interface TwoPaneArgs {
775
+ width: number;
776
+ bodyRows: number;
777
+ left: string[]; // pre-rendered left content rows (exact LW-2 cells each)
778
+ right: string[]; // pre-rendered right content rows (exact RW-2 cells each)
779
+ leftTitle: string;
780
+ rightTitle: string;
781
+ leftW: number; // LW
782
+ theme: ThemeLike;
783
+ }
784
+
785
+ /** Emit the full combined frame (top rule, body rows, bottom rule). */
786
+ function renderTwoPaneFrame(a: TwoPaneArgs): string[] {
787
+ const { width, bodyRows, left, right, leftTitle, rightTitle, leftW, theme } = a;
788
+ // RW fills the remainder; the divider column is shared (overlaps 1 cell) so
789
+ // net rendered width = LW + RW - 1 = width. Hence RW = width - LW + 1.
790
+ const rightW = width - leftW + 1;
791
+ const leftInner = leftW - 2;
792
+ const rightInner = rightW - 2;
793
+ const bc = (s: string) => theme.fg("muted", s);
794
+ const out: string[] = [];
795
+
796
+ // Top rule: ┌ <left title> ┬ <right title> ┐
797
+ out.push(
798
+ bc(BX.tl) +
799
+ topTitleSegment(leftTitle, leftInner, false, theme) +
800
+ bc(BX.tj) +
801
+ topTitleSegment(rightTitle, rightInner, true, theme) +
802
+ bc(BX.tr),
803
+ );
804
+
805
+ // Body rows.
806
+ const blankL = " ".repeat(leftInner);
807
+ const blankR = " ".repeat(rightInner);
808
+ for (let r = 0; r < bodyRows; r++) {
809
+ const l = left[r] ?? blankL;
810
+ const rr = right[r] ?? blankR;
811
+ out.push(bc(BX.v) + l + bc(BX.v) + rr + bc(BX.v));
812
+ }
813
+
814
+ // Bottom rule: └ ─ ┴ ─ ┘
815
+ out.push(bc(BX.bl) + bc(BX.h.repeat(leftInner)) + bc(BX.bj) + bc(BX.h.repeat(rightInner)) + bc(BX.br));
816
+ return out;
817
+ }
818
+
819
+ /**
820
+ * Render the combined Phases | agents two-pane view. Shared by the "phases"
821
+ * branch (cursor in left/Phases pane) and the "agents" branch (cursor in
822
+ * right/agents pane after drilling in). Returns the full frame as lines.
823
+ */
824
+ function renderPhasesAgents(
825
+ state: NavigatorState,
826
+ model: NavigatorModel,
827
+ runId: string,
828
+ width: number,
829
+ theme: ThemeLike,
830
+ bodyCap: number,
831
+ ): string[] {
832
+ const phases = model.phases(runId);
833
+ // Group agents by phase ONCE per frame (O(agents)). leftPhaseRow needs each
834
+ // visible phase's agents (status colour) and the selected phase's agents drive
835
+ // the right pane; calling model.agents() per phase row was O(phases × agents).
836
+ const agentsByPhase = model.agentsByPhase(runId);
837
+ const agentsOf = (title: string): AgentRow[] => agentsByPhase.get(title) ?? [];
838
+ // Which phase is selected drives the right pane. In "phases" view it's the
839
+ // cursor; in "agents" view it's the drilled-in phase (state.phase).
840
+ const inAgents = state.kind === "agents";
841
+ let selPhaseIdx = inAgents ? phases.findIndex((p) => p.title === state.phase) : state.cursor;
842
+ if (selPhaseIdx < 0) selPhaseIdx = 0;
843
+ const selPhase = phases[selPhaseIdx];
844
+ const agents = selPhase ? agentsOf(selPhase.title) : [];
845
+
846
+ // Narrow-terminal degrade: single pane (spec §7.1).
847
+ if (width < LW_MIN + RW_MIN - 1) {
848
+ return renderSinglePane(state, phases, selPhaseIdx, agents, width, theme, bodyCap, inAgents);
849
+ }
850
+
851
+ const leftW = computeLeftWidth(phases, width);
852
+ const rightW = width - leftW + 1; // shared divider overlaps 1 cell
853
+ const leftInner = leftW - 2;
854
+ const rightInner = rightW - 2;
855
+
856
+ // Vertical scroll so the active item stays visible (spec §7.2).
857
+ const leftRows = scrollWindow(phases.length, inAgents ? selPhaseIdx : state.cursor, bodyCap);
858
+ const rightRows = scrollWindow(agents.length, inAgents ? state.cursor : 0, bodyCap);
859
+ const bodyRows = Math.max(1, Math.min(bodyCap, Math.max(leftRows.count, rightRows.count)));
860
+
861
+ // Left column (Phases).
862
+ const left: string[] = [];
863
+ for (let k = 0; k < bodyRows; k++) {
864
+ const idx = leftRows.start + k;
865
+ if (idx >= phases.length) {
866
+ left.push(" ".repeat(leftInner));
867
+ continue;
868
+ }
869
+ const p = phases[idx];
870
+ const selected = !inAgents && idx === state.cursor;
871
+ const ag = agentsOf(p.title);
872
+ let row = leftPhaseRow(p, idx, selected, ag, leftInner, theme);
873
+ if (k === bodyRows - 1 && leftRows.more) {
874
+ row = truncateToWidth(theme.fg("dim", ` ${ELLIPSIS}`), leftInner, "", true);
875
+ }
876
+ left.push(row);
877
+ }
878
+
879
+ // Right column (agents of selected phase).
880
+ const modelColStart = computeModelColStart(agents, rightInner);
881
+ const right: string[] = [];
882
+ if (agents.length === 0) {
883
+ const msg = truncateToWidth(theme.fg("dim", "no agents"), rightInner, "", true);
884
+ for (let k = 0; k < bodyRows; k++) right.push(k === 0 ? msg : " ".repeat(rightInner));
885
+ } else {
886
+ for (let k = 0; k < bodyRows; k++) {
887
+ const idx = rightRows.start + k;
888
+ if (idx >= agents.length) {
889
+ right.push(" ".repeat(rightInner));
890
+ continue;
891
+ }
892
+ const selected = inAgents && idx === state.cursor;
893
+ let row = rightAgentRow(agents[idx], selected, modelColStart, rightInner, theme);
894
+ if (k === bodyRows - 1 && rightRows.more) {
895
+ row = truncateToWidth(theme.fg("dim", ` ${ELLIPSIS}`), rightInner, "", true);
896
+ }
897
+ right.push(row);
898
+ }
899
+ }
900
+
901
+ const n = agents.length;
902
+ const rightTitle = `${selPhase ? selPhase.title : "(none)"} · ${n} ${pluralize("agent", n)}`;
903
+ return renderTwoPaneFrame({
904
+ width,
905
+ bodyRows,
906
+ left,
907
+ right,
908
+ leftTitle: "Phases",
909
+ rightTitle,
910
+ leftW,
911
+ theme,
912
+ });
913
+ }
914
+
915
+ /** Model column start aligned across agent rows (spec §4.3), clamped to field. */
916
+ function computeModelColStart(agents: AgentRow[], innerW: number): number {
917
+ let maxName = 0;
918
+ for (const a of agents) maxName = Math.max(maxName, visibleWidth(a.label));
919
+ const start = 2 /*dot+sp*/ + maxName + GAP_NM;
920
+ // Keep model column from colliding with the right edge; cap at ~55% of field.
921
+ return Math.min(start, Math.max(2, Math.floor(innerW * 0.55)));
922
+ }
923
+
924
+ interface ScrollWin {
925
+ start: number;
926
+ count: number;
927
+ more: boolean;
928
+ }
929
+ /** Compute a scroll window of up to `cap` rows keeping `active` visible. */
930
+ function scrollWindow(total: number, active: number, cap: number): ScrollWin {
931
+ if (total <= cap) return { start: 0, count: total, more: false };
932
+ let start = Math.max(0, Math.min(active - Math.floor(cap / 2), total - cap));
933
+ if (active < start) start = active;
934
+ if (active >= start + cap) start = active - cap + 1;
935
+ return { start, count: cap, more: start + cap < total };
936
+ }
937
+
938
+ /** Narrow-terminal single pane (spec §7.1): show the active pane full width. */
939
+ function renderSinglePane(
940
+ state: NavigatorState,
941
+ phases: PhaseRow[],
942
+ selPhaseIdx: number,
943
+ agents: AgentRow[],
944
+ width: number,
945
+ theme: ThemeLike,
946
+ bodyCap: number,
947
+ inAgents: boolean,
948
+ ): string[] {
949
+ const innerW = Math.max(1, width - 2);
950
+ const bc = (s: string) => theme.fg("muted", s);
951
+ const out: string[] = [];
952
+ if (inAgents) {
953
+ const selPhase = phases[selPhaseIdx];
954
+ const n = agents.length;
955
+ const title = `${selPhase ? selPhase.title : "(none)"} · ${n} ${pluralize("agent", n)}`;
956
+ out.push(bc(BX.tl) + topTitleSegment(title, innerW, false, theme) + bc(BX.tr));
957
+ const win = scrollWindow(agents.length, state.cursor, bodyCap);
958
+ const modelColStart = computeModelColStart(agents, innerW);
959
+ const rows = Math.max(1, win.count);
960
+ for (let k = 0; k < rows; k++) {
961
+ const idx = win.start + k;
962
+ if (idx >= agents.length) {
963
+ out.push(bc(BX.v) + " ".repeat(innerW) + bc(BX.v));
964
+ continue;
965
+ }
966
+ let row = rightAgentRow(agents[idx], idx === state.cursor, modelColStart, innerW, theme);
967
+ if (k === rows - 1 && win.more) row = truncateToWidth(theme.fg("dim", ` ${ELLIPSIS}`), innerW, "", true);
968
+ out.push(bc(BX.v) + row + bc(BX.v));
969
+ }
970
+ } else {
971
+ out.push(bc(BX.tl) + topTitleSegment("Phases", innerW, false, theme) + bc(BX.tr));
972
+ const win = scrollWindow(phases.length, state.cursor, bodyCap);
973
+ const rows = Math.max(1, win.count);
974
+ for (let k = 0; k < rows; k++) {
975
+ const idx = win.start + k;
976
+ if (idx >= phases.length) {
977
+ out.push(bc(BX.v) + " ".repeat(innerW) + bc(BX.v));
978
+ continue;
979
+ }
980
+ const p = phases[idx];
981
+ let row = leftPhaseRow(p, idx, idx === state.cursor, [], innerW, theme);
982
+ if (k === rows - 1 && win.more) row = truncateToWidth(theme.fg("dim", ` ${ELLIPSIS}`), innerW, "", true);
983
+ out.push(bc(BX.v) + row + bc(BX.v));
984
+ }
985
+ }
986
+ out.push(bc(BX.bl) + bc(BX.h.repeat(innerW)) + bc(BX.br));
987
+ return out;
988
+ }
989
+
990
+ /** Build the lines for the current view. Pure: depends only on state + model + theme. */
991
+ export function renderNavigator(
992
+ state: NavigatorState,
993
+ model: NavigatorModel,
994
+ width: number,
995
+ theme: ThemeLike = PLAIN,
996
+ viewportRows = 24,
997
+ markdownTheme?: MarkdownTheme,
998
+ ): string[] {
999
+ return model.withRenderFrame(() =>
1000
+ renderNavigatorFrame(state, model, width, theme, viewportRows, markdownTheme, undefined),
1001
+ );
1002
+ }
1003
+
1004
+ function renderNavigatorFrame(
1005
+ state: NavigatorState,
1006
+ model: NavigatorModel,
1007
+ width: number,
1008
+ theme: ThemeLike,
1009
+ viewportRows: number,
1010
+ markdownTheme: MarkdownTheme | undefined,
1011
+ renderCache: NavigatorTextRenderCache | undefined,
1012
+ ): string[] {
1013
+ const lines: string[] = [];
1014
+ state.setPageSize(Math.max(1, viewportRows - 5));
1015
+ const sel = (i: number, text: string) =>
1016
+ i === state.cursor ? theme.fg("accent", theme.bold(`❯ ${text}`)) : ` ${text}`;
1017
+ const dim = (t: string) => theme.fg("dim", t);
1018
+
1019
+ // Render a detail body inside a FIXED-height viewport so j/k scrolls within a
1020
+ // stable box (clamping state.scroll) instead of slicing to the end — which
1021
+ // shrank the overlay and looked like it was collapsing.
1022
+ const pushScrollable = (body: string[]) => {
1023
+ const viewport = Math.max(1, viewportRows - 4); // reserve title + blank + footer + indicator
1024
+ state.setPageSize(viewport);
1025
+ const maxScroll = Math.max(0, body.length - viewport);
1026
+ if (state.kind === "detail" && state.tailing) state.scroll = maxScroll;
1027
+ state.scroll = Math.min(Math.max(0, state.scroll), maxScroll);
1028
+ lines.push(...body.slice(state.scroll, state.scroll + viewport));
1029
+ if (body.length > viewport) {
1030
+ const end = Math.min(state.scroll + viewport, body.length);
1031
+ const up = state.scroll > 0 ? "↑" : " ";
1032
+ const down = end < body.length ? "↓" : " ";
1033
+ const mode = state.kind === "detail" && state.tailing ? " TAIL" : "";
1034
+ lines.push(dim(` [${state.scroll + 1}-${end} / ${body.length}] ${up}${down}${mode}`));
1035
+ }
1036
+ };
1037
+
1038
+ // Compact agent details are deliberately not a pager: they show the useful
1039
+ // current snapshot and reserve scrolling for the explicit full-pager view.
1040
+ const pushCompact = (body: string[]) => {
1041
+ const viewport = Math.max(1, viewportRows - 3); // title + blank + footer
1042
+ if (body.length <= viewport) {
1043
+ lines.push(...body);
1044
+ return;
1045
+ }
1046
+ lines.push(...body.slice(0, Math.max(1, viewport - 1)));
1047
+ lines.push(dim(" … enter to open full pager"));
1048
+ };
1049
+
1050
+ if (state.kind === "runs") {
1051
+ const runs = model.runs();
1052
+ const saved = model.saved();
1053
+ const total = runs.length + saved.length;
1054
+ state.clamp(total);
1055
+
1056
+ // Keep the selected run visible when history exceeds the overlay height.
1057
+ const bodyCap = Math.max(1, viewportRows - 3); // title + blank + footer
1058
+ let win = scrollWindow(total, state.cursor, bodyCap);
1059
+ const windowEnd = () => win.start + win.count;
1060
+ const crossesSavedBoundary = () =>
1061
+ runs.length > 0 && saved.length > 0 && win.start < runs.length && windowEnd() > runs.length;
1062
+ if (crossesSavedBoundary() && bodyCap > 1) win = scrollWindow(total, state.cursor, bodyCap - 1);
1063
+ const up = win.start > 0 ? "↑" : " ";
1064
+ const down = windowEnd() < total ? "↓" : " ";
1065
+ const range =
1066
+ win.start > 0 || windowEnd() < total ? dim(` [${up} ${win.start + 1}-${windowEnd()} / ${total} ${down}]`) : "";
1067
+ lines.push(theme.bold(`Workflows${range}`));
1068
+
1069
+ if (total === 0) {
1070
+ lines.push(dim(" No runs yet. Start one with a background workflow."));
1071
+ }
1072
+ for (let i = win.start; i < windowEnd(); i++) {
1073
+ if (i === runs.length && runs.length > 0 && saved.length > 0) lines.push(dim(" ── saved ──"));
1074
+ if (i < runs.length) {
1075
+ const r = runs[i];
1076
+ if (!r) continue;
1077
+ const icon = STATUS_ICON[r.status] ?? "?";
1078
+ const tok = fmtTokenSegment(r, pad);
1079
+ const meta = [`${r.done}/${r.total}`, tok, r.cost > 0 ? fmtCost(r.cost) : ""].filter(Boolean).join(" · ");
1080
+ lines.push(sel(i, `${icon} ${r.name} ${dim(`${r.runId} · ${r.status} · ${meta}`)}`));
1081
+ } else {
1082
+ const w = saved[i - runs.length];
1083
+ if (!w) continue;
1084
+ const loc = w.location === "user" ? "~" : ".";
1085
+ const desc = w.description ? dim(` ${w.description}`) : "";
1086
+ lines.push(sel(i, `${w.name}${desc} ${dim(loc)}`));
1087
+ }
1088
+ }
1089
+ } else if (state.kind === "phases" && state.runId) {
1090
+ const phases = model.phases(state.runId);
1091
+ state.clamp(phases.length);
1092
+ // Two-line header (name + description/status) then the combined frame.
1093
+ lines.push(...twoPaneHeader(model, state.runId, phases, width, theme));
1094
+ // Body cap: total height minus 2 header + 2 frame rules + blank + footer.
1095
+ const bodyCap = Math.max(1, viewportRows - 2 /*header*/ - 2 /*rules*/ - 2 /*blank+footer*/);
1096
+ lines.push(...renderPhasesAgents(state, model, state.runId, width, theme, bodyCap));
1097
+ } else if (state.kind === "agents" && state.runId && state.phase) {
1098
+ const agents = model.agents(state.runId, state.phase);
1099
+ state.clamp(agents.length);
1100
+ const phases = model.phases(state.runId);
1101
+ lines.push(...twoPaneHeader(model, state.runId, phases, width, theme));
1102
+ const bodyCap = Math.max(1, viewportRows - 2 - 2 - 2);
1103
+ lines.push(...renderPhasesAgents(state, model, state.runId, width, theme, bodyCap));
1104
+ } else if (state.kind === "detail" && state.runId && state.agentId != null) {
1105
+ const a = model.agentDetail(state.runId, state.agentId);
1106
+ lines.push(theme.bold(a ? asText(a.label) : "agent"));
1107
+ if (a) {
1108
+ // Coerce every dynamic value before wrap() (#110): a non-string prompt is
1109
+ // reachable even from a LIVE run — agent(42) in a model-written script is
1110
+ // never type-checked — and would crash wrap()'s text.split(). Persisted
1111
+ // error/status/history text can be non-string on a corrupt run too.
1112
+ const body: string[] = [];
1113
+ if (state.pagerOpen) {
1114
+ body.push(dim("Status: ") + asText(a.status ?? ""));
1115
+ if (a.model) body.push(dim("Model: ") + (shortModel(a.model) ?? ""));
1116
+ if (a.error) body.push(dim("Error: ") + asText(a.error));
1117
+ if (a.errorCode) {
1118
+ body.push(`${dim("Error code: ")}${asText(a.errorCode)}${a.recoverable ? " (recoverable)" : ""}`);
1119
+ }
1120
+ body.push("", theme.fg("accent", theme.bold("Prompt:")));
1121
+ body.push(...renderMarkdownLines(asText(a.prompt ?? ""), width, markdownTheme, renderCache));
1122
+ body.push("", theme.fg("accent", theme.bold("Result:")));
1123
+ body.push(...renderResultLines(a.result, a.resultPreview, width, markdownTheme, renderCache));
1124
+ if (Array.isArray(a.history) && a.history.length) {
1125
+ body.push("", theme.fg("accent", theme.bold("History:")));
1126
+ for (let i = 0; i < a.history.length; i++) {
1127
+ body.push(...renderHistoryEntryLines(a.history, i, width, markdownTheme, dim, renderCache));
1128
+ }
1129
+ }
1130
+ pushScrollable(body);
1131
+ } else if (a.status === "done") {
1132
+ // Completed agents default to their useful final output; prompt/history
1133
+ // remain one keypress away in the full pager.
1134
+ body.push(theme.fg("accent", theme.bold("Result:")));
1135
+ body.push(...renderResultLines(a.result, a.resultPreview, width, markdownTheme, renderCache));
1136
+ pushCompact(body);
1137
+ } else {
1138
+ // Active/failed agents default to context plus the latest two events.
1139
+ body.push(dim("Status: ") + asText(a.status ?? ""));
1140
+ if (a.model) body.push(dim("Model: ") + (shortModel(a.model) ?? ""));
1141
+ if (a.error) body.push(dim("Error: ") + asText(a.error));
1142
+ if (a.errorCode) {
1143
+ body.push(`${dim("Error code: ")}${asText(a.errorCode)}${a.recoverable ? " (recoverable)" : ""}`);
1144
+ }
1145
+ body.push("", theme.fg("accent", theme.bold("Prompt:")));
1146
+ const promptLines = renderMarkdownLines(asText(a.prompt ?? ""), width, markdownTheme, renderCache);
1147
+ body.push(...promptLines.slice(0, 5));
1148
+ if (promptLines.length > 5) body.push(dim(" … prompt continues in pager"));
1149
+ body.push("", theme.fg("accent", theme.bold("Recent activity:")));
1150
+ if (a.history?.length) {
1151
+ const start = Math.max(0, a.history.length - 2);
1152
+ for (let i = start; i < a.history.length; i++) {
1153
+ const eventLines = renderHistoryEntryLines(a.history, i, width, markdownTheme, dim, renderCache);
1154
+ body.push(...eventLines.slice(0, 4));
1155
+ if (eventLines.length > 4) body.push(dim(" … event continues in pager"));
1156
+ }
1157
+ } else {
1158
+ body.push(dim(" Waiting for the first agent event…"));
1159
+ }
1160
+ pushCompact(body);
1161
+ }
1162
+ }
1163
+ } else if (state.kind === "savedDetail" && state.savedName) {
1164
+ const saved = model.saved();
1165
+ const w = saved.find((s) => s.name === state.savedName);
1166
+ lines.push(theme.bold(w ? w.name : "saved workflow"));
1167
+ if (w) {
1168
+ const body: string[] = [];
1169
+ if (w.description) body.push(dim("Description: ") + asText(w.description));
1170
+ body.push(dim("Location: ") + (w.location === "user" ? "user (~/.pi)" : "project (.pi)"));
1171
+ body.push(dim("Saved at: ") + asText(w.savedAt));
1172
+ if (w.parameters) body.push(dim("Parameters: ") + JSON.stringify(w.parameters));
1173
+ body.push("", theme.fg("accent", theme.bold("Script:")));
1174
+ // Coerce (#110): corrupt saved-workflow JSON can carry a non-string script.
1175
+ body.push(...renderCodeLines(asText(w.script), "javascript", width, markdownTheme, renderCache));
1176
+ pushScrollable(body);
1177
+ }
1178
+ }
1179
+
1180
+ lines.push("");
1181
+ lines.push(footerHint(state, model, theme));
1182
+ return lines;
1183
+ }
1184
+
1185
+ /**
1186
+ * Two-line header above the Phases | agents frame (spec §1):
1187
+ * line 0: <name> (ACCENT_BOLD)
1188
+ * line 1: <status> <done>/<total> agent[s] · <tokens> (DIM)
1189
+ * Right segment is built first and never truncated; the left segment is
1190
+ * truncated to the remaining width with an ellipsis.
1191
+ */
1192
+ function twoPaneHeader(
1193
+ model: NavigatorModel,
1194
+ runId: string,
1195
+ phases: PhaseRow[],
1196
+ width: number,
1197
+ theme: ThemeLike,
1198
+ ): string[] {
1199
+ const name = model.runName(runId);
1200
+ const status = model.runStatus(runId);
1201
+ let done = 0;
1202
+ let total = 0;
1203
+ let fresh = 0;
1204
+ let cacheRead = 0;
1205
+ for (const p of phases) {
1206
+ done += p.done;
1207
+ total += p.total;
1208
+ fresh += p.fresh;
1209
+ cacheRead += p.cacheRead;
1210
+ }
1211
+ // Line 0 — name (accent + bold), truncated to width if needed.
1212
+ const nameText = truncateToWidth(name, width, ELLIPSIS, false);
1213
+ const line0 = theme.fg("accent", theme.bold(nameText));
1214
+
1215
+ // Line 1 — left status, right summary.
1216
+ const headerSegment = fmtTokenSegment({ fresh, cacheRead }, compactTokens);
1217
+ const rightRaw = `${done}/${total} ${pluralize("agent", total)}${headerSegment ? ` · ${headerSegment}` : ""}`;
1218
+ const rightW = visibleWidth(rightRaw);
1219
+ const gap = 2;
1220
+ let line1: string;
1221
+ if (rightW >= width) {
1222
+ // No room for left content: right-align (truncate from the right as last resort).
1223
+ line1 = theme.fg("dim", truncateToWidth(rightRaw, width, ELLIPSIS, false));
1224
+ } else {
1225
+ const availL = width - rightW - gap;
1226
+ const leftText = availL > 0 ? truncateToWidth(status, availL, ELLIPSIS, false) : "";
1227
+ const leftW = visibleWidth(leftText);
1228
+ const fill = " ".repeat(Math.max(gap, width - leftW - rightW));
1229
+ line1 = theme.fg("dim", leftText) + fill + theme.fg("dim", rightRaw);
1230
+ }
1231
+ return [line0, line1];
1232
+ }
1233
+
1234
+ function historyLabel(entry: NonNullable<WorkflowAgentSnapshot["history"]>[number]): string {
1235
+ if (entry.kind === "toolCall") return entry.toolName ? `assistant tool ${asText(entry.toolName)}` : "assistant tool";
1236
+ if (entry.role === "tool") return entry.toolName ? `tool ${asText(entry.toolName)}` : "tool";
1237
+ if (entry.kind === "error") return `${asText(entry.role)} error`;
1238
+ return asText(entry.role);
1239
+ }
1240
+
1241
+ function editCallPath(entry: NonNullable<WorkflowAgentSnapshot["history"]>[number]): string | undefined {
1242
+ if (entry.kind !== "toolCall" || entry.toolName !== "edit") return undefined;
1243
+ if (typeof entry.path === "string") return entry.path;
1244
+ // Backward compatibility for persisted histories from before edit paths were
1245
+ // stored separately from the JSON argument envelope.
1246
+ try {
1247
+ const args = JSON.parse(asText(entry.text)) as { path?: unknown };
1248
+ return typeof args.path === "string" ? args.path : undefined;
1249
+ } catch {
1250
+ return undefined;
1251
+ }
1252
+ }
1253
+
1254
+ function writeCallSource(
1255
+ entry: NonNullable<WorkflowAgentSnapshot["history"]>[number],
1256
+ ): { path: string; content: string } | undefined {
1257
+ if (entry.kind !== "toolCall" || entry.toolName !== "write") return undefined;
1258
+ if (typeof entry.path === "string") return { path: entry.path, content: asText(entry.text) };
1259
+ // Backward compatibility for older persisted histories that stored the whole
1260
+ // write argument envelope as JSON.
1261
+ try {
1262
+ const args = JSON.parse(asText(entry.text)) as { path?: unknown; content?: unknown };
1263
+ return typeof args.path === "string" && typeof args.content === "string"
1264
+ ? { path: args.path, content: args.content }
1265
+ : undefined;
1266
+ } catch {
1267
+ return undefined;
1268
+ }
1269
+ }
1270
+
1271
+ /** Infer source language for history that pi stores as raw tool text rather than
1272
+ * Markdown. Tool-call arguments are JSON; file writes and read results inherit
1273
+ * their source language from the requested path. */
1274
+ function historyEntryLanguage(
1275
+ history: NonNullable<WorkflowAgentSnapshot["history"]>,
1276
+ index: number,
1277
+ ): string | undefined {
1278
+ const entry = history[index];
1279
+ if (!entry) return undefined;
1280
+ if (entry.kind === "toolCall") {
1281
+ const write = writeCallSource(entry);
1282
+ return write ? (getLanguageFromPath(write.path) ?? "text") : "json";
1283
+ }
1284
+ if (entry.kind !== "toolResult" || entry.toolName !== "read") return undefined;
1285
+
1286
+ for (let i = index - 1; i >= 0; i--) {
1287
+ const call = history[i];
1288
+ if (call?.kind !== "toolCall" || call.toolName !== "read") continue;
1289
+ try {
1290
+ const args = JSON.parse(asText(call.text)) as { path?: unknown };
1291
+ return typeof args.path === "string" ? getLanguageFromPath(args.path) : undefined;
1292
+ } catch {
1293
+ return undefined;
1294
+ }
1295
+ }
1296
+ return undefined;
1297
+ }
1298
+
1299
+ function renderHistoryEntryLines(
1300
+ history: NonNullable<WorkflowAgentSnapshot["history"]>,
1301
+ index: number,
1302
+ width: number,
1303
+ markdownTheme: MarkdownTheme | undefined,
1304
+ dim: (text: string) => string,
1305
+ renderCache?: NavigatorTextRenderCache,
1306
+ ): string[] {
1307
+ const entry = history[index];
1308
+ // Skip null/primitive elements from corrupt persisted histories (#110).
1309
+ if (!entry || typeof entry !== "object") return [];
1310
+ const write = writeCallSource(entry);
1311
+ const editPath = editCallPath(entry);
1312
+ const path = write?.path ?? editPath;
1313
+ const header = dim(`${historyLabel(entry)}:${path ? ` ${path}` : ""}`);
1314
+
1315
+ // The edit result carries the same display-oriented diff used by Pi's built-in
1316
+ // edit renderer. Render it with Pi's native colors, line numbers, and
1317
+ // intra-line highlighting instead of showing the raw replacement JSON.
1318
+ if (entry.kind === "toolResult" && entry.toolName === "edit" && typeof entry.diff === "string") {
1319
+ return [header, ...renderDiffLines(entry.diff, width, renderCache)];
1320
+ }
1321
+ if (editPath) return [header];
1322
+
1323
+ const language = historyEntryLanguage(history, index);
1324
+ const text = write?.content ?? asText(entry.text);
1325
+ return [
1326
+ header,
1327
+ ...(language
1328
+ ? renderCodeLines(text, language, width, markdownTheme, renderCache)
1329
+ : renderMarkdownLines(text, width, markdownTheme, renderCache)),
1330
+ ];
1331
+ }
1332
+
1333
+ function footerHint(state: NavigatorState, model: NavigatorModel, theme: ThemeLike): string {
1334
+ const parts: string[] = [];
1335
+ switch (state.kind) {
1336
+ case "detail":
1337
+ if (state.pagerOpen) {
1338
+ parts.push(
1339
+ "↑/↓ line",
1340
+ "PgUp/PgDn page",
1341
+ "g/G ends",
1342
+ `t tail:${state.tailing ? "on" : "off"}`,
1343
+ "enter summary",
1344
+ "esc summary",
1345
+ );
1346
+ } else {
1347
+ parts.push("enter open pager", "t tail", "esc back");
1348
+ }
1349
+ break;
1350
+ case "savedDetail":
1351
+ parts.push("↑/↓ line", "PgUp/PgDn page", "g/G ends", "esc back", "x delete");
1352
+ break;
1353
+ case "runs": {
1354
+ const itemKind = model.saved().length > 0 ? state.itemKindAt(model, state.cursor) : "run";
1355
+ parts.push("↑/↓ select", "enter open", "esc back");
1356
+ if (itemKind === "run") {
1357
+ parts.push("p pause", "x stop", "r restart", "s save");
1358
+ } else {
1359
+ parts.push("x delete");
1360
+ }
1361
+ parts.push("q quit");
1362
+ break;
1363
+ }
1364
+ default:
1365
+ parts.push("↑/↓ select", "enter open", "esc back", "q quit");
1366
+ }
1367
+ return theme.fg("dim", parts.join(" · "));
1368
+ }
1369
+
1370
+ function wrap(text: unknown, width: number): string[] {
1371
+ return wrapTextWithAnsi(asText(text), Math.max(1, width));
1372
+ }
1373
+
1374
+ /** Render prose as Markdown when the host theme is available. Fenced code blocks
1375
+ * are syntax highlighted by pi's Markdown renderer. */
1376
+ function renderMarkdownLines(
1377
+ text: unknown,
1378
+ width: number,
1379
+ markdownTheme?: MarkdownTheme,
1380
+ renderCache?: NavigatorTextRenderCache,
1381
+ ): string[] {
1382
+ const safeText = asText(text);
1383
+ if (!markdownTheme) return wrap(safeText, width);
1384
+ const renderWidth = Math.max(1, width);
1385
+ const key = `md:${renderWidth}:${safeText}`;
1386
+ const cached = renderCache?.get(key);
1387
+ if (cached) return cached;
1388
+ const lines = new Markdown(safeText, 0, 0, markdownTheme).render(renderWidth);
1389
+ return renderCache?.set(key, lines, key.length + lines.reduce((sum, line) => sum + line.length, 0)) ?? lines;
1390
+ }
1391
+
1392
+ /** Render Pi's display-oriented edit diff inside the navigator's bounded
1393
+ * viewport while preserving its ANSI colors and intra-line highlights. */
1394
+ function renderDiffLines(diff: string, width: number, renderCache?: NavigatorTextRenderCache): string[] {
1395
+ const renderWidth = Math.max(1, width);
1396
+ const key = `diff:${renderWidth}:${diff}`;
1397
+ const cached = renderCache?.get(key);
1398
+ if (cached) return cached;
1399
+ const lines = renderDiff(diff)
1400
+ .split("\n")
1401
+ .flatMap((line) => wrapTextWithAnsi(` ${line}`, renderWidth));
1402
+ return renderCache?.set(key, lines, key.length + lines.reduce((sum, line) => sum + line.length, 0)) ?? lines;
1403
+ }
1404
+
1405
+ /** Render a known-language source block without requiring Markdown fences (a
1406
+ * workflow script can itself contain backticks). */
1407
+ function renderCodeLines(
1408
+ text: unknown,
1409
+ language: string,
1410
+ width: number,
1411
+ markdownTheme?: MarkdownTheme,
1412
+ renderCache?: NavigatorTextRenderCache,
1413
+ ): string[] {
1414
+ const safeText = asText(text);
1415
+ const renderWidth = Math.max(1, width);
1416
+ const key = `code:${language}:${renderWidth}:${safeText}`;
1417
+ const cached = renderCache?.get(key);
1418
+ if (cached) return cached;
1419
+ const sourceLines = markdownTheme?.highlightCode?.(safeText, language) ?? safeText.split("\n");
1420
+ const lines = sourceLines.flatMap((line) => wrapTextWithAnsi(` ${line}`, renderWidth));
1421
+ return renderCache?.set(key, lines, key.length + lines.reduce((sum, line) => sum + line.length, 0)) ?? lines;
1422
+ }
1423
+
1424
+ function renderResultLines(
1425
+ result: unknown,
1426
+ preview: string | undefined,
1427
+ width: number,
1428
+ markdownTheme?: MarkdownTheme,
1429
+ renderCache?: NavigatorTextRenderCache,
1430
+ ): string[] {
1431
+ if (result !== undefined && typeof result !== "string") {
1432
+ let json: string;
1433
+ if (renderCache && typeof result === "object" && result !== null) {
1434
+ json = renderCache.stringify(result);
1435
+ } else {
1436
+ try {
1437
+ json = JSON.stringify(result, null, 2) ?? String(result);
1438
+ } catch {
1439
+ json = String(result);
1440
+ }
1441
+ }
1442
+ return renderCodeLines(json, "json", width, markdownTheme, renderCache);
1443
+ }
1444
+ return renderMarkdownLines(
1445
+ typeof result === "string" ? result : (preview ?? "(none)"),
1446
+ width,
1447
+ markdownTheme,
1448
+ renderCache,
1449
+ );
1450
+ }
1451
+
1452
+ /** What a key press should do. Pure mapping from a parsed key id to an action. */
1453
+ export type NavAction =
1454
+ | { type: "move"; delta: number }
1455
+ | { type: "page"; direction: -1 | 1 }
1456
+ | { type: "jump"; edge: "start" | "end" }
1457
+ | { type: "toggleTail" }
1458
+ | { type: "togglePager" }
1459
+ | { type: "openPager" }
1460
+ | { type: "drill" }
1461
+ | { type: "back" }
1462
+ | { type: "close" }
1463
+ | { type: "pause" }
1464
+ | { type: "stop" }
1465
+ | { type: "restart" }
1466
+ | { type: "save" }
1467
+ | { type: "deleteSaved" }
1468
+ | { type: "none" };
1469
+
1470
+ export function keyToAction(keyId: string | undefined, kind: ViewKind, itemKind?: "run" | "saved"): NavAction {
1471
+ switch (keyId) {
1472
+ case "up":
1473
+ return { type: "move", delta: -1 };
1474
+ case "down":
1475
+ return { type: "move", delta: 1 };
1476
+ case "k":
1477
+ return { type: "move", delta: -1 };
1478
+ case "j":
1479
+ return { type: "move", delta: 1 };
1480
+ case "pageUp":
1481
+ case "ctrl+u":
1482
+ case "ctrl+b":
1483
+ return { type: "page", direction: -1 };
1484
+ case "pageDown":
1485
+ case "ctrl+d":
1486
+ case "ctrl+f":
1487
+ return { type: "page", direction: 1 };
1488
+ case "space":
1489
+ return kind === "detail" || kind === "savedDetail" ? { type: "page", direction: 1 } : { type: "none" };
1490
+ case "home":
1491
+ case "g":
1492
+ return { type: "jump", edge: "start" };
1493
+ case "end":
1494
+ case "G":
1495
+ case "shift+g":
1496
+ return { type: "jump", edge: "end" };
1497
+ case "t":
1498
+ return kind === "detail" ? { type: "toggleTail" } : { type: "none" };
1499
+ case "enter":
1500
+ case "return":
1501
+ if (kind === "detail") return { type: "togglePager" };
1502
+ if (kind === "savedDetail") return { type: "none" };
1503
+ return { type: "drill" };
1504
+ case "right":
1505
+ if (kind === "detail") return { type: "openPager" };
1506
+ if (kind === "savedDetail") return { type: "none" };
1507
+ return { type: "drill" };
1508
+ case "escape":
1509
+ case "esc":
1510
+ case "left":
1511
+ return { type: "back" };
1512
+ case "q":
1513
+ return { type: "close" };
1514
+ case "p":
1515
+ return { type: "pause" };
1516
+ case "x":
1517
+ if (kind === "savedDetail" || itemKind === "saved") return { type: "deleteSaved" };
1518
+ return { type: "stop" };
1519
+ case "r":
1520
+ return { type: "restart" };
1521
+ case "s":
1522
+ if (itemKind === "saved") return { type: "none" };
1523
+ return { type: "save" };
1524
+ default:
1525
+ return { type: "none" };
1526
+ }
1527
+ }
1528
+
1529
+ function currentCount(state: NavigatorState, model: NavigatorModel): number {
1530
+ if (state.kind === "runs") return model.runs().length + model.saved().length;
1531
+ if (state.kind === "phases" && state.runId) return model.phases(state.runId).length;
1532
+ if (state.kind === "agents" && state.runId && state.phase) return model.agents(state.runId, state.phase).length;
1533
+ return 0;
1534
+ }
1535
+
1536
+ import type { OverlayAnchor } from "@earendil-works/pi-tui";
1537
+
1538
+ export interface NavigatorOptions {
1539
+ storage?: WorkflowStorage;
1540
+ cwd?: string;
1541
+ /** Overlay anchor position: "center" (default) or "right-center" for sidebar. */
1542
+ anchor?: OverlayAnchor;
1543
+ }
1544
+
1545
+ /**
1546
+ * Open the interactive `/workflows` navigator as a focused overlay. Resolves when
1547
+ * the user closes it (esc at the top level, or `q`).
1548
+ */
1549
+ export function openWorkflowNavigator(
1550
+ pi: ExtensionAPI,
1551
+ manager: WorkflowManager,
1552
+ ui: ExtensionUIContext,
1553
+ opts: NavigatorOptions = {},
1554
+ ): Promise<void> {
1555
+ const model = new NavigatorModel(manager, opts.storage);
1556
+ const state = new NavigatorState();
1557
+
1558
+ return ui.custom<void>(
1559
+ (tui: TUI, theme: Theme, _keybindings, done: (r: undefined) => void) => {
1560
+ const rerender = () => tui.requestRender();
1561
+ const markdownTheme = getMarkdownTheme();
1562
+ const renderCache = new NavigatorTextRenderCache();
1563
+ const events = ["agentStart", "agentEnd", "phase", "log", "complete", "error", "stopped", "paused", "resumed"];
1564
+ const onEvent = () => rerender();
1565
+ for (const ev of events) manager.on(ev, onEvent);
1566
+
1567
+ // Histories can update several times per second for every parallel agent.
1568
+ // Only agent detail consumes those updates, so ignore unrelated agents and
1569
+ // coalesce matching updates into a modest trailing redraw cadence.
1570
+ let historyRenderTimer: ReturnType<typeof setTimeout> | undefined;
1571
+ let historyRenderTarget: { runId: string; agentId: number } | undefined;
1572
+ const onAgentHistory = (event: { runId: string; agentId?: number }) => {
1573
+ if (
1574
+ state.kind !== "detail" ||
1575
+ event.runId !== state.runId ||
1576
+ event.agentId === undefined ||
1577
+ event.agentId !== state.agentId
1578
+ ) {
1579
+ return;
1580
+ }
1581
+ // Keep the newest matching target even while a redraw is already
1582
+ // scheduled. If navigation switches agents inside the coalescing window,
1583
+ // the pending redraw should follow the new agent rather than the event
1584
+ // that originally created the shared timer.
1585
+ historyRenderTarget = { runId: event.runId, agentId: event.agentId };
1586
+ if (historyRenderTimer) return;
1587
+ historyRenderTimer = setTimeout(() => {
1588
+ historyRenderTimer = undefined;
1589
+ const target = historyRenderTarget;
1590
+ historyRenderTarget = undefined;
1591
+ if (target && state.kind === "detail" && target.runId === state.runId && target.agentId === state.agentId) {
1592
+ rerender();
1593
+ }
1594
+ }, 125);
1595
+ (historyRenderTimer as { unref?: () => void }).unref?.();
1596
+ };
1597
+ manager.on("agentHistory", onAgentHistory);
1598
+
1599
+ const cleanup = () => {
1600
+ for (const ev of events) manager.off(ev, onEvent);
1601
+ manager.off("agentHistory", onAgentHistory);
1602
+ if (historyRenderTimer) clearTimeout(historyRenderTimer);
1603
+ historyRenderTimer = undefined;
1604
+ historyRenderTarget = undefined;
1605
+ };
1606
+
1607
+ const act = (data: string) => {
1608
+ const itemKind = state.kind === "runs" ? state.itemKindAt(model, state.cursor) : undefined;
1609
+ const action = keyToAction(parseKey(data), state.kind, itemKind);
1610
+ // Keep the whole dispatch behind one error boundary so corrupt on-disk
1611
+ // data or persistence failures cannot crash the overlay input handler.
1612
+ try {
1613
+ switch (action.type) {
1614
+ case "move":
1615
+ state.move(action.delta, currentCount(state, model));
1616
+ break;
1617
+ case "page":
1618
+ state.movePage(action.direction, currentCount(state, model));
1619
+ break;
1620
+ case "jump":
1621
+ state.jump(action.edge, currentCount(state, model));
1622
+ break;
1623
+ case "toggleTail":
1624
+ state.toggleTail();
1625
+ break;
1626
+ case "togglePager":
1627
+ state.togglePager();
1628
+ break;
1629
+ case "openPager":
1630
+ state.openPager();
1631
+ break;
1632
+ case "drill":
1633
+ state.drill(model);
1634
+ break;
1635
+ case "back":
1636
+ if (!state.back()) {
1637
+ cleanup();
1638
+ done(undefined);
1639
+ }
1640
+ break;
1641
+ case "close":
1642
+ cleanup();
1643
+ done(undefined);
1644
+ return;
1645
+ case "deleteSaved": {
1646
+ if (state.kind === "runs") {
1647
+ const saved = model.saved();
1648
+ const runCount = model.runs().length;
1649
+ const item = saved[state.cursor - runCount];
1650
+ if (item) {
1651
+ model.deleteSaved(item.name);
1652
+ ui.notify(`Deleted /${item.name}`, "info");
1653
+ }
1654
+ } else if (state.kind === "savedDetail" && state.savedName) {
1655
+ model.deleteSaved(state.savedName);
1656
+ ui.notify(`Deleted /${state.savedName}`, "info");
1657
+ state.back();
1658
+ }
1659
+ break;
1660
+ }
1661
+ case "pause": {
1662
+ const id = state.activeRunId(model);
1663
+ if (id) ui.notify(manager.pause(id) ? `Paused ${id}` : `Cannot pause ${id}`, "info");
1664
+ break;
1665
+ }
1666
+ case "stop": {
1667
+ const id = state.activeRunId(model);
1668
+ if (id) ui.notify(manager.stop(id) ? `Stopped ${id}` : `Cannot stop ${id}`, "info");
1669
+ break;
1670
+ }
1671
+ case "restart": {
1672
+ const id = state.activeRunId(model);
1673
+ const run = id ? manager.listRuns().find((r) => r.runId === id) : undefined;
1674
+ if (!run?.script) {
1675
+ ui.notify(id ? `Cannot restart ${id} (no script saved)` : "No run selected to restart", "warning");
1676
+ break;
1677
+ }
1678
+ try {
1679
+ const { runId: newId } = manager.startInBackground(run.script, run.args);
1680
+ ui.notify(`Restarted ${run.workflowName || "workflow"} as ${newId}`, "info");
1681
+ } catch (error) {
1682
+ ui.notify(
1683
+ `Failed to restart ${run.workflowName || "workflow"}: ${error instanceof Error ? error.message : error}`,
1684
+ "error",
1685
+ );
1686
+ }
1687
+ break;
1688
+ }
1689
+ case "save": {
1690
+ const id = state.activeRunId(model);
1691
+ const run = id ? manager.listRuns().find((r) => r.runId === id) : undefined;
1692
+ if (!run?.script) {
1693
+ ui.notify("No saved run script to save", "warning");
1694
+ } else if (!opts.storage) {
1695
+ ui.notify("Saving is not available (no storage)", "error");
1696
+ } else {
1697
+ const storage = opts.storage;
1698
+ const name = run.workflowName || "workflow";
1699
+ let saved: ReturnType<WorkflowStorage["save"]>;
1700
+ try {
1701
+ saved = storage.save({
1702
+ name,
1703
+ description: run.workflowName,
1704
+ script: run.script,
1705
+ location: "project",
1706
+ });
1707
+ } catch (error) {
1708
+ ui.notify(error instanceof Error ? error.message : String(error), "error");
1709
+ break;
1710
+ }
1711
+ registerSavedWorkflow(pi, opts.cwd ?? process.cwd(), saved, undefined, () =>
1712
+ storage.list().some((w) => w.name === saved.name),
1713
+ );
1714
+ ui.notify(`Saved /${name}`, "info");
1715
+ }
1716
+ break;
1717
+ }
1718
+ default:
1719
+ return;
1720
+ }
1721
+ } catch (error) {
1722
+ ui.notify(
1723
+ `Workflow action "${action.type}" failed: ${error instanceof Error ? error.message : error}`,
1724
+ "error",
1725
+ );
1726
+ }
1727
+ rerender();
1728
+ };
1729
+
1730
+ // Wrap the rendered content inside a visual box border for better
1731
+ // screen-boundary contrast. Follows the same pattern as pi-ask-user:
1732
+ // top border ──╭───╮
1733
+ // side borders │ … │
1734
+ // bottom border╰───╯
1735
+ let _focused = false;
1736
+ const component: Component & Focusable & { dispose?(): void } = {
1737
+ get focused(): boolean {
1738
+ return _focused;
1739
+ },
1740
+ set focused(v: boolean) {
1741
+ _focused = v;
1742
+ },
1743
+ render: (width: number) => {
1744
+ // Brighter border when focused, muted when not
1745
+ const borderColor = (s: string) => (_focused ? theme.fg("accent", s) : theme.fg("borderMuted", s));
1746
+ const titleColor = (s: string) => (_focused ? theme.fg("dim", theme.bold(s)) : theme.fg("muted", s));
1747
+ const bgColor = (s: string) => theme.bg("customMessageBg", s);
1748
+ const innerWidth = Math.max(10, width - BOX_BORDER_OVERHEAD);
1749
+ // Match the navigator's own viewport to the overlay's 92% maxHeight;
1750
+ // otherwise the host truncates the footer and bottom border before the
1751
+ // pager gets a chance to scroll them into view.
1752
+ const terminalRows = tui.terminal?.rows ?? 24;
1753
+ const overlayRows = Math.max(8, Math.floor(terminalRows * 0.92));
1754
+ const contentRows = Math.max(6, overlayRows - 2); // top + bottom box borders
1755
+ const raw = model.withRenderFrame(() =>
1756
+ renderNavigatorFrame(state, model, innerWidth, theme, contentRows, markdownTheme, renderCache),
1757
+ );
1758
+ const title = titleColor(" workflows ");
1759
+ const topBorder =
1760
+ borderColor("╭─") + title + borderColor("─".repeat(Math.max(0, innerWidth - 10))) + borderColor("╮");
1761
+ const botBorder = borderColor(`╰${"─".repeat(Math.max(0, innerWidth + 2))}╯`);
1762
+ const wrapAndBg = (line: string) => {
1763
+ const padded = truncateToWidth(line, innerWidth, "", true);
1764
+ const fullLine = borderColor(BOX_BORDER_LEFT) + padded + borderColor(BOX_BORDER_RIGHT);
1765
+ // Fill trailing whitespace for consistent background across the width
1766
+ const trailingPad = width - visibleWidth(fullLine);
1767
+ return bgColor(fullLine + (trailingPad > 0 ? " ".repeat(trailingPad) : ""));
1768
+ };
1769
+ return [bgColor(topBorder), ...raw.map(wrapAndBg), bgColor(botBorder)];
1770
+ },
1771
+ handleInput: (data: string) => act(data),
1772
+ invalidate: () => {},
1773
+ dispose: () => cleanup(),
1774
+ };
1775
+ return component;
1776
+ },
1777
+ // A roomy overlay with visual margin so borders stand out from the terminal edge.
1778
+ // Supports sidebar mode via opts.anchor="right-center".
1779
+ {
1780
+ overlay: true,
1781
+ overlayOptions: {
1782
+ width: opts.anchor === "right-center" ? "60%" : "94%",
1783
+ maxHeight: "92%",
1784
+ anchor: opts.anchor ?? "center",
1785
+ margin: 1,
1786
+ },
1787
+ },
1788
+ );
1789
+ }