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