@herbertgao/pi-subagents 0.17.1 → 0.18.1

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 (50) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/README.md +427 -120
  3. package/docs/rpc.md +184 -0
  4. package/docs/workflows.md +466 -0
  5. package/examples/agent-tool-description.md +6 -6
  6. package/examples/workflows/compose.js +52 -0
  7. package/examples/workflows/fan-out-audit.js +56 -0
  8. package/examples/workflows/gated-fix.js +60 -0
  9. package/examples/workflows/lib/count-child.js +30 -0
  10. package/examples/workflows/review-panel.js +68 -0
  11. package/examples/workflows/structured-findings.js +81 -0
  12. package/package.json +12 -10
  13. package/src/agent-file-toggle.ts +52 -12
  14. package/src/agent-manager.ts +837 -146
  15. package/src/agent-runner.ts +213 -39
  16. package/src/cross-extension-rpc.ts +73 -14
  17. package/src/custom-agents.ts +101 -47
  18. package/src/index.ts +2249 -914
  19. package/src/invocation-config.ts +13 -0
  20. package/src/mention-clone.ts +215 -0
  21. package/src/mention.ts +147 -0
  22. package/src/model-resolver.ts +9 -1
  23. package/src/nested-tools.ts +40 -26
  24. package/src/output-file.ts +18 -8
  25. package/src/prompts.ts +46 -9
  26. package/src/schedule.ts +21 -16
  27. package/src/settings.ts +137 -7
  28. package/src/structured-output.ts +136 -0
  29. package/src/types.ts +126 -8
  30. package/src/ui/agent-mention.ts +274 -0
  31. package/src/ui/agent-widget.ts +20 -5
  32. package/src/ui/conversation-viewer.ts +10 -4
  33. package/src/ui/fleet-list.ts +167 -22
  34. package/src/ui/workflow-card.ts +555 -0
  35. package/src/ui/workflow-dialog.ts +1304 -0
  36. package/src/ui/workflow-menu.ts +226 -0
  37. package/src/workflow/collisions.ts +122 -0
  38. package/src/workflow/entry.ts +47 -0
  39. package/src/workflow/host.ts +463 -0
  40. package/src/workflow/journal.ts +164 -0
  41. package/src/workflow/json-schema.ts +142 -0
  42. package/src/workflow/meta.ts +401 -0
  43. package/src/workflow/progress.ts +622 -0
  44. package/src/workflow/runtime.ts +1399 -0
  45. package/src/workflow/saved.ts +230 -0
  46. package/src/workflow/task.ts +333 -0
  47. package/src/workflow/tool-description.ts +200 -0
  48. package/src/workflow/worker-source.ts +781 -0
  49. package/src/worktree.ts +97 -95
  50. package/src/xml.ts +13 -0
