@esso0428/pi-subagents 0.17.6 → 0.17.7

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