@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,844 @@
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
+ import { matchesKey, stripTerminalSequences, truncateToWidth, visibleWidth, wrapTextWithAnsi, } from "@earendil-works/pi-tui";
43
+ import { buildPhaseGroups, displayState, formatDuration, header, isLive, } from "../workflow/progress.js";
44
+ import { SPINNER } from "./agent-widget.js";
45
+ import { ASCII_GLYPHS, clampLine, formatCompactTokens, formatModel, formatThinking, REPLAYED_ANNOTATION, styleWorkflowCardLines, UNICODE_GLYPHS, } from "./workflow-card.js";
46
+ /** Fallback width when the caller does not know the terminal's. */
47
+ const DEFAULT_WIDTH = 80;
48
+ /** Inner width of the left pane at any comfortable terminal size. */
49
+ const LEFT_PANE_WIDTH = 18;
50
+ /**
51
+ * Most rows the frame will ever draw between its top and bottom edges.
52
+ *
53
+ * A cap, not a height: the box sizes itself to what it holds, and this is where
54
+ * it stops growing so a 200-agent fan-out scrolls inside the pane instead of
55
+ * pasting 200 rows into the conversation.
56
+ */
57
+ export const DEFAULT_PANE_BODY_ROWS = 22;
58
+ /**
59
+ * Fewest rows the frame will draw.
60
+ *
61
+ * Below this a pane stops reading as a pane, and the box would resize on
62
+ * nearly every keypress — most phases hold a handful of agents, so a floor here
63
+ * absorbs the ordinary movement and leaves the height alone.
64
+ */
65
+ export const MIN_PANE_BODY_ROWS = 6;
66
+ /** Prompt lines shown before `expand` is offered. */
67
+ export const PROMPT_COLLAPSED_LINES = 4;
68
+ /** Spinner cadence. Unlike the card's 1s header tick, this row really animates. */
69
+ export const WORKFLOW_DIALOG_SPINNER_MS = 80;
70
+ export const UNICODE_DIALOG_GLYPHS = {
71
+ tick: UNICODE_GLYPHS.tick,
72
+ cross: UNICODE_GLYPHS.cross,
73
+ queued: "◌",
74
+ pointer: "❯",
75
+ focus: UNICODE_GLYPHS.pointer,
76
+ spinner: SPINNER,
77
+ box: {
78
+ topLeft: "╭",
79
+ topRight: "╮",
80
+ bottomLeft: "╰",
81
+ bottomRight: "╯",
82
+ horizontal: "─",
83
+ vertical: "│",
84
+ topTee: "┬",
85
+ bottomTee: "┴",
86
+ },
87
+ ellipsis: "…",
88
+ upDown: "↑↓",
89
+ enter: "⏎",
90
+ };
91
+ /** The ASCII tier, one column per glyph so the panes stay aligned either way. */
92
+ export const ASCII_DIALOG_GLYPHS = {
93
+ tick: ASCII_GLYPHS.tick,
94
+ cross: ASCII_GLYPHS.cross,
95
+ queued: "o",
96
+ pointer: ">",
97
+ focus: ASCII_GLYPHS.pointer,
98
+ spinner: ["-", "\\", "|", "/"],
99
+ box: {
100
+ topLeft: "+",
101
+ topRight: "+",
102
+ bottomLeft: "+",
103
+ bottomRight: "+",
104
+ horizontal: "-",
105
+ vertical: "|",
106
+ topTee: "+",
107
+ bottomTee: "+",
108
+ },
109
+ ellipsis: "~",
110
+ upDown: "up/down",
111
+ enter: "enter",
112
+ };
113
+ /**
114
+ * The recovered dialog mapping — keyed on the *display* state.
115
+ *
116
+ * Claude Code's `permission` colour has no pi equivalent; a blocked agent is
117
+ * waiting on the user, so it maps to `warning` (selection maps to `accent`).
118
+ */
119
+ export function dialogRowGlyph(state, glyphs, spinnerFrame = 0) {
120
+ switch (state) {
121
+ case "done":
122
+ return { text: glyphs.tick, color: "success" };
123
+ case "failed":
124
+ return { text: glyphs.cross, color: "error" };
125
+ case "skipped":
126
+ return { text: glyphs.cross, color: "dim" };
127
+ case "blocked":
128
+ return { text: glyphs.cross, color: "warning" };
129
+ case "queued":
130
+ case "interrupted":
131
+ return { text: glyphs.queued, color: "dim" };
132
+ case "running":
133
+ return { text: glyphs.spinner[spinnerFrame % glyphs.spinner.length], color: "dim" };
134
+ }
135
+ }
136
+ /* ------------------------------------------------------------------------- *
137
+ * Verbatim copy
138
+ * ------------------------------------------------------------------------- */
139
+ /**
140
+ * Claude Code's own strings. Kept together and named so a reader can see at a
141
+ * glance which surface each belongs to, and so none drifts under an edit.
142
+ */
143
+ export const WORKFLOW_DIALOG_COPY = {
144
+ waitingForSlot: "Waiting for an agent slot.",
145
+ availableOnceStarted: "Available once the agent starts.",
146
+ notAvailableYet: "Not available yet (agent still running).",
147
+ noTranscript: "Transcript not available.",
148
+ stoppedEarly: "The workflow stopped before this agent finished.",
149
+ skippedByUser: "Skipped by user.",
150
+ noToolCallsYet: "No tool calls yet.",
151
+ noToolCalls: "No tool calls.",
152
+ noAgents: "No agents",
153
+ };
154
+ /** The order `f` cycles through. */
155
+ export const WORKFLOW_DIALOG_FILTERS = [
156
+ "all",
157
+ "running",
158
+ "queued",
159
+ "done",
160
+ "failed",
161
+ "blocked",
162
+ "skipped",
163
+ "interrupted",
164
+ ];
165
+ export function initialWorkflowDialogState(initialPhaseIndex = 0) {
166
+ return {
167
+ selectedPhase: initialPhaseIndex,
168
+ selectedAgent: 0,
169
+ level: "phases",
170
+ filter: "all",
171
+ promptExpanded: false,
172
+ };
173
+ }
174
+ /** Content width. The 6 columns are the dialog's border and padding. */
175
+ export function workflowDialogContentWidth(terminalWidth) {
176
+ return Math.max(12, terminalWidth - 6);
177
+ }
178
+ const clampIndex = (index, length) => length === 0 ? 0 : Math.min(Math.max(0, Math.trunc(index)), length - 1);
179
+ /**
180
+ * Settle the selection against the data actually present.
181
+ *
182
+ * Selection is stored raw and clamped on read, so a phase finishing (and its
183
+ * agents dropping out of a filtered view) never leaves the cursor pointing past
184
+ * the end — the same trick `fleet-list.ts` plays, minus the mutation.
185
+ */
186
+ export function resolveWorkflowDialog(input) {
187
+ const groups = buildPhaseGroups(input.progress, input.meta?.phases);
188
+ const workflowActive = input.task.status === "running" || input.task.status === "paused";
189
+ const clampedPhase = clampIndex(input.state.selectedPhase, groups.length);
190
+ const all = groups[clampedPhase]?.agents ?? [];
191
+ const visibleAgents = input.state.filter === "all"
192
+ ? [...all]
193
+ : all.filter(entry => displayState(entry, workflowActive) === input.state.filter);
194
+ const clampedAgent = clampIndex(input.state.selectedAgent, visibleAgents.length);
195
+ return {
196
+ groups,
197
+ clampedPhase,
198
+ clampedAgent,
199
+ visibleAgents,
200
+ selectedEntry: visibleAgents[clampedAgent],
201
+ workflowActive,
202
+ paused: input.task.status === "paused",
203
+ };
204
+ }
205
+ /* ------------------------------------------------------------------------- *
206
+ * Row pieces
207
+ * ------------------------------------------------------------------------- */
208
+ /**
209
+ * The `·`-separated annotations between an agent's label and its stats.
210
+ *
211
+ * These say *why* a row looks the way it does — a retry and its cause, a cache
212
+ * hit replayed from the resume journal, how long a queued agent has been
213
+ * waiting. The stat tail (agentType, model, tokens, tool calls, duration) is the
214
+ * card's and is appended after.
215
+ */
216
+ export function subStatusAnnotations(entry, state, now) {
217
+ const parts = [];
218
+ if (entry.isolation)
219
+ parts.push(entry.isolation);
220
+ if (entry.cached)
221
+ parts.push(REPLAYED_ANNOTATION);
222
+ if (entry.lastAttemptReason) {
223
+ parts.push(entry.lastAttemptReason === "user-retry" ? "user retry" : entry.lastAttemptReason);
224
+ }
225
+ if (entry.attempt != null && entry.attempt > 1)
226
+ parts.push(`attempt ${entry.attempt}`);
227
+ if (state === "queued" && entry.queuedAt != null) {
228
+ parts.push(`waiting ${formatDuration(Math.max(0, now - entry.queuedAt))}`);
229
+ }
230
+ return parts;
231
+ }
232
+ const lineWidth = (line) => line.reduce((sum, s) => sum + visibleWidth(s.text), 0);
233
+ /** Place `right` flush to `width`, cutting `left` first so the stats survive. */
234
+ function rightAlign(left, right, width) {
235
+ const rightWidth = lineWidth(right);
236
+ const clampedLeft = clampLine(left, Math.max(0, width - rightWidth - 1));
237
+ const gap = Math.max(1, width - lineWidth(clampedLeft) - rightWidth);
238
+ return clampLine([...clampedLeft, { text: " ".repeat(gap) }, ...right], width);
239
+ }
240
+ /**
241
+ * Window a list around its selection, so a 200-agent fan-out still shows the
242
+ * row you are on. Mirrors `fleet-list.ts`'s arithmetic.
243
+ */
244
+ function windowRange(selected, total, max) {
245
+ const visible = Math.min(max, total);
246
+ const start = selected < visible ? 0 : selected - visible + 1;
247
+ return { start, end: start + visible };
248
+ }
249
+ /* ------------------------------------------------------------------------- *
250
+ * The pane frame
251
+ * ------------------------------------------------------------------------- */
252
+ /**
253
+ * Inner width of the left pane.
254
+ *
255
+ * Fixed rather than proportional at usable terminal sizes: the left pane holds
256
+ * short labels (a phase title, an agent label) and the right pane holds
257
+ * everything that actually needs room, so giving the left a share of a wide
258
+ * terminal would only pad it. It gives way on a narrow one.
259
+ */
260
+ export function leftPaneWidth(width) {
261
+ // The two cells share everything except the three border columns, and the
262
+ // right one must keep at least a column — so the left is capped by what it
263
+ // can take without squeezing the right out and tearing the frame.
264
+ const available = Math.max(2, width - 3);
265
+ return Math.max(1, Math.min(LEFT_PANE_WIDTH, Math.floor(available / 3), available - 1));
266
+ }
267
+ /** Fill a title out to the cell width with rule, so the corners stay put. */
268
+ function padTitle(line, width, horizontal) {
269
+ const gap = Math.max(0, width - lineWidth(line));
270
+ return gap > 0 ? [...line, { text: horizontal.repeat(gap), color: "dim" }] : line;
271
+ }
272
+ /** One pane row, padded to exactly `width` so the frame stays vertical. */
273
+ function padCell(line, width) {
274
+ const clamped = clampLine(line, width);
275
+ const gap = Math.max(0, width - lineWidth(clamped));
276
+ return gap > 0 ? [...clamped, { text: " ".repeat(gap) }] : clamped;
277
+ }
278
+ /** A title as it sits in the frame's top edge: ` Title ` then rule to `width`. */
279
+ function frameTitle(title, width, glyphs) {
280
+ const room = Math.max(0, width - 2);
281
+ // Truncated with the marker rather than simply cut: `Discover · 1 ag…` has to
282
+ // read as "there was more", not as a title that happens to end oddly. Pi's
283
+ // own truncation does the width arithmetic, including wide characters; its
284
+ // ellipsis arrives wrapped in resets, which are stripped for the same reason
285
+ // `clampLine` strips them — the layout stays plain text until it is themed.
286
+ const shown = stripTerminalSequences(truncateToWidth(title, room, glyphs.ellipsis));
287
+ const rule = Math.max(0, width - visibleWidth(shown) - 2);
288
+ // Clamped as well as computed: at a terminal narrow enough that `room` hits
289
+ // zero the marker alone is already wider than the cell, and a title that
290
+ // overflows tears the frame open on every row below it.
291
+ return clampLine([
292
+ { text: " ", color: "dim" },
293
+ { text: shown, color: "muted", bold: true },
294
+ { text: ` ${glyphs.box.horizontal.repeat(rule)}`, color: "dim" },
295
+ ], width);
296
+ }
297
+ /**
298
+ * Draw the two panes into one framed block.
299
+ *
300
+ * Both columns are padded to `bodyRows` so the frame is the same height however
301
+ * much either side holds — a box that grew and shrank as you moved the
302
+ * selection would make the whole dialog jump.
303
+ */
304
+ function paneFrame(options) {
305
+ const { glyphs, width } = options;
306
+ const box = glyphs.box;
307
+ const left = leftPaneWidth(width);
308
+ const right = Math.max(1, width - left - 3);
309
+ const lines = [];
310
+ lines.push([
311
+ { text: box.topLeft, color: "dim" },
312
+ ...padTitle(frameTitle(options.leftTitle, left, glyphs), left, box.horizontal),
313
+ { text: box.topTee, color: "dim" },
314
+ ...padTitle(frameTitle(options.rightTitle, right, glyphs), right, box.horizontal),
315
+ { text: box.topRight, color: "dim" },
316
+ ]);
317
+ for (let row = 0; row < options.bodyRows; row++) {
318
+ lines.push([
319
+ { text: box.vertical, color: "dim" },
320
+ ...padCell(options.leftRows[row] ?? [], left),
321
+ { text: box.vertical, color: "dim" },
322
+ ...padCell(options.rightRows[row] ?? [], right),
323
+ { text: box.vertical, color: "dim" },
324
+ ]);
325
+ }
326
+ lines.push([
327
+ { text: box.bottomLeft, color: "dim" },
328
+ { text: box.horizontal.repeat(left), color: "dim" },
329
+ { text: box.bottomTee, color: "dim" },
330
+ { text: box.horizontal.repeat(right), color: "dim" },
331
+ { text: box.bottomRight, color: "dim" },
332
+ ]);
333
+ return lines;
334
+ }
335
+ /* ------------------------------------------------------------------------- *
336
+ * Detail sections
337
+ * ------------------------------------------------------------------------- */
338
+ /** Split a preview into lines, treating an empty preview as absent. */
339
+ const previewLines = (preview) => (preview ? preview.split("\n") : []);
340
+ /** What the Activity body says. There is a count of tool calls, never a list. */
341
+ function activityBody(entry, state) {
342
+ if (state === "queued")
343
+ return WORKFLOW_DIALOG_COPY.availableOnceStarted;
344
+ if ((entry.toolCalls ?? 0) > 0)
345
+ return WORKFLOW_DIALOG_COPY.noTranscript;
346
+ return isLive(entry) ? WORKFLOW_DIALOG_COPY.noToolCallsYet : WORKFLOW_DIALOG_COPY.noToolCalls;
347
+ }
348
+ /** What the Outcome body says, which is a different sentence for every state. */
349
+ function outcomeBody(entry, state) {
350
+ switch (state) {
351
+ case "skipped":
352
+ return WORKFLOW_DIALOG_COPY.skippedByUser;
353
+ case "interrupted":
354
+ return WORKFLOW_DIALOG_COPY.stoppedEarly;
355
+ case "queued":
356
+ return WORKFLOW_DIALOG_COPY.waitingForSlot;
357
+ case "running":
358
+ return WORKFLOW_DIALOG_COPY.notAvailableYet;
359
+ case "failed":
360
+ case "blocked":
361
+ return entry.error ?? WORKFLOW_DIALOG_COPY.noTranscript;
362
+ case "done":
363
+ return entry.resultPreview ?? WORKFLOW_DIALOG_COPY.noTranscript;
364
+ }
365
+ }
366
+ /* ------------------------------------------------------------------------- *
367
+ * Layout
368
+ * ------------------------------------------------------------------------- */
369
+ /**
370
+ * Which of the per-agent actions the selected row can currently take.
371
+ *
372
+ * The window for both is the one in which the agent's `agent()` call is still
373
+ * unanswered. Skip covers that whole window; retry needs a child to stop and
374
+ * start again, so it begins only once one exists. Once the call has settled its
375
+ * value is already the script's, and there is nothing either key could change.
376
+ */
377
+ export function agentActions(entry, workflowActive) {
378
+ if (entry === undefined || !workflowActive)
379
+ return { skip: false, retry: false };
380
+ const state = displayState(entry, workflowActive);
381
+ return { skip: state === "queued" || state === "running", retry: state === "running" };
382
+ }
383
+ /** Status word the detail pane leads with, one per display state. */
384
+ function statusWord(state) {
385
+ switch (state) {
386
+ case "done": return "Completed";
387
+ case "failed": return "Failed";
388
+ case "skipped": return "Skipped";
389
+ case "blocked": return "Blocked";
390
+ case "queued": return "Queued";
391
+ case "interrupted": return "Stopped";
392
+ case "running": return "Running";
393
+ }
394
+ }
395
+ /** `Prompt · 5 lines · ⏎ expand` — a detail heading and its dim suffixes. */
396
+ function detailHeading(title, suffixes, width) {
397
+ const line = [{ text: " " }, { text: title, color: "muted", bold: true }];
398
+ for (const suffix of suffixes) {
399
+ line.push({ text: " · ", color: "dim" }, { text: suffix, color: "dim" });
400
+ }
401
+ return clampLine(line, width);
402
+ }
403
+ /** A detail body line, indented under its heading. */
404
+ const detailBody = (text, width) => clampLine([{ text: ` ${text}`, color: "dim" }], width);
405
+ /** The row for one agent, as it appears in whichever pane is listing agents. */
406
+ function agentRow(options) {
407
+ const { entry, selected, glyphs, width } = options;
408
+ const display = displayState(entry, options.workflowActive);
409
+ const head = [
410
+ { text: " " },
411
+ { text: selected ? glyphs.pointer : " ", color: "accent" },
412
+ { text: " " },
413
+ dialogRowGlyph(display, glyphs, options.spinnerFrame),
414
+ { text: " " },
415
+ { text: entry.label, color: selected ? "accent" : undefined },
416
+ ];
417
+ // The narrow pane holds the label and nothing else; there is no room for a
418
+ // stat tail, and clamping one would just spend columns on a truncated word.
419
+ if (options.compact)
420
+ return clampLine(head, width);
421
+ const model = formatModel(entry);
422
+ if (model)
423
+ head.push({ text: ` ${model}`, color: "dim" });
424
+ for (const part of [...subStatusAnnotations(entry, display, options.now), ...rowStatSegments(entry)]) {
425
+ head.push({ text: " · ", color: "dim" }, { text: part, color: "dim" });
426
+ }
427
+ // The duration sits flush right, so a column of rows reads as a column of
428
+ // durations rather than as ragged text.
429
+ const duration = entry.durationMs ? [{ text: `${formatDuration(entry.durationMs)} `, color: "dim" }] : [];
430
+ return duration.length > 0 ? rightAlign(head, duration, width) : clampLine(head, width);
431
+ }
432
+ /** The agent row's dot-separated tail. The model is not in it — it leads. */
433
+ function rowStatSegments(entry) {
434
+ return entry.tokens ? [`${formatCompactTokens(entry.tokens)} tok`] : [];
435
+ }
436
+ /**
437
+ * Build the dialog.
438
+ *
439
+ * Two panes side by side inside one frame, and two levels of depth: phases with
440
+ * the selected phase's agents beside them, then — on opening one — those agents
441
+ * with the selected agent's detail beside them. The frame is a fixed height so
442
+ * the dialog does not jump as the selection moves through runs of very
443
+ * different sizes.
444
+ */
445
+ export function layoutWorkflowDialog(input) {
446
+ const glyphs = input.ascii ? ASCII_DIALOG_GLYPHS : UNICODE_DIALOG_GLYPHS;
447
+ const width = workflowDialogContentWidth(input.width ?? DEFAULT_WIDTH);
448
+ const now = input.now ?? Date.now();
449
+ const view = resolveWorkflowDialog(input);
450
+ const { state } = input;
451
+ // What the panes may *hold*; the frame's actual height is settled below, once
452
+ // there is something to measure.
453
+ const capacity = Math.max(MIN_PANE_BODY_ROWS, input.bodyRows ?? DEFAULT_PANE_BODY_ROWS);
454
+ const spinnerFrame = input.spinnerFrame ?? 0;
455
+ const lines = [];
456
+ // ---- Header: the run's name, then its description with the stats flush right.
457
+ const head = header(input.task, input.meta, view.groups, input.agentCount ?? 0, now);
458
+ lines.push(clampLine([{ text: " " }, { text: head.name, color: "toolTitle", bold: true }], width));
459
+ lines.push(rightAlign(head.subtext ? [{ text: " " }, { text: head.subtext, color: "dim" }] : [], [{ text: head.stats, color: "dim" }], width));
460
+ lines.push([]);
461
+ const frameWidth = width - 1;
462
+ const leftWidth = leftPaneWidth(frameWidth);
463
+ const rightWidth = Math.max(1, frameWidth - leftWidth - 3);
464
+ const inPhases = state.level === "phases";
465
+ const entry = view.selectedEntry;
466
+ // ---- The pane listing phases, shown only at the overview level.
467
+ const phaseRows = [];
468
+ const digits = String(view.groups.length).length;
469
+ const phases = windowRange(view.clampedPhase, view.groups.length, capacity);
470
+ for (let i = phases.start; i < Math.min(phases.end, view.groups.length); i++) {
471
+ const group = view.groups[i];
472
+ const selected = i === view.clampedPhase;
473
+ const color = selected ? "accent"
474
+ : group.status === "done" ? "success"
475
+ : group.status === "failed" ? "error"
476
+ : "dim";
477
+ // An unfinished phase shows its number where a finished one shows a glyph —
478
+ // so the list doubles as a numbered plan of the run.
479
+ const glyph = group.status === "done" ? glyphs.tick
480
+ : group.status === "failed" ? glyphs.cross
481
+ : String(i + 1);
482
+ phaseRows.push(rightAlign([
483
+ { text: " " },
484
+ { text: selected ? glyphs.pointer : " ", color: "accent" },
485
+ { text: " " },
486
+ { text: glyph.padStart(digits), color },
487
+ { text: " " },
488
+ { text: group.title, color },
489
+ ], group.totalCount === 0 ? [] : [{ text: `${group.doneCount}/${group.totalCount} `, color }], leftWidth));
490
+ }
491
+ // ---- The pane listing this phase's agents. It is the right pane at the
492
+ // overview level and the left pane in the subview, so it is built once.
493
+ const agentPaneWidth = inPhases ? rightWidth : leftWidth;
494
+ const agentRows = [];
495
+ if (view.visibleAgents.length === 0) {
496
+ agentRows.push(clampLine([{ text: ` ${WORKFLOW_DIALOG_COPY.noAgents}`, color: "dim" }], agentPaneWidth));
497
+ }
498
+ else {
499
+ const agents = windowRange(view.clampedAgent, view.visibleAgents.length, capacity);
500
+ for (let i = agents.start; i < Math.min(agents.end, view.visibleAgents.length); i++) {
501
+ agentRows.push(agentRow({
502
+ entry: view.visibleAgents[i],
503
+ selected: i === view.clampedAgent,
504
+ compact: !inPhases,
505
+ width: agentPaneWidth,
506
+ glyphs,
507
+ workflowActive: view.workflowActive,
508
+ spinnerFrame,
509
+ now,
510
+ }));
511
+ }
512
+ }
513
+ // ---- The detail pane, in the subview only.
514
+ const detailRows = [];
515
+ if (!inPhases && entry) {
516
+ const display = displayState(entry, view.workflowActive);
517
+ // Prefers the canonical `provider/model-id` here — two providers can serve
518
+ // models whose short names read alike, and this pane has the width for it.
519
+ const model = formatModel(entry, { canonical: true });
520
+ detailRows.push(clampLine([
521
+ { text: " " },
522
+ dialogRowGlyph(display, glyphs, spinnerFrame),
523
+ { text: ` ${statusWord(display)}`, color: "muted" },
524
+ ...(model ? [{ text: " · ", color: "dim" }, { text: model, color: "dim" }] : []),
525
+ ], rightWidth));
526
+ // Rebuilt rather than filtered out of `agentStatSegments`: the model and the
527
+ // agent type are already on the line above, and the token count wants its
528
+ // unit here exactly as it has one in the row.
529
+ const stats = [];
530
+ // The thinking level lives here rather than on the tight card row: it is
531
+ // per-agent configuration, which is what someone opening this pane came to
532
+ // see, and `thinking: medium` on every row of a fan-out would be noise.
533
+ const thinking = formatThinking(entry);
534
+ if (thinking)
535
+ stats.push(thinking);
536
+ if (entry.tokens)
537
+ stats.push(`${formatCompactTokens(entry.tokens)} tok`);
538
+ if (entry.toolCalls)
539
+ stats.push(`${entry.toolCalls} tool call${entry.toolCalls === 1 ? "" : "s"}`);
540
+ if (entry.durationMs)
541
+ stats.push(formatDuration(entry.durationMs));
542
+ if (stats.length > 0) {
543
+ detailRows.push(clampLine([{ text: ` ${stats.join(" · ")}`, color: "dim" }], rightWidth));
544
+ }
545
+ const prompt = previewLines(entry.promptPreview);
546
+ const collapsed = !state.promptExpanded && prompt.length > PROMPT_COLLAPSED_LINES;
547
+ const promptSuffix = [];
548
+ if (prompt.length > 0)
549
+ promptSuffix.push(`${prompt.length} ${prompt.length === 1 ? "line" : "lines"}`);
550
+ if (prompt.length > PROMPT_COLLAPSED_LINES) {
551
+ promptSuffix.push(`${glyphs.enter} ${state.promptExpanded ? "collapse" : "expand"}`);
552
+ }
553
+ detailRows.push([]);
554
+ detailRows.push(detailHeading("Prompt", promptSuffix, rightWidth));
555
+ if (prompt.length === 0) {
556
+ detailRows.push(detailBody(WORKFLOW_DIALOG_COPY.availableOnceStarted, rightWidth));
557
+ }
558
+ else {
559
+ const shown = collapsed ? prompt.slice(0, PROMPT_COLLAPSED_LINES) : prompt;
560
+ for (const text of shown)
561
+ detailRows.push(detailBody(text, rightWidth));
562
+ // Named rather than silently cut: the reader has to know the prompt goes on.
563
+ if (collapsed) {
564
+ const hidden = prompt.length - PROMPT_COLLAPSED_LINES;
565
+ detailRows.push(detailBody(`${glyphs.ellipsis} ${hidden} more line${hidden === 1 ? "" : "s"}`, rightWidth));
566
+ }
567
+ }
568
+ const toolCalls = entry.toolCalls ?? 0;
569
+ detailRows.push([]);
570
+ detailRows.push(detailHeading("Activity", toolCalls > 0 ? [`${toolCalls} tool call${toolCalls === 1 ? "" : "s"}`] : [], rightWidth));
571
+ detailRows.push(detailBody(activityBody(entry, display), rightWidth));
572
+ detailRows.push([]);
573
+ detailRows.push(detailHeading("Outcome", [], rightWidth));
574
+ // Wrapped, not clamped: the outcome is the thing the reader came for, and
575
+ // cutting it at the pane edge would hide the half that matters.
576
+ for (const text of wrapTextWithAnsi(outcomeBody(entry, display), Math.max(1, rightWidth - 4))) {
577
+ detailRows.push(detailBody(text, rightWidth));
578
+ }
579
+ }
580
+ const phaseTitle = view.groups[view.clampedPhase]?.title ?? "Phases";
581
+ // With a filter on, the count is of what survived it — so the title says
582
+ // which filter, rather than leaving "3 agents" looking like the whole phase.
583
+ const shown = view.visibleAgents.length;
584
+ const agentPaneTitle = state.filter === "all" ?
585
+ `${phaseTitle} · ${shown} agent${shown === 1 ? "" : "s"}`
586
+ : `${phaseTitle} · ${shown} ${state.filter}`;
587
+ // Indented one column, so the frame's left edge lines up under the name and
588
+ // the description rather than hanging off the edge of them.
589
+ const leftRows = inPhases ? phaseRows : agentRows;
590
+ const rightRows = inPhases ? agentRows : detailRows;
591
+ lines.push(...paneFrame({
592
+ leftTitle: inPhases ? "Phases" : agentPaneTitle,
593
+ rightTitle: inPhases ? agentPaneTitle : (entry?.label ?? WORKFLOW_DIALOG_COPY.noAgents),
594
+ leftRows,
595
+ rightRows,
596
+ width: width - 1,
597
+ // Tall enough for whichever pane holds more, and no taller. Both panes
598
+ // were built against `capacity`, so neither can exceed it and nothing
599
+ // measured here is ever cut by the frame it is sizing.
600
+ bodyRows: Math.min(capacity, Math.max(MIN_PANE_BODY_ROWS, leftRows.length, rightRows.length)),
601
+ glyphs,
602
+ }).map(line => [{ text: " " }, ...line]));
603
+ // ---- Key hints ----
604
+ // Only the actions the run can currently take, so the footer never advertises
605
+ // a key that does nothing. Gated on `available` as well as run state: a caller
606
+ // that wires only some of the actions must not get a footer advertising keys
607
+ // that do nothing. Omitting `available` entirely keeps every hint, which is
608
+ // what the layout tests want.
609
+ const can = (action) => input.available?.[action] ?? true;
610
+ const hints = [];
611
+ if (inPhases) {
612
+ hints.push(`${glyphs.upDown} select`);
613
+ if (view.visibleAgents.length > 0)
614
+ hints.push(`${glyphs.enter} open`);
615
+ hints.push("f filter");
616
+ }
617
+ else {
618
+ hints.push(`${glyphs.upDown} agent`);
619
+ if (previewLines(entry?.promptPreview).length > PROMPT_COLLAPSED_LINES) {
620
+ hints.push(`${glyphs.enter} prompt`);
621
+ }
622
+ const actions = agentActions(entry, view.workflowActive);
623
+ if (actions.skip && can("onSkipAgent"))
624
+ hints.push("s skip");
625
+ if (actions.retry && can("onRetryAgent"))
626
+ hints.push("r retry");
627
+ }
628
+ if (view.paused && can("onResume"))
629
+ hints.push("p resume");
630
+ else if (view.workflowActive && can("onPause"))
631
+ hints.push("p pause");
632
+ if (view.workflowActive && can("onKill"))
633
+ hints.push("x stop");
634
+ hints.push(inPhases ? "esc close" : "esc back");
635
+ // Advertised at BOTH levels, because the key works at both: the selected row
636
+ // is the one marked in the agents pane either way. Gated on the entry having
637
+ // a record id, so a row whose child has not been issued one — a queued agent,
638
+ // a replayed one — does not promise a conversation that does not exist.
639
+ //
640
+ // Last, and abbreviated, for one reason: the footer is clamped rather than
641
+ // wrapped, so whatever sits at the end is what an 80-column terminal drops.
642
+ // This is the only hint here that can be dropped without stranding the
643
+ // reader — every other key either moves the cursor, changes the run, or is
644
+ // the way out — so it is the one that goes over the edge first.
645
+ if (view.selectedEntry?.recordId !== undefined && can("onOpenAgent")) {
646
+ hints.push("c convo");
647
+ }
648
+ lines.push(clampLine([{ text: ` ${hints.join(" · ")}`, color: "dim" }], width));
649
+ return lines;
650
+ }
651
+ /* ------------------------------------------------------------------------- *
652
+ * Keys
653
+ * ------------------------------------------------------------------------- */
654
+ const nextFilter = (filter) => WORKFLOW_DIALOG_FILTERS[(WORKFLOW_DIALOG_FILTERS.indexOf(filter) + 1) % WORKFLOW_DIALOG_FILTERS.length];
655
+ /**
656
+ * Map a keypress to the next state and, where the key is an action, what the
657
+ * caller should do about it. Pure: `undefined` means "not ours".
658
+ *
659
+ * Movement clamps at both ends rather than wrapping — a long agent list should
660
+ * not jump back to the top under a held `j`.
661
+ */
662
+ export function handleWorkflowDialogKey(data, state, view) {
663
+ // Ctrl+C is the reflex for backing out of a full-screen overlay, so it closes
664
+ // outright from EITHER level — the conversation viewer's #255 fix, which this
665
+ // dialog is reached the same way as. Deliberately not folded into the `esc`
666
+ // branch below: stepping back a level on the reflex key still leaves the
667
+ // overlay on screen, which is the stuck feeling the key exists to avoid.
668
+ if (matchesKey(data, "ctrl+c"))
669
+ return { state, action: { kind: "cancel" } };
670
+ // Back one level before out of the dialog: `esc` in the subview returns to
671
+ // the overview, and only closes from there. Anything else would make a wrong
672
+ // turn cost the whole dialog.
673
+ if (matchesKey(data, "escape") || matchesKey(data, "q")) {
674
+ if (state.level === "agent")
675
+ return { state: { ...state, level: "phases", promptExpanded: false } };
676
+ return { state, action: { kind: "cancel" } };
677
+ }
678
+ if (matchesKey(data, "left") && state.level === "agent") {
679
+ return { state: { ...state, level: "phases", promptExpanded: false } };
680
+ }
681
+ const down = matchesKey(data, "j") || matchesKey(data, "down");
682
+ const up = matchesKey(data, "k") || matchesKey(data, "up");
683
+ if (down || up) {
684
+ const delta = down ? 1 : -1;
685
+ if (state.level === "phases") {
686
+ const next = clampIndex(view.clampedPhase + delta, view.groups.length);
687
+ // Changing phase re-points the agent list at a different set of rows, so
688
+ // the old row index would be meaningless.
689
+ return { state: next === view.clampedPhase ? state : { ...state, selectedPhase: next, selectedAgent: 0 } };
690
+ }
691
+ return { state: { ...state, selectedAgent: clampIndex(view.clampedAgent + delta, view.visibleAgents.length) } };
692
+ }
693
+ // One key, two jobs, because the two levels are what it means at each: open
694
+ // the selected phase's agents, then expand the prompt of the one you opened.
695
+ if (matchesKey(data, "enter") || matchesKey(data, "right")) {
696
+ if (state.level === "phases") {
697
+ // Nothing to open, so nothing happens — entering an empty pane would
698
+ // strand the reader in a subview with no rows and no detail.
699
+ if (view.visibleAgents.length === 0)
700
+ return { state };
701
+ return { state: { ...state, level: "agent", promptExpanded: false } };
702
+ }
703
+ return { state: { ...state, promptExpanded: !state.promptExpanded } };
704
+ }
705
+ if (matchesKey(data, "e")) {
706
+ return { state: { ...state, promptExpanded: !state.promptExpanded } };
707
+ }
708
+ // The one key that opens something instead of changing the run, so unlike
709
+ // skip/retry it works at both levels and on a settled agent: reading what a
710
+ // finished child did is most of why anyone opens this dialog. A row with no
711
+ // record id has no conversation to open, and falls through as unbound.
712
+ if (matchesKey(data, "c")) {
713
+ const recordId = view.selectedEntry?.recordId;
714
+ return recordId === undefined ? undefined : { state, action: { kind: "open", recordId } };
715
+ }
716
+ // The filter re-points the agent list, so it belongs to the level that shows
717
+ // the whole list rather than the one showing a single agent's detail.
718
+ if (matchesKey(data, "f") && state.level === "phases") {
719
+ return { state: { ...state, filter: nextFilter(state.filter), selectedAgent: 0 } };
720
+ }
721
+ // Gated on the run being live, exactly as the footer's hints are. A settled
722
+ // run has nothing left to stop, and firing `kill` at one aborts a controller
723
+ // that is already done and reports a stop that never happened.
724
+ if (matchesKey(data, "x"))
725
+ return view.workflowActive ? { state, action: { kind: "kill" } } : undefined;
726
+ if (matchesKey(data, "p")) {
727
+ if (!view.workflowActive)
728
+ return undefined;
729
+ return { state, action: { kind: view.paused ? "resume" : "pause" } };
730
+ }
731
+ const actions = agentActions(view.selectedEntry, view.workflowActive);
732
+ if (matchesKey(data, "s") && actions.skip && view.selectedEntry) {
733
+ return { state, action: { kind: "skip", index: view.selectedEntry.index } };
734
+ }
735
+ if (matchesKey(data, "r") && actions.retry && view.selectedEntry) {
736
+ return { state, action: { kind: "retry", index: view.selectedEntry.index } };
737
+ }
738
+ return undefined;
739
+ }
740
+ /* ------------------------------------------------------------------------- *
741
+ * Rendering
742
+ * ------------------------------------------------------------------------- */
743
+ /** The dialog as plain text — what the layout tests assert against. */
744
+ export function plainWorkflowDialogLines(lines) {
745
+ return lines.map(line => line.map(segment => segment.text).join(""));
746
+ }
747
+ /**
748
+ * The `/agents → Workflows` overlay.
749
+ *
750
+ * Deliberately thin: it owns the spinner timer and the theme, and delegates
751
+ * everything else to the two pure functions above. `source` is re-read every
752
+ * render so a live run updates in place without any subscription plumbing.
753
+ */
754
+ export class WorkflowDialog {
755
+ tui;
756
+ source;
757
+ theme;
758
+ done;
759
+ actions;
760
+ state;
761
+ spinnerFrame = 0;
762
+ timer;
763
+ closed = false;
764
+ constructor(tui, source, theme, done, actions = {}, initialPhaseIndex = 0) {
765
+ this.tui = tui;
766
+ this.source = source;
767
+ this.theme = theme;
768
+ this.done = done;
769
+ this.actions = actions;
770
+ this.state = initialWorkflowDialogState(initialPhaseIndex);
771
+ this.timer = setInterval(() => {
772
+ this.spinnerFrame++;
773
+ if (!this.closed)
774
+ this.tui.requestRender();
775
+ }, WORKFLOW_DIALOG_SPINNER_MS);
776
+ this.timer.unref?.();
777
+ }
778
+ handleInput(data) {
779
+ const input = { ...this.source(), state: this.state };
780
+ const result = handleWorkflowDialogKey(data, this.state, resolveWorkflowDialog(input));
781
+ if (!result)
782
+ return;
783
+ this.state = result.state;
784
+ if (result.action)
785
+ this.dispatch(result.action);
786
+ this.tui.requestRender();
787
+ }
788
+ render(width) {
789
+ const lines = layoutWorkflowDialog({
790
+ ...this.source(),
791
+ state: this.state,
792
+ // Derived from what was actually injected, so the footer advertises only
793
+ // the keys this dialog can service.
794
+ available: {
795
+ onKill: this.actions.onKill !== undefined,
796
+ onPause: this.actions.onPause !== undefined,
797
+ onResume: this.actions.onResume !== undefined,
798
+ onSkipAgent: this.actions.onSkipAgent !== undefined,
799
+ onRetryAgent: this.actions.onRetryAgent !== undefined,
800
+ onOpenAgent: this.actions.onOpenAgent !== undefined,
801
+ },
802
+ width,
803
+ spinnerFrame: this.spinnerFrame,
804
+ });
805
+ return styleWorkflowCardLines(lines, this.theme);
806
+ }
807
+ invalidate() {
808
+ /* no cached state to clear */
809
+ }
810
+ dispose() {
811
+ this.closed = true;
812
+ if (this.timer) {
813
+ clearInterval(this.timer);
814
+ this.timer = undefined;
815
+ }
816
+ }
817
+ dispatch(action) {
818
+ switch (action.kind) {
819
+ case "cancel":
820
+ this.closed = true;
821
+ this.done(undefined);
822
+ return;
823
+ case "kill":
824
+ this.actions.onKill?.();
825
+ return;
826
+ case "pause":
827
+ this.actions.onPause?.();
828
+ return;
829
+ case "resume":
830
+ this.actions.onResume?.();
831
+ return;
832
+ case "skip":
833
+ this.actions.onSkipAgent?.(action.index);
834
+ return;
835
+ case "retry":
836
+ this.actions.onRetryAgent?.(action.index);
837
+ return;
838
+ case "open":
839
+ this.actions.onOpenAgent?.(action.recordId);
840
+ return;
841
+ }
842
+ }
843
+ }
844
+ //# sourceMappingURL=workflow-dialog.js.map