@@ -0,0 +1,1304 @@
1
+ /**
2
+ * workflow-dialog.ts — the `/agents → Workflows` two-pane inspector.
3
+ *
4
+ * ```
5
+ * review-changes
6
+ * Review changed files across dimensions 3/7 agents · 1m12s
7
+ *
8
+ * ╭ Phases ──────────┬ Verify · 1 agent ──────────────────────────────╮
9
+ * │ ❯ ✔ Review 3/3 │ ❯ ◌ verify:auth.ts · attempt 2 · waiting 8s │
10
+ * │ 2 Verify 1/2 │ │
11
+ * │ 3 Report │ │
12
+ * ╰──────────────────┴────────────────────────────────────────────────╯
13
+ * ↑↓ select · ⏎ open · f filter · x stop · esc close · c convo
14
+ * ```
15
+ *
16
+ * Opening an agent swaps the panes: that phase's agents move left and the
17
+ * right becomes the agent's Prompt / Activity / Outcome detail.
18
+ *
19
+ * A phase with no agents yet shows its number and nothing else. The single-pane
20
+ * layout this replaced spelled that out as "Not started yet"; the left pane is
21
+ * too narrow to hold the words, and a numbered row with no count says it.
22
+ *
23
+ * **The glyphs are not the card's glyphs.** `workflow-card.ts` keys off the raw
24
+ * entry `state`; this file keys off the *derived* `displayState(entry, active)`
25
+ * and splits cases the card cannot see — skipped, blocked, queued and
26
+ * interrupted all render as a plain ✘ or ⟳ inline but are distinct here. `◌`
27
+ * (U+25CC) appears only in this file, and a running row animates a spinner where
28
+ * the card draws a static `⟳`.
29
+ *
30
+ * **The phases pane is stranger still**: a phase that has not finished shows
31
+ * *its number*, not a glyph. That is deliberate, recovered behaviour.
32
+ *
33
+ * **The layout is pure.** `layoutWorkflowDialog` returns coloured segments and
34
+ * `handleWorkflowDialogKey` maps a keypress to the next state plus an optional
35
+ * action; neither touches a theme, a terminal, or the workflow runtime. The
36
+ * `WorkflowDialog` component is the thin shell that wires those to `ctx.ui`, and
37
+ * the runtime side arrives as an injected `WorkflowDialogActions`.
38
+ *
39
+ * All state derivation lives in `src/workflow/progress.ts`; this file only
40
+ * arranges what that module returns.
41
+ */
42
+
43
+ import {
44
+ type Component,
45
+ matchesKey,
46
+ stripTerminalSequences,
47
+ type TUI,
48
+ truncateToWidth,
49
+ visibleWidth,
50
+ wrapTextWithAnsi,
51
+ } from "@earendil-works/pi-tui"
52
+ import type { WorkflowMeta } from "../workflow/meta.js"
53
+ import {
54
+ buildPhaseGroups,
55
+ displayState,
56
+ formatDuration,
57
+ header,
58
+ isLive,
59
+ type PhaseGroup,
60
+ type WorkflowAgentEntry,
61
+ type WorkflowDisplayState,
62
+ type WorkflowEntry,
63
+ } from "../workflow/progress.js"
64
+ import { SPINNER, type Theme } from "./agent-widget.js"
65
+ import {
66
+ ASCII_GLYPHS,
67
+ clampLine,
68
+ formatCompactTokens,
69
+ formatModel,
70
+ formatThinking,
71
+ REPLAYED_ANNOTATION,
72
+ styleWorkflowCardLines,
73
+ UNICODE_GLYPHS,
74
+ type WorkflowCardColor,
75
+ type WorkflowCardLine,
76
+ type WorkflowCardSegment,
77
+ type WorkflowCardTask,
78
+ } from "./workflow-card.js"
79
+
80
+ /** Fallback width when the caller does not know the terminal's. */
81
+ const DEFAULT_WIDTH = 80
82
+
83
+ /** Inner width of the left pane at any comfortable terminal size. */
84
+ const LEFT_PANE_WIDTH = 18
85
+
86
+ /**
87
+ * Most rows the frame will ever draw between its top and bottom edges.
88
+ *
89
+ * A cap, not a height: the box sizes itself to what it holds, and this is where
90
+ * it stops growing so a 200-agent fan-out scrolls inside the pane instead of
91
+ * pasting 200 rows into the conversation.
92
+ */
93
+ export const DEFAULT_PANE_BODY_ROWS = 22
94
+ /**
95
+ * Fewest rows the frame will draw.
96
+ *
97
+ * Below this a pane stops reading as a pane, and the box would resize on
98
+ * nearly every keypress — most phases hold a handful of agents, so a floor here
99
+ * absorbs the ordinary movement and leaves the height alone.
100
+ */
101
+ export const MIN_PANE_BODY_ROWS = 6
102
+ /** Prompt lines shown before `expand` is offered. */
103
+ export const PROMPT_COLLAPSED_LINES = 4
104
+ /** Spinner cadence. Unlike the card's 1s header tick, this row really animates. */
105
+ export const WORKFLOW_DIALOG_SPINNER_MS = 80
106
+
107
+ /* ------------------------------------------------------------------------- *
108
+ * Glyphs
109
+ * ------------------------------------------------------------------------- */
110
+
111
+ export interface WorkflowDialogGlyphs {
112
+ tick: string
113
+ cross: string
114
+ /** `◌` — queued or interrupted. The card has no row that draws this. */
115
+ queued: string
116
+ /** `figures.pointer` — the selected row in either pane. */
117
+ pointer: string
118
+ /** Marks whichever pane currently owns j/k. */
119
+ focus: string
120
+ /** Running rows cycle these. */
121
+ spinner: readonly string[]
122
+ /** The pane frame: corners, edges and the tee where the two panes meet. */
123
+ box: {
124
+ topLeft: string
125
+ topRight: string
126
+ bottomLeft: string
127
+ bottomRight: string
128
+ horizontal: string
129
+ vertical: string
130
+ topTee: string
131
+ bottomTee: string
132
+ }
133
+ /** Trailing marker on a title the pane was too narrow to hold. */
134
+ ellipsis: string
135
+ /** How the footer names the arrow keys and Enter. */
136
+ upDown: string
137
+ enter: string
138
+ }
139
+
140
+ export const UNICODE_DIALOG_GLYPHS: WorkflowDialogGlyphs = {
141
+ tick: UNICODE_GLYPHS.tick,
142
+ cross: UNICODE_GLYPHS.cross,
143
+ queued: "◌",
144
+ pointer: "❯",
145
+ focus: UNICODE_GLYPHS.pointer,
146
+ spinner: SPINNER,
147
+ box: {
148
+ topLeft: "╭",
149
+ topRight: "╮",
150
+ bottomLeft: "╰",
151
+ bottomRight: "╯",
152
+ horizontal: "─",
153
+ vertical: "│",
154
+ topTee: "┬",
155
+ bottomTee: "┴",
156
+ },
157
+ ellipsis: "…",
158
+ upDown: "↑↓",
159
+ enter: "⏎",
160
+ }
161
+
162
+ /** The ASCII tier, one column per glyph so the panes stay aligned either way. */
163
+ export const ASCII_DIALOG_GLYPHS: WorkflowDialogGlyphs = {
164
+ tick: ASCII_GLYPHS.tick,
165
+ cross: ASCII_GLYPHS.cross,
166
+ queued: "o",
167
+ pointer: ">",
168
+ focus: ASCII_GLYPHS.pointer,
169
+ spinner: ["-", "\\", "|", "/"],
170
+ box: {
171
+ topLeft: "+",
172
+ topRight: "+",
173
+ bottomLeft: "+",
174
+ bottomRight: "+",
175
+ horizontal: "-",
176
+ vertical: "|",
177
+ topTee: "+",
178
+ bottomTee: "+",
179
+ },
180
+ ellipsis: "~",
181
+ upDown: "up/down",
182
+ enter: "enter",
183
+ }
184
+
185
+ /**
186
+ * The recovered dialog mapping — keyed on the *display* state.
187
+ *
188
+ * Claude Code's `permission` colour has no pi equivalent; a blocked agent is
189
+ * waiting on the user, so it maps to `warning` (selection maps to `accent`).
190
+ */
191
+ export function dialogRowGlyph(
192
+ state: WorkflowDisplayState,
193
+ glyphs: WorkflowDialogGlyphs,
194
+ spinnerFrame = 0,
195
+ ): WorkflowCardSegment {
196
+ switch (state) {
197
+ case "done":
198
+ return { text: glyphs.tick, color: "success" }
199
+ case "failed":
200
+ return { text: glyphs.cross, color: "error" }
201
+ case "skipped":
202
+ return { text: glyphs.cross, color: "dim" }
203
+ case "blocked":
204
+ return { text: glyphs.cross, color: "warning" }
205
+ case "queued":
206
+ case "interrupted":
207
+ return { text: glyphs.queued, color: "dim" }
208
+ case "running":
209
+ return {
210
+ text: glyphs.spinner[spinnerFrame % glyphs.spinner.length],
211
+ color: "dim",
212
+ }
213
+ }
214
+ }
215
+
216
+ /* ------------------------------------------------------------------------- *
217
+ * Verbatim copy
218
+ * ------------------------------------------------------------------------- */
219
+
220
+ /**
221
+ * Claude Code's own strings. Kept together and named so a reader can see at a
222
+ * glance which surface each belongs to, and so none drifts under an edit.
223
+ */
224
+ export const WORKFLOW_DIALOG_COPY = {
225
+ waitingForSlot: "Waiting for an agent slot.",
226
+ availableOnceStarted: "Available once the agent starts.",
227
+ notAvailableYet: "Not available yet (agent still running).",
228
+ noTranscript: "Transcript not available.",
229
+ stoppedEarly: "The workflow stopped before this agent finished.",
230
+ skippedByUser: "Skipped by user.",
231
+ noToolCallsYet: "No tool calls yet.",
232
+ noToolCalls: "No tool calls.",
233
+ noAgents: "No agents",
234
+ } as const
235
+
236
+ /* ------------------------------------------------------------------------- *
237
+ * State
238
+ * ------------------------------------------------------------------------- */
239
+
240
+ /**
241
+ * Which of the two drill-down levels is showing.
242
+ *
243
+ * `phases` is the overview — phases on the left, the selected phase's agents on
244
+ * the right. `agent` is the subview reached by opening one: the same phase's
245
+ * agents move to the left pane and the right pane becomes that agent's detail.
246
+ * The panes never change count, only what they hold, which is what makes the
247
+ * frame stay put as you drill in and back out.
248
+ */
249
+ export type WorkflowDialogLevel = "phases" | "agent"
250
+
251
+ /** `all`, or exactly one display state. */
252
+ export type WorkflowDialogFilter = "all" | WorkflowDisplayState
253
+
254
+ /** The order `f` cycles through. */
255
+ export const WORKFLOW_DIALOG_FILTERS: readonly WorkflowDialogFilter[] = [
256
+ "all",
257
+ "running",
258
+ "queued",
259
+ "done",
260
+ "failed",
261
+ "blocked",
262
+ "skipped",
263
+ "interrupted",
264
+ ]
265
+
266
+ export interface WorkflowDialogState {
267
+ /** Raw selection; `clampedPhase` is what actually renders. */
268
+ selectedPhase: number
269
+ selectedAgent: number
270
+ level: WorkflowDialogLevel
271
+ filter: WorkflowDialogFilter
272
+ promptExpanded: boolean
273
+ }
274
+
275
+ export function initialWorkflowDialogState(
276
+ initialPhaseIndex = 0,
277
+ ): WorkflowDialogState {
278
+ return {
279
+ selectedPhase: initialPhaseIndex,
280
+ selectedAgent: 0,
281
+ level: "phases",
282
+ filter: "all",
283
+ promptExpanded: false,
284
+ }
285
+ }
286
+
287
+ /** Everything the dialog reads about a run, so it can be driven from a stub. */
288
+ export interface WorkflowDialogSource {
289
+ progress: readonly WorkflowEntry[]
290
+ task: WorkflowCardTask
291
+ meta?: WorkflowMeta
292
+ /** Agents the runtime has scheduled, which can exceed those that reported. */
293
+ agentCount?: number
294
+ }
295
+
296
+ export interface WorkflowDialogInput extends WorkflowDialogSource {
297
+ state: WorkflowDialogState
298
+ /**
299
+ * Which actions the caller actually wired. Absent keys default to available,
300
+ * so layout tests and read-only callers keep the full footer; a caller that
301
+ * wires only some actions passes the map so the hints stay truthful.
302
+ */
303
+ available?: Partial<Record<keyof WorkflowDialogActions, boolean>>
304
+ now?: number
305
+ /** The *terminal* width; the content width is derived from it. */
306
+ width?: number
307
+ ascii?: boolean
308
+ spinnerFrame?: number
309
+ /**
310
+ * Most rows the frame may use, overriding {@link DEFAULT_PANE_BODY_ROWS}.
311
+ *
312
+ * The frame still sizes to its content and still respects
313
+ * {@link MIN_PANE_BODY_ROWS}; this only moves the ceiling.
314
+ */
315
+ bodyRows?: number
316
+ }
317
+
318
+ /** The actions the dialog needs from the workflow runtime, injected. */
319
+ export interface WorkflowDialogActions {
320
+ onKill?(): void
321
+ onPause?(): void
322
+ onResume?(): void
323
+ /** `index` is the entry's stable `index`, not its row position. */
324
+ onSkipAgent?(index: number): void
325
+ onRetryAgent?(index: number): void
326
+ /**
327
+ * Open the selected agent's conversation.
328
+ *
329
+ * `recordId` is the manager's id for the child, which the entry carries once
330
+ * the host has reported one — the dialog knows nothing about sessions, so
331
+ * what happens to an id whose record has since been swept is the caller's to
332
+ * say. The only key here that shows something rather than changing the run.
333
+ */
334
+ onOpenAgent?(recordId: string): void
335
+ }
336
+
337
+ export type WorkflowDialogAction =
338
+ | { kind: "cancel" }
339
+ | { kind: "kill" }
340
+ | { kind: "pause" }
341
+ | { kind: "resume" }
342
+ | { kind: "skip"; index: number }
343
+ | { kind: "retry"; index: number }
344
+ | { kind: "open"; recordId: string }
345
+
346
+ export interface ResolvedWorkflowDialog {
347
+ groups: PhaseGroup[]
348
+ clampedPhase: number
349
+ clampedAgent: number
350
+ /** The selected phase's agents, after the state filter. */
351
+ visibleAgents: WorkflowAgentEntry[]
352
+ selectedEntry: WorkflowAgentEntry | undefined
353
+ /** False once the run stops — which is what turns live agents "interrupted". */
354
+ workflowActive: boolean
355
+ paused: boolean
356
+ }
357
+
358
+ /** Content width. The 6 columns are the dialog's border and padding. */
359
+ export function workflowDialogContentWidth(terminalWidth: number): number {
360
+ return Math.max(12, terminalWidth - 6)
361
+ }
362
+
363
+ const clampIndex = (index: number, length: number) =>
364
+ length === 0 ? 0 : Math.min(Math.max(0, Math.trunc(index)), length - 1)
365
+
366
+ /**
367
+ * Settle the selection against the data actually present.
368
+ *
369
+ * Selection is stored raw and clamped on read, so a phase finishing (and its
370
+ * agents dropping out of a filtered view) never leaves the cursor pointing past
371
+ * the end — the same trick `fleet-list.ts` plays, minus the mutation.
372
+ */
373
+ export function resolveWorkflowDialog(
374
+ input: WorkflowDialogInput,
375
+ ): ResolvedWorkflowDialog {
376
+ const groups = buildPhaseGroups(input.progress, input.meta?.phases)
377
+ const workflowActive =
378
+ input.task.status === "running" || input.task.status === "paused"
379
+ const clampedPhase = clampIndex(input.state.selectedPhase, groups.length)
380
+ const all = groups[clampedPhase]?.agents ?? []
381
+ const visibleAgents =
382
+ input.state.filter === "all"
383
+ ? [...all]
384
+ : all.filter(
385
+ (entry) => displayState(entry, workflowActive) === input.state.filter,
386
+ )
387
+ const clampedAgent = clampIndex(
388
+ input.state.selectedAgent,
389
+ visibleAgents.length,
390
+ )
391
+
392
+ return {
393
+ groups,
394
+ clampedPhase,
395
+ clampedAgent,
396
+ visibleAgents,
397
+ selectedEntry: visibleAgents[clampedAgent],
398
+ workflowActive,
399
+ paused: input.task.status === "paused",
400
+ }
401
+ }
402
+
403
+ /* ------------------------------------------------------------------------- *
404
+ * Row pieces
405
+ * ------------------------------------------------------------------------- */
406
+
407
+ /**
408
+ * The `·`-separated annotations between an agent's label and its stats.
409
+ *
410
+ * These say *why* a row looks the way it does — a retry and its cause, a cache
411
+ * hit replayed from the resume journal, how long a queued agent has been
412
+ * waiting. The stat tail (agentType, model, tokens, tool calls, duration) is the
413
+ * card's and is appended after.
414
+ */
415
+ export function subStatusAnnotations(
416
+ entry: WorkflowAgentEntry,
417
+ state: WorkflowDisplayState,
418
+ now: number,
419
+ ): string[] {
420
+ const parts: string[] = []
421
+ if (entry.isolation) parts.push(entry.isolation)
422
+ if (entry.cached) parts.push(REPLAYED_ANNOTATION)
423
+ if (entry.lastAttemptReason) {
424
+ parts.push(
425
+ entry.lastAttemptReason === "user-retry"
426
+ ? "user retry"
427
+ : entry.lastAttemptReason,
428
+ )
429
+ }
430
+ if (entry.attempt != null && entry.attempt > 1)
431
+ parts.push(`attempt ${entry.attempt}`)
432
+ if (state === "queued" && entry.queuedAt != null) {
433
+ parts.push(`waiting ${formatDuration(Math.max(0, now - entry.queuedAt))}`)
434
+ }
435
+ return parts
436
+ }
437
+
438
+ const lineWidth = (line: WorkflowCardLine) =>
439
+ line.reduce((sum, s) => sum + visibleWidth(s.text), 0)
440
+
441
+ /** Place `right` flush to `width`, cutting `left` first so the stats survive. */
442
+ function rightAlign(
443
+ left: WorkflowCardLine,
444
+ right: WorkflowCardLine,
445
+ width: number,
446
+ ): WorkflowCardLine {
447
+ const rightWidth = lineWidth(right)
448
+ const clampedLeft = clampLine(left, Math.max(0, width - rightWidth - 1))
449
+ const gap = Math.max(1, width - lineWidth(clampedLeft) - rightWidth)
450
+ return clampLine([...clampedLeft, { text: " ".repeat(gap) }, ...right], width)
451
+ }
452
+
453
+ /**
454
+ * Window a list around its selection, so a 200-agent fan-out still shows the
455
+ * row you are on. Mirrors `fleet-list.ts`'s arithmetic.
456
+ */
457
+ function windowRange(
458
+ selected: number,
459
+ total: number,
460
+ max: number,
461
+ ): { start: number; end: number } {
462
+ const visible = Math.min(max, total)
463
+ const start = selected < visible ? 0 : selected - visible + 1
464
+ return { start, end: start + visible }
465
+ }
466
+
467
+ /* ------------------------------------------------------------------------- *
468
+ * The pane frame
469
+ * ------------------------------------------------------------------------- */
470
+
471
+ /**
472
+ * Inner width of the left pane.
473
+ *
474
+ * Fixed rather than proportional at usable terminal sizes: the left pane holds
475
+ * short labels (a phase title, an agent label) and the right pane holds
476
+ * everything that actually needs room, so giving the left a share of a wide
477
+ * terminal would only pad it. It gives way on a narrow one.
478
+ */
479
+ export function leftPaneWidth(width: number): number {
480
+ // The two cells share everything except the three border columns, and the
481
+ // right one must keep at least a column — so the left is capped by what it
482
+ // can take without squeezing the right out and tearing the frame.
483
+ const available = Math.max(2, width - 3)
484
+ return Math.max(
485
+ 1,
486
+ Math.min(LEFT_PANE_WIDTH, Math.floor(available / 3), available - 1),
487
+ )
488
+ }
489
+
490
+ /** Fill a title out to the cell width with rule, so the corners stay put. */
491
+ function padTitle(
492
+ line: WorkflowCardLine,
493
+ width: number,
494
+ horizontal: string,
495
+ ): WorkflowCardLine {
496
+ const gap = Math.max(0, width - lineWidth(line))
497
+ return gap > 0
498
+ ? [...line, { text: horizontal.repeat(gap), color: "dim" }]
499
+ : line
500
+ }
501
+
502
+ /** One pane row, padded to exactly `width` so the frame stays vertical. */
503
+ function padCell(line: WorkflowCardLine, width: number): WorkflowCardLine {
504
+ const clamped = clampLine(line, width)
505
+ const gap = Math.max(0, width - lineWidth(clamped))
506
+ return gap > 0 ? [...clamped, { text: " ".repeat(gap) }] : clamped
507
+ }
508
+
509
+ /** A title as it sits in the frame's top edge: ` Title ` then rule to `width`. */
510
+ function frameTitle(
511
+ title: string,
512
+ width: number,
513
+ glyphs: WorkflowDialogGlyphs,
514
+ ): WorkflowCardSegment[] {
515
+ const room = Math.max(0, width - 2)
516
+ // Truncated with the marker rather than simply cut: `Discover · 1 ag…` has to
517
+ // read as "there was more", not as a title that happens to end oddly. Pi's
518
+ // own truncation does the width arithmetic, including wide characters; its
519
+ // ellipsis arrives wrapped in resets, which are stripped for the same reason
520
+ // `clampLine` strips them — the layout stays plain text until it is themed.
521
+ const shown = stripTerminalSequences(
522
+ truncateToWidth(title, room, glyphs.ellipsis),
523
+ )
524
+ const rule = Math.max(0, width - visibleWidth(shown) - 2)
525
+ // Clamped as well as computed: at a terminal narrow enough that `room` hits
526
+ // zero the marker alone is already wider than the cell, and a title that
527
+ // overflows tears the frame open on every row below it.
528
+ return clampLine(
529
+ [
530
+ { text: " ", color: "dim" },
531
+ { text: shown, color: "muted", bold: true },
532
+ { text: ` ${glyphs.box.horizontal.repeat(rule)}`, color: "dim" },
533
+ ],
534
+ width,
535
+ )
536
+ }
537
+
538
+ /**
539
+ * Draw the two panes into one framed block.
540
+ *
541
+ * Both columns are padded to `bodyRows` so the frame is the same height however
542
+ * much either side holds — a box that grew and shrank as you moved the
543
+ * selection would make the whole dialog jump.
544
+ */
545
+ function paneFrame(options: {
546
+ leftTitle: string
547
+ rightTitle: string
548
+ leftRows: WorkflowCardLine[]
549
+ rightRows: WorkflowCardLine[]
550
+ width: number
551
+ bodyRows: number
552
+ glyphs: WorkflowDialogGlyphs
553
+ }): WorkflowCardLine[] {
554
+ const { glyphs, width } = options
555
+ const box = glyphs.box
556
+ const left = leftPaneWidth(width)
557
+ const right = Math.max(1, width - left - 3)
558
+
559
+ const lines: WorkflowCardLine[] = []
560
+ lines.push([
561
+ { text: box.topLeft, color: "dim" },
562
+ ...padTitle(
563
+ frameTitle(options.leftTitle, left, glyphs),
564
+ left,
565
+ box.horizontal,
566
+ ),
567
+ { text: box.topTee, color: "dim" },
568
+ ...padTitle(
569
+ frameTitle(options.rightTitle, right, glyphs),
570
+ right,
571
+ box.horizontal,
572
+ ),
573
+ { text: box.topRight, color: "dim" },
574
+ ])
575
+
576
+ for (let row = 0; row < options.bodyRows; row++) {
577
+ lines.push([
578
+ { text: box.vertical, color: "dim" },
579
+ ...padCell(options.leftRows[row] ?? [], left),
580
+ { text: box.vertical, color: "dim" },
581
+ ...padCell(options.rightRows[row] ?? [], right),
582
+ { text: box.vertical, color: "dim" },
583
+ ])
584
+ }
585
+
586
+ lines.push([
587
+ { text: box.bottomLeft, color: "dim" },
588
+ { text: box.horizontal.repeat(left), color: "dim" },
589
+ { text: box.bottomTee, color: "dim" },
590
+ { text: box.horizontal.repeat(right), color: "dim" },
591
+ { text: box.bottomRight, color: "dim" },
592
+ ])
593
+ return lines
594
+ }
595
+
596
+ /* ------------------------------------------------------------------------- *
597
+ * Detail sections
598
+ * ------------------------------------------------------------------------- */
599
+
600
+ /** Split a preview into lines, treating an empty preview as absent. */
601
+ const previewLines = (preview: string | undefined) =>
602
+ preview ? preview.split("\n") : []
603
+
604
+ /** What the Activity body says. There is a count of tool calls, never a list. */
605
+ function activityBody(
606
+ entry: WorkflowAgentEntry,
607
+ state: WorkflowDisplayState,
608
+ ): string {
609
+ if (state === "queued") return WORKFLOW_DIALOG_COPY.availableOnceStarted
610
+ if ((entry.toolCalls ?? 0) > 0) return WORKFLOW_DIALOG_COPY.noTranscript
611
+ return isLive(entry)
612
+ ? WORKFLOW_DIALOG_COPY.noToolCallsYet
613
+ : WORKFLOW_DIALOG_COPY.noToolCalls
614
+ }
615
+
616
+ /** What the Outcome body says, which is a different sentence for every state. */
617
+ function outcomeBody(
618
+ entry: WorkflowAgentEntry,
619
+ state: WorkflowDisplayState,
620
+ ): string {
621
+ switch (state) {
622
+ case "skipped":
623
+ return WORKFLOW_DIALOG_COPY.skippedByUser
624
+ case "interrupted":
625
+ return WORKFLOW_DIALOG_COPY.stoppedEarly
626
+ case "queued":
627
+ return WORKFLOW_DIALOG_COPY.waitingForSlot
628
+ case "running":
629
+ return WORKFLOW_DIALOG_COPY.notAvailableYet
630
+ case "failed":
631
+ case "blocked":
632
+ return entry.error ?? WORKFLOW_DIALOG_COPY.noTranscript
633
+ case "done":
634
+ return entry.resultPreview ?? WORKFLOW_DIALOG_COPY.noTranscript
635
+ }
636
+ }
637
+
638
+ /* ------------------------------------------------------------------------- *
639
+ * Layout
640
+ * ------------------------------------------------------------------------- */
641
+
642
+ /**
643
+ * Which of the per-agent actions the selected row can currently take.
644
+ *
645
+ * The window for both is the one in which the agent's `agent()` call is still
646
+ * unanswered. Skip covers that whole window; retry needs a child to stop and
647
+ * start again, so it begins only once one exists. Once the call has settled its
648
+ * value is already the script's, and there is nothing either key could change.
649
+ */
650
+ export function agentActions(
651
+ entry: WorkflowAgentEntry | undefined,
652
+ workflowActive: boolean,
653
+ ): { skip: boolean; retry: boolean } {
654
+ if (entry === undefined || !workflowActive)
655
+ return { skip: false, retry: false }
656
+ const state = displayState(entry, workflowActive)
657
+ return {
658
+ skip: state === "queued" || state === "running",
659
+ retry: state === "running",
660
+ }
661
+ }
662
+
663
+ /** Status word the detail pane leads with, one per display state. */
664
+ function statusWord(state: WorkflowDisplayState): string {
665
+ switch (state) {
666
+ case "done":
667
+ return "Completed"
668
+ case "failed":
669
+ return "Failed"
670
+ case "skipped":
671
+ return "Skipped"
672
+ case "blocked":
673
+ return "Blocked"
674
+ case "queued":
675
+ return "Queued"
676
+ case "interrupted":
677
+ return "Stopped"
678
+ case "running":
679
+ return "Running"
680
+ }
681
+ }
682
+
683
+ /** `Prompt · 5 lines · ⏎ expand` — a detail heading and its dim suffixes. */
684
+ function detailHeading(
685
+ title: string,
686
+ suffixes: readonly string[],
687
+ width: number,
688
+ ): WorkflowCardLine {
689
+ const line: WorkflowCardLine = [
690
+ { text: " " },
691
+ { text: title, color: "muted", bold: true },
692
+ ]
693
+ for (const suffix of suffixes) {
694
+ line.push({ text: " · ", color: "dim" }, { text: suffix, color: "dim" })
695
+ }
696
+ return clampLine(line, width)
697
+ }
698
+
699
+ /** A detail body line, indented under its heading. */
700
+ const detailBody = (text: string, width: number): WorkflowCardLine =>
701
+ clampLine([{ text: ` ${text}`, color: "dim" }], width)
702
+
703
+ /** The row for one agent, as it appears in whichever pane is listing agents. */
704
+ function agentRow(options: {
705
+ entry: WorkflowAgentEntry
706
+ selected: boolean
707
+ compact: boolean
708
+ width: number
709
+ glyphs: WorkflowDialogGlyphs
710
+ workflowActive: boolean
711
+ spinnerFrame: number
712
+ now: number
713
+ }): WorkflowCardLine {
714
+ const { entry, selected, glyphs, width } = options
715
+ const display = displayState(entry, options.workflowActive)
716
+ const head: WorkflowCardLine = [
717
+ { text: " " },
718
+ { text: selected ? glyphs.pointer : " ", color: "accent" },
719
+ { text: " " },
720
+ dialogRowGlyph(display, glyphs, options.spinnerFrame),
721
+ { text: " " },
722
+ { text: entry.label, color: selected ? "accent" : undefined },
723
+ ]
724
+ // The narrow pane holds the label and nothing else; there is no room for a
725
+ // stat tail, and clamping one would just spend columns on a truncated word.
726
+ if (options.compact) return clampLine(head, width)
727
+
728
+ const model = formatModel(entry)
729
+ if (model) head.push({ text: ` ${model}`, color: "dim" })
730
+ for (const part of [
731
+ ...subStatusAnnotations(entry, display, options.now),
732
+ ...rowStatSegments(entry),
733
+ ]) {
734
+ head.push({ text: " · ", color: "dim" }, { text: part, color: "dim" })
735
+ }
736
+ // The duration sits flush right, so a column of rows reads as a column of
737
+ // durations rather than as ragged text.
738
+ const duration = entry.durationMs
739
+ ? [{ text: `${formatDuration(entry.durationMs)} `, color: "dim" as const }]
740
+ : []
741
+ return duration.length > 0
742
+ ? rightAlign(head, duration, width)
743
+ : clampLine(head, width)
744
+ }
745
+
746
+ /** The agent row's dot-separated tail. The model is not in it — it leads. */
747
+ function rowStatSegments(entry: WorkflowAgentEntry): string[] {
748
+ return entry.tokens ? [`${formatCompactTokens(entry.tokens)} tok`] : []
749
+ }
750
+
751
+ /**
752
+ * Build the dialog.
753
+ *
754
+ * Two panes side by side inside one frame, and two levels of depth: phases with
755
+ * the selected phase's agents beside them, then — on opening one — those agents
756
+ * with the selected agent's detail beside them. The frame is a fixed height so
757
+ * the dialog does not jump as the selection moves through runs of very
758
+ * different sizes.
759
+ */
760
+ export function layoutWorkflowDialog(
761
+ input: WorkflowDialogInput,
762
+ ): WorkflowCardLine[] {
763
+ const glyphs = input.ascii ? ASCII_DIALOG_GLYPHS : UNICODE_DIALOG_GLYPHS
764
+ const width = workflowDialogContentWidth(input.width ?? DEFAULT_WIDTH)
765
+ const now = input.now ?? Date.now()
766
+ const view = resolveWorkflowDialog(input)
767
+ const { state } = input
768
+ // What the panes may *hold*; the frame's actual height is settled below, once
769
+ // there is something to measure.
770
+ const capacity = Math.max(
771
+ MIN_PANE_BODY_ROWS,
772
+ input.bodyRows ?? DEFAULT_PANE_BODY_ROWS,
773
+ )
774
+ const spinnerFrame = input.spinnerFrame ?? 0
775
+
776
+ const lines: WorkflowCardLine[] = []
777
+
778
+ // ---- Header: the run's name, then its description with the stats flush right.
779
+ const head = header(
780
+ input.task,
781
+ input.meta,
782
+ view.groups,
783
+ input.agentCount ?? 0,
784
+ now,
785
+ )
786
+ lines.push(
787
+ clampLine(
788
+ [{ text: " " }, { text: head.name, color: "toolTitle", bold: true }],
789
+ width,
790
+ ),
791
+ )
792
+ lines.push(
793
+ rightAlign(
794
+ head.subtext ? [{ text: " " }, { text: head.subtext, color: "dim" }] : [],
795
+ [{ text: head.stats, color: "dim" }],
796
+ width,
797
+ ),
798
+ )
799
+ lines.push([])
800
+
801
+ const frameWidth = width - 1
802
+ const leftWidth = leftPaneWidth(frameWidth)
803
+ const rightWidth = Math.max(1, frameWidth - leftWidth - 3)
804
+ const inPhases = state.level === "phases"
805
+ const entry = view.selectedEntry
806
+
807
+ // ---- The pane listing phases, shown only at the overview level.
808
+ const phaseRows: WorkflowCardLine[] = []
809
+ const digits = String(view.groups.length).length
810
+ const phases = windowRange(view.clampedPhase, view.groups.length, capacity)
811
+ for (
812
+ let i = phases.start;
813
+ i < Math.min(phases.end, view.groups.length);
814
+ i++
815
+ ) {
816
+ const group = view.groups[i]
817
+ const selected = i === view.clampedPhase
818
+ const color: WorkflowCardColor = selected
819
+ ? "accent"
820
+ : group.status === "done"
821
+ ? "success"
822
+ : group.status === "failed"
823
+ ? "error"
824
+ : "dim"
825
+ // An unfinished phase shows its number where a finished one shows a glyph —
826
+ // so the list doubles as a numbered plan of the run.
827
+ const glyph =
828
+ group.status === "done"
829
+ ? glyphs.tick
830
+ : group.status === "failed"
831
+ ? glyphs.cross
832
+ : String(i + 1)
833
+ phaseRows.push(
834
+ rightAlign(
835
+ [
836
+ { text: " " },
837
+ { text: selected ? glyphs.pointer : " ", color: "accent" },
838
+ { text: " " },
839
+ { text: glyph.padStart(digits), color },
840
+ { text: " " },
841
+ { text: group.title, color },
842
+ ],
843
+ group.totalCount === 0
844
+ ? []
845
+ : [{ text: `${group.doneCount}/${group.totalCount} `, color }],
846
+ leftWidth,
847
+ ),
848
+ )
849
+ }
850
+
851
+ // ---- The pane listing this phase's agents. It is the right pane at the
852
+ // overview level and the left pane in the subview, so it is built once.
853
+ const agentPaneWidth = inPhases ? rightWidth : leftWidth
854
+ const agentRows: WorkflowCardLine[] = []
855
+ if (view.visibleAgents.length === 0) {
856
+ agentRows.push(
857
+ clampLine(
858
+ [{ text: ` ${WORKFLOW_DIALOG_COPY.noAgents}`, color: "dim" }],
859
+ agentPaneWidth,
860
+ ),
861
+ )
862
+ } else {
863
+ const agents = windowRange(
864
+ view.clampedAgent,
865
+ view.visibleAgents.length,
866
+ capacity,
867
+ )
868
+ for (
869
+ let i = agents.start;
870
+ i < Math.min(agents.end, view.visibleAgents.length);
871
+ i++
872
+ ) {
873
+ agentRows.push(
874
+ agentRow({
875
+ entry: view.visibleAgents[i],
876
+ selected: i === view.clampedAgent,
877
+ compact: !inPhases,
878
+ width: agentPaneWidth,
879
+ glyphs,
880
+ workflowActive: view.workflowActive,
881
+ spinnerFrame,
882
+ now,
883
+ }),
884
+ )
885
+ }
886
+ }
887
+
888
+ // ---- The detail pane, in the subview only.
889
+ const detailRows: WorkflowCardLine[] = []
890
+ if (!inPhases && entry) {
891
+ const display = displayState(entry, view.workflowActive)
892
+ // Prefers the canonical `provider/model-id` here — two providers can serve
893
+ // models whose short names read alike, and this pane has the width for it.
894
+ const model = formatModel(entry, { canonical: true })
895
+ detailRows.push(
896
+ clampLine(
897
+ [
898
+ { text: " " },
899
+ dialogRowGlyph(display, glyphs, spinnerFrame),
900
+ { text: ` ${statusWord(display)}`, color: "muted" },
901
+ ...(model
902
+ ? [
903
+ { text: " · ", color: "dim" as const },
904
+ { text: model, color: "dim" as const },
905
+ ]
906
+ : []),
907
+ ],
908
+ rightWidth,
909
+ ),
910
+ )
911
+ // Rebuilt rather than filtered out of `agentStatSegments`: the model and the
912
+ // agent type are already on the line above, and the token count wants its
913
+ // unit here exactly as it has one in the row.
914
+ const stats: string[] = []
915
+ // The thinking level lives here rather than on the tight card row: it is
916
+ // per-agent configuration, which is what someone opening this pane came to
917
+ // see, and `thinking: medium` on every row of a fan-out would be noise.
918
+ const thinking = formatThinking(entry)
919
+ if (thinking) stats.push(thinking)
920
+ if (entry.tokens) stats.push(`${formatCompactTokens(entry.tokens)} tok`)
921
+ if (entry.toolCalls)
922
+ stats.push(
923
+ `${entry.toolCalls} tool call${entry.toolCalls === 1 ? "" : "s"}`,
924
+ )
925
+ if (entry.durationMs) stats.push(formatDuration(entry.durationMs))
926
+ if (stats.length > 0) {
927
+ detailRows.push(
928
+ clampLine(
929
+ [{ text: ` ${stats.join(" · ")}`, color: "dim" }],
930
+ rightWidth,
931
+ ),
932
+ )
933
+ }
934
+
935
+ const prompt = previewLines(entry.promptPreview)
936
+ const collapsed =
937
+ !state.promptExpanded && prompt.length > PROMPT_COLLAPSED_LINES
938
+ const promptSuffix: string[] = []
939
+ if (prompt.length > 0)
940
+ promptSuffix.push(
941
+ `${prompt.length} ${prompt.length === 1 ? "line" : "lines"}`,
942
+ )
943
+ if (prompt.length > PROMPT_COLLAPSED_LINES) {
944
+ promptSuffix.push(
945
+ `${glyphs.enter} ${state.promptExpanded ? "collapse" : "expand"}`,
946
+ )
947
+ }
948
+ detailRows.push([])
949
+ detailRows.push(detailHeading("Prompt", promptSuffix, rightWidth))
950
+ if (prompt.length === 0) {
951
+ detailRows.push(
952
+ detailBody(WORKFLOW_DIALOG_COPY.availableOnceStarted, rightWidth),
953
+ )
954
+ } else {
955
+ const shown = collapsed ? prompt.slice(0, PROMPT_COLLAPSED_LINES) : prompt
956
+ for (const text of shown) detailRows.push(detailBody(text, rightWidth))
957
+ // Named rather than silently cut: the reader has to know the prompt goes on.
958
+ if (collapsed) {
959
+ const hidden = prompt.length - PROMPT_COLLAPSED_LINES
960
+ detailRows.push(
961
+ detailBody(
962
+ `${glyphs.ellipsis} ${hidden} more line${hidden === 1 ? "" : "s"}`,
963
+ rightWidth,
964
+ ),
965
+ )
966
+ }
967
+ }
968
+
969
+ const toolCalls = entry.toolCalls ?? 0
970
+ detailRows.push([])
971
+ detailRows.push(
972
+ detailHeading(
973
+ "Activity",
974
+ toolCalls > 0
975
+ ? [`${toolCalls} tool call${toolCalls === 1 ? "" : "s"}`]
976
+ : [],
977
+ rightWidth,
978
+ ),
979
+ )
980
+ detailRows.push(detailBody(activityBody(entry, display), rightWidth))
981
+
982
+ detailRows.push([])
983
+ detailRows.push(detailHeading("Outcome", [], rightWidth))
984
+ // Wrapped, not clamped: the outcome is the thing the reader came for, and
985
+ // cutting it at the pane edge would hide the half that matters.
986
+ for (const text of wrapTextWithAnsi(
987
+ outcomeBody(entry, display),
988
+ Math.max(1, rightWidth - 4),
989
+ )) {
990
+ detailRows.push(detailBody(text, rightWidth))
991
+ }
992
+ }
993
+
994
+ const phaseTitle = view.groups[view.clampedPhase]?.title ?? "Phases"
995
+ // With a filter on, the count is of what survived it — so the title says
996
+ // which filter, rather than leaving "3 agents" looking like the whole phase.
997
+ const shown = view.visibleAgents.length
998
+ const agentPaneTitle =
999
+ state.filter === "all"
1000
+ ? `${phaseTitle} · ${shown} agent${shown === 1 ? "" : "s"}`
1001
+ : `${phaseTitle} · ${shown} ${state.filter}`
1002
+ // Indented one column, so the frame's left edge lines up under the name and
1003
+ // the description rather than hanging off the edge of them.
1004
+ const leftRows = inPhases ? phaseRows : agentRows
1005
+ const rightRows = inPhases ? agentRows : detailRows
1006
+ lines.push(
1007
+ ...paneFrame({
1008
+ leftTitle: inPhases ? "Phases" : agentPaneTitle,
1009
+ rightTitle: inPhases
1010
+ ? agentPaneTitle
1011
+ : (entry?.label ?? WORKFLOW_DIALOG_COPY.noAgents),
1012
+ leftRows,
1013
+ rightRows,
1014
+ width: width - 1,
1015
+ // Tall enough for whichever pane holds more, and no taller. Both panes
1016
+ // were built against `capacity`, so neither can exceed it and nothing
1017
+ // measured here is ever cut by the frame it is sizing.
1018
+ bodyRows: Math.min(
1019
+ capacity,
1020
+ Math.max(MIN_PANE_BODY_ROWS, leftRows.length, rightRows.length),
1021
+ ),
1022
+ glyphs,
1023
+ }).map((line) => [{ text: " " }, ...line]),
1024
+ )
1025
+
1026
+ // ---- Key hints ----
1027
+ // Only the actions the run can currently take, so the footer never advertises
1028
+ // a key that does nothing. Gated on `available` as well as run state: a caller
1029
+ // that wires only some of the actions must not get a footer advertising keys
1030
+ // that do nothing. Omitting `available` entirely keeps every hint, which is
1031
+ // what the layout tests want.
1032
+ const can = (action: keyof WorkflowDialogActions) =>
1033
+ input.available?.[action] ?? true
1034
+ const hints: string[] = []
1035
+ if (inPhases) {
1036
+ hints.push(`${glyphs.upDown} select`)
1037
+ if (view.visibleAgents.length > 0) hints.push(`${glyphs.enter} open`)
1038
+ hints.push("f filter")
1039
+ } else {
1040
+ hints.push(`${glyphs.upDown} agent`)
1041
+ if (previewLines(entry?.promptPreview).length > PROMPT_COLLAPSED_LINES) {
1042
+ hints.push(`${glyphs.enter} prompt`)
1043
+ }
1044
+ const actions = agentActions(entry, view.workflowActive)
1045
+ if (actions.skip && can("onSkipAgent")) hints.push("s skip")
1046
+ if (actions.retry && can("onRetryAgent")) hints.push("r retry")
1047
+ }
1048
+ if (view.paused && can("onResume")) hints.push("p resume")
1049
+ else if (view.workflowActive && can("onPause")) hints.push("p pause")
1050
+ if (view.workflowActive && can("onKill")) hints.push("x stop")
1051
+ hints.push(inPhases ? "esc close" : "esc back")
1052
+ // Advertised at BOTH levels, because the key works at both: the selected row
1053
+ // is the one marked in the agents pane either way. Gated on the entry having
1054
+ // a record id, so a row whose child has not been issued one — a queued agent,
1055
+ // a replayed one — does not promise a conversation that does not exist.
1056
+ //
1057
+ // Last, and abbreviated, for one reason: the footer is clamped rather than
1058
+ // wrapped, so whatever sits at the end is what an 80-column terminal drops.
1059
+ // This is the only hint here that can be dropped without stranding the
1060
+ // reader — every other key either moves the cursor, changes the run, or is
1061
+ // the way out — so it is the one that goes over the edge first.
1062
+ if (view.selectedEntry?.recordId !== undefined && can("onOpenAgent")) {
1063
+ hints.push("c convo")
1064
+ }
1065
+ lines.push(
1066
+ clampLine([{ text: ` ${hints.join(" · ")}`, color: "dim" }], width),
1067
+ )
1068
+
1069
+ return lines
1070
+ }
1071
+
1072
+ /* ------------------------------------------------------------------------- *
1073
+ * Keys
1074
+ * ------------------------------------------------------------------------- */
1075
+
1076
+ const nextFilter = (filter: WorkflowDialogFilter): WorkflowDialogFilter =>
1077
+ WORKFLOW_DIALOG_FILTERS[
1078
+ (WORKFLOW_DIALOG_FILTERS.indexOf(filter) + 1) %
1079
+ WORKFLOW_DIALOG_FILTERS.length
1080
+ ]
1081
+
1082
+ /**
1083
+ * Map a keypress to the next state and, where the key is an action, what the
1084
+ * caller should do about it. Pure: `undefined` means "not ours".
1085
+ *
1086
+ * Movement clamps at both ends rather than wrapping — a long agent list should
1087
+ * not jump back to the top under a held `j`.
1088
+ */
1089
+ export function handleWorkflowDialogKey(
1090
+ data: string,
1091
+ state: WorkflowDialogState,
1092
+ view: ResolvedWorkflowDialog,
1093
+ ): { state: WorkflowDialogState; action?: WorkflowDialogAction } | undefined {
1094
+ // Ctrl+C is the reflex for backing out of a full-screen overlay, so it closes
1095
+ // outright from EITHER level — the conversation viewer's #255 fix, which this
1096
+ // dialog is reached the same way as. Deliberately not folded into the `esc`
1097
+ // branch below: stepping back a level on the reflex key still leaves the
1098
+ // overlay on screen, which is the stuck feeling the key exists to avoid.
1099
+ if (matchesKey(data, "ctrl+c")) return { state, action: { kind: "cancel" } }
1100
+
1101
+ // Back one level before out of the dialog: `esc` in the subview returns to
1102
+ // the overview, and only closes from there. Anything else would make a wrong
1103
+ // turn cost the whole dialog.
1104
+ if (matchesKey(data, "escape") || matchesKey(data, "q")) {
1105
+ if (state.level === "agent")
1106
+ return { state: { ...state, level: "phases", promptExpanded: false } }
1107
+ return { state, action: { kind: "cancel" } }
1108
+ }
1109
+ if (matchesKey(data, "left") && state.level === "agent") {
1110
+ return { state: { ...state, level: "phases", promptExpanded: false } }
1111
+ }
1112
+
1113
+ const down = matchesKey(data, "j") || matchesKey(data, "down")
1114
+ const up = matchesKey(data, "k") || matchesKey(data, "up")
1115
+ if (down || up) {
1116
+ const delta = down ? 1 : -1
1117
+ if (state.level === "phases") {
1118
+ const next = clampIndex(view.clampedPhase + delta, view.groups.length)
1119
+ // Changing phase re-points the agent list at a different set of rows, so
1120
+ // the old row index would be meaningless.
1121
+ return {
1122
+ state:
1123
+ next === view.clampedPhase
1124
+ ? state
1125
+ : { ...state, selectedPhase: next, selectedAgent: 0 },
1126
+ }
1127
+ }
1128
+ return {
1129
+ state: {
1130
+ ...state,
1131
+ selectedAgent: clampIndex(
1132
+ view.clampedAgent + delta,
1133
+ view.visibleAgents.length,
1134
+ ),
1135
+ },
1136
+ }
1137
+ }
1138
+
1139
+ // One key, two jobs, because the two levels are what it means at each: open
1140
+ // the selected phase's agents, then expand the prompt of the one you opened.
1141
+ if (matchesKey(data, "enter") || matchesKey(data, "right")) {
1142
+ if (state.level === "phases") {
1143
+ // Nothing to open, so nothing happens — entering an empty pane would
1144
+ // strand the reader in a subview with no rows and no detail.
1145
+ if (view.visibleAgents.length === 0) return { state }
1146
+ return { state: { ...state, level: "agent", promptExpanded: false } }
1147
+ }
1148
+ return { state: { ...state, promptExpanded: !state.promptExpanded } }
1149
+ }
1150
+ if (matchesKey(data, "e")) {
1151
+ return { state: { ...state, promptExpanded: !state.promptExpanded } }
1152
+ }
1153
+
1154
+ // The one key that opens something instead of changing the run, so unlike
1155
+ // skip/retry it works at both levels and on a settled agent: reading what a
1156
+ // finished child did is most of why anyone opens this dialog. A row with no
1157
+ // record id has no conversation to open, and falls through as unbound.
1158
+ if (matchesKey(data, "c")) {
1159
+ const recordId = view.selectedEntry?.recordId
1160
+ return recordId === undefined
1161
+ ? undefined
1162
+ : { state, action: { kind: "open", recordId } }
1163
+ }
1164
+
1165
+ // The filter re-points the agent list, so it belongs to the level that shows
1166
+ // the whole list rather than the one showing a single agent's detail.
1167
+ if (matchesKey(data, "f") && state.level === "phases") {
1168
+ return {
1169
+ state: { ...state, filter: nextFilter(state.filter), selectedAgent: 0 },
1170
+ }
1171
+ }
1172
+
1173
+ // Gated on the run being live, exactly as the footer's hints are. A settled
1174
+ // run has nothing left to stop, and firing `kill` at one aborts a controller
1175
+ // that is already done and reports a stop that never happened.
1176
+ if (matchesKey(data, "x"))
1177
+ return view.workflowActive ? { state, action: { kind: "kill" } } : undefined
1178
+ if (matchesKey(data, "p")) {
1179
+ if (!view.workflowActive) return undefined
1180
+ return { state, action: { kind: view.paused ? "resume" : "pause" } }
1181
+ }
1182
+ const actions = agentActions(view.selectedEntry, view.workflowActive)
1183
+ if (matchesKey(data, "s") && actions.skip && view.selectedEntry) {
1184
+ return { state, action: { kind: "skip", index: view.selectedEntry.index } }
1185
+ }
1186
+ if (matchesKey(data, "r") && actions.retry && view.selectedEntry) {
1187
+ return { state, action: { kind: "retry", index: view.selectedEntry.index } }
1188
+ }
1189
+
1190
+ return undefined
1191
+ }
1192
+
1193
+ /* ------------------------------------------------------------------------- *
1194
+ * Rendering
1195
+ * ------------------------------------------------------------------------- */
1196
+
1197
+ /** The dialog as plain text — what the layout tests assert against. */
1198
+ export function plainWorkflowDialogLines(
1199
+ lines: readonly WorkflowCardLine[],
1200
+ ): string[] {
1201
+ return lines.map((line) => line.map((segment) => segment.text).join(""))
1202
+ }
1203
+
1204
+ /**
1205
+ * The `/agents → Workflows` overlay.
1206
+ *
1207
+ * Deliberately thin: it owns the spinner timer and the theme, and delegates
1208
+ * everything else to the two pure functions above. `source` is re-read every
1209
+ * render so a live run updates in place without any subscription plumbing.
1210
+ */
1211
+ export class WorkflowDialog implements Component {
1212
+ private state: WorkflowDialogState
1213
+ private spinnerFrame = 0
1214
+ private timer: ReturnType<typeof setInterval> | undefined
1215
+ private closed = false
1216
+
1217
+ constructor(
1218
+ private tui: TUI,
1219
+ private source: () => WorkflowDialogSource,
1220
+ private theme: Theme,
1221
+ private done: (result: undefined) => void,
1222
+ private actions: WorkflowDialogActions = {},
1223
+ initialPhaseIndex = 0,
1224
+ ) {
1225
+ this.state = initialWorkflowDialogState(initialPhaseIndex)
1226
+ this.timer = setInterval(() => {
1227
+ this.spinnerFrame++
1228
+ if (!this.closed) this.tui.requestRender()
1229
+ }, WORKFLOW_DIALOG_SPINNER_MS)
1230
+ this.timer.unref?.()
1231
+ }
1232
+
1233
+ handleInput(data: string): void {
1234
+ const input: WorkflowDialogInput = { ...this.source(), state: this.state }
1235
+ const result = handleWorkflowDialogKey(
1236
+ data,
1237
+ this.state,
1238
+ resolveWorkflowDialog(input),
1239
+ )
1240
+ if (!result) return
1241
+ this.state = result.state
1242
+ if (result.action) this.dispatch(result.action)
1243
+ this.tui.requestRender()
1244
+ }
1245
+
1246
+ render(width: number): string[] {
1247
+ const lines = layoutWorkflowDialog({
1248
+ ...this.source(),
1249
+ state: this.state,
1250
+ // Derived from what was actually injected, so the footer advertises only
1251
+ // the keys this dialog can service.
1252
+ available: {
1253
+ onKill: this.actions.onKill !== undefined,
1254
+ onPause: this.actions.onPause !== undefined,
1255
+ onResume: this.actions.onResume !== undefined,
1256
+ onSkipAgent: this.actions.onSkipAgent !== undefined,
1257
+ onRetryAgent: this.actions.onRetryAgent !== undefined,
1258
+ onOpenAgent: this.actions.onOpenAgent !== undefined,
1259
+ },
1260
+ width,
1261
+ spinnerFrame: this.spinnerFrame,
1262
+ })
1263
+ return styleWorkflowCardLines(lines, this.theme)
1264
+ }
1265
+
1266
+ invalidate(): void {
1267
+ /* no cached state to clear */
1268
+ }
1269
+
1270
+ dispose(): void {
1271
+ this.closed = true
1272
+ if (this.timer) {
1273
+ clearInterval(this.timer)
1274
+ this.timer = undefined
1275
+ }
1276
+ }
1277
+
1278
+ private dispatch(action: WorkflowDialogAction): void {
1279
+ switch (action.kind) {
1280
+ case "cancel":
1281
+ this.closed = true
1282
+ this.done(undefined)
1283
+ return
1284
+ case "kill":
1285
+ this.actions.onKill?.()
1286
+ return
1287
+ case "pause":
1288
+ this.actions.onPause?.()
1289
+ return
1290
+ case "resume":
1291
+ this.actions.onResume?.()
1292
+ return
1293
+ case "skip":
1294
+ this.actions.onSkipAgent?.(action.index)
1295
+ return
1296
+ case "retry":
1297
+ this.actions.onRetryAgent?.(action.index)
1298
+ return
1299
+ case "open":
1300
+ this.actions.onOpenAgent?.(action.recordId)
1301
+ return
1302
+ }
1303
+ }
1304
+ }