@bastani/atomic 0.5.3-1 → 0.5.4-0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/README.md +110 -11
  2. package/dist/{chunk-mn870nrv.js → chunk-xkxndz5g.js} +213 -154
  3. package/dist/sdk/components/workflow-picker-panel.d.ts +120 -0
  4. package/dist/sdk/define-workflow.d.ts +1 -1
  5. package/dist/sdk/index.js +1 -1
  6. package/dist/sdk/runtime/discovery.d.ts +57 -3
  7. package/dist/sdk/runtime/executor.d.ts +15 -2
  8. package/dist/sdk/runtime/tmux.d.ts +9 -0
  9. package/dist/sdk/types.d.ts +63 -4
  10. package/dist/sdk/workflows/builtin/deep-research-codebase/claude/index.d.ts +61 -0
  11. package/dist/sdk/workflows/builtin/deep-research-codebase/copilot/index.d.ts +48 -0
  12. package/dist/sdk/workflows/builtin/deep-research-codebase/helpers/heuristic.d.ts +25 -0
  13. package/dist/sdk/workflows/builtin/deep-research-codebase/helpers/prompts.d.ts +91 -0
  14. package/dist/sdk/workflows/builtin/deep-research-codebase/helpers/scout.d.ts +56 -0
  15. package/dist/sdk/workflows/builtin/deep-research-codebase/opencode/index.d.ts +48 -0
  16. package/dist/sdk/workflows/builtin/ralph/claude/index.js +6 -5
  17. package/dist/sdk/workflows/builtin/ralph/copilot/index.js +6 -5
  18. package/dist/sdk/workflows/builtin/ralph/opencode/index.js +6 -5
  19. package/dist/sdk/workflows/index.d.ts +4 -4
  20. package/dist/sdk/workflows/index.js +7 -1
  21. package/package.json +1 -1
  22. package/src/cli.ts +25 -3
  23. package/src/commands/cli/chat/index.ts +5 -5
  24. package/src/commands/cli/init/index.ts +79 -77
  25. package/src/commands/cli/workflow-command.test.ts +757 -0
  26. package/src/commands/cli/workflow.test.ts +310 -0
  27. package/src/commands/cli/workflow.ts +445 -105
  28. package/src/sdk/components/workflow-picker-panel.tsx +1462 -0
  29. package/src/sdk/define-workflow.test.ts +101 -0
  30. package/src/sdk/define-workflow.ts +62 -2
  31. package/src/sdk/runtime/discovery.ts +111 -8
  32. package/src/sdk/runtime/executor.ts +89 -32
  33. package/src/sdk/runtime/tmux.conf +55 -0
  34. package/src/sdk/runtime/tmux.ts +34 -10
  35. package/src/sdk/types.ts +67 -4
  36. package/src/sdk/workflows/builtin/deep-research-codebase/claude/index.ts +294 -0
  37. package/src/sdk/workflows/builtin/deep-research-codebase/copilot/index.ts +276 -0
  38. package/src/sdk/workflows/builtin/deep-research-codebase/helpers/heuristic.ts +38 -0
  39. package/src/sdk/workflows/builtin/deep-research-codebase/helpers/prompts.ts +816 -0
  40. package/src/sdk/workflows/builtin/deep-research-codebase/helpers/scout.ts +334 -0
  41. package/src/sdk/workflows/builtin/deep-research-codebase/opencode/index.ts +284 -0
  42. package/src/sdk/workflows/builtin/ralph/claude/index.ts +8 -4
  43. package/src/sdk/workflows/builtin/ralph/copilot/index.ts +10 -4
  44. package/src/sdk/workflows/builtin/ralph/opencode/index.ts +8 -4
  45. package/src/sdk/workflows/index.ts +9 -1
  46. package/src/services/system/auto-sync.ts +1 -1
  47. package/src/services/system/install-ui.ts +109 -39
  48. package/src/theme/colors.ts +65 -1
@@ -0,0 +1,1462 @@
1
+ /** @jsxImportSource @opentui/react */
2
+ /**
3
+ * WorkflowPickerPanel — interactive TUI for `atomic workflow -a <agent>`.
4
+ *
5
+ * Telescope-style fuzzy picker with a two-phase flow:
6
+ *
7
+ * 1. PICK — filter workflows scoped to the current agent, navigate with
8
+ * ↑/↓ or ⌃j/⌃k, press ↵ to lock in a selection.
9
+ * 2. PROMPT — fill the workflow's declared input schema (one field per
10
+ * declared `WorkflowInput`). Free-form workflows fall back to
11
+ * a single `prompt` text field.
12
+ *
13
+ * Pressing ⌃s in the prompt phase validates required fields and opens a
14
+ * CONFIRM modal that shows the fully-composed shell command before
15
+ * submission. y/↵ confirms, n/esc cancels back to the form.
16
+ *
17
+ * Lifecycle:
18
+ *
19
+ * const panel = await WorkflowPickerPanel.create({ agent, workflows });
20
+ * const result = await panel.waitForSelection();
21
+ * panel.destroy();
22
+ * if (result) await executeWorkflow({ ... });
23
+ *
24
+ * `waitForSelection()` resolves with `null` if the user exits without
25
+ * committing (esc in PICK phase, or ⌃c anywhere) and with a
26
+ * `{ workflow, inputs }` record if they confirm the run.
27
+ */
28
+
29
+ import { createCliRenderer, type CliRenderer } from "@opentui/core";
30
+ import {
31
+ createRoot,
32
+ useKeyboard,
33
+ type Root,
34
+ } from "@opentui/react";
35
+ import { useState, useEffect, useMemo } from "react";
36
+ import { resolveTheme, type TerminalTheme } from "../runtime/theme.ts";
37
+ import type { AgentType, WorkflowInput } from "../types.ts";
38
+ import type { WorkflowWithMetadata } from "../runtime/discovery.ts";
39
+ import { ErrorBoundary } from "./error-boundary.tsx";
40
+
41
+ // ─── Theme ──────────────────────────────────────
42
+ // The picker uses a slightly extended palette vs. the base terminal theme:
43
+ // an `info` (sky) hue for built-in workflows and a `mauve` hue for global
44
+ // ones — the same distinctions `atomic workflow -l` already draws. The
45
+ // rest is sourced from {@link resolveTheme} so light/dark mode tracks the
46
+ // orchestrator panel.
47
+ export interface PickerTheme {
48
+ background: string;
49
+ backgroundPanel: string;
50
+ backgroundElement: string;
51
+ surface: string;
52
+ text: string;
53
+ textMuted: string;
54
+ textDim: string;
55
+ primary: string;
56
+ success: string;
57
+ error: string;
58
+ warning: string;
59
+ info: string;
60
+ mauve: string;
61
+ border: string;
62
+ borderActive: string;
63
+ }
64
+
65
+ export function buildPickerTheme(base: TerminalTheme): PickerTheme {
66
+ // For dark mode the prototype values track Catppuccin Mocha. For light
67
+ // mode we derive muted variants from the base palette — the specific
68
+ // extras (`info`, `mauve`, the three-level background ladder) have no
69
+ // direct entries in `TerminalTheme`, so we pick close-enough Catppuccin
70
+ // values to keep the picker visually consistent with the orchestrator.
71
+ const isDark = base.bg !== "#eff1f5";
72
+ return {
73
+ background: base.bg,
74
+ backgroundPanel: isDark ? "#181825" : "#e6e9ef",
75
+ backgroundElement: isDark ? "#11111b" : "#dce0e8",
76
+ surface: base.surface,
77
+ text: base.text,
78
+ textMuted: isDark ? "#a6adc8" : "#5c5f77",
79
+ textDim: base.dim,
80
+ primary: base.accent,
81
+ success: base.success,
82
+ error: base.error,
83
+ warning: base.warning,
84
+ info: isDark ? "#89dceb" : "#04a5e5",
85
+ mauve: isDark ? "#cba6f7" : "#8839ef",
86
+ border: base.borderDim,
87
+ borderActive: base.border,
88
+ };
89
+ }
90
+
91
+ // ─── Types ──────────────────────────────────────
92
+
93
+ type Source = "local" | "global" | "builtin";
94
+ type Phase = "pick" | "prompt";
95
+
96
+ /** The payload the picker resolves with on successful submission. */
97
+ export interface WorkflowPickerResult {
98
+ /** The workflow the user committed to running. */
99
+ workflow: WorkflowWithMetadata;
100
+ /** Populated form values, one per declared input (or { prompt } for free-form). */
101
+ inputs: Record<string, string>;
102
+ }
103
+
104
+ /** Fallback field used when a workflow has no structured input schema. */
105
+ const DEFAULT_PROMPT_INPUT: WorkflowInput = {
106
+ name: "prompt",
107
+ type: "text",
108
+ required: true,
109
+ description: "what do you want this workflow to do?",
110
+ placeholder: "describe your task…",
111
+ };
112
+
113
+ // ─── Helpers ────────────────────────────────────
114
+
115
+ const SOURCE_DISPLAY: Record<Source, string> = {
116
+ local: "local",
117
+ global: "global",
118
+ builtin: "builtin",
119
+ };
120
+
121
+ const SOURCE_DIR: Record<Source, string> = {
122
+ local: ".atomic/workflows",
123
+ global: "~/.atomic/workflows",
124
+ builtin: "built-in",
125
+ };
126
+
127
+ const SOURCE_COLOR: Record<Source, keyof PickerTheme> = {
128
+ local: "success",
129
+ global: "mauve",
130
+ builtin: "info",
131
+ };
132
+
133
+ /**
134
+ * Subsequence fuzzy match — Telescope-style. Returns a score (lower =
135
+ * better) or null for no match. Adjacent matches are rewarded; jumps over
136
+ * non-matching characters are penalized proportionally to the gap.
137
+ */
138
+ export function fuzzyMatch(query: string, target: string): number | null {
139
+ if (query === "") return 0;
140
+ const q = query.toLowerCase();
141
+ const t = target.toLowerCase();
142
+ let ti = 0;
143
+ let score = 0;
144
+ let prev = -2;
145
+ for (let qi = 0; qi < q.length; qi++) {
146
+ let found = -1;
147
+ while (ti < t.length) {
148
+ if (t[ti] === q[qi]) {
149
+ found = ti;
150
+ break;
151
+ }
152
+ ti++;
153
+ }
154
+ if (found === -1) return null;
155
+ score += found === prev + 1 ? 1 : 4 + (found - prev);
156
+ prev = found;
157
+ ti++;
158
+ }
159
+ return score;
160
+ }
161
+
162
+ // ─── List Building ──────────────────────────────
163
+
164
+ interface ListEntry {
165
+ workflow: WorkflowWithMetadata;
166
+ section: Source;
167
+ }
168
+
169
+ interface ListRow {
170
+ kind: "section" | "entry";
171
+ source?: Source;
172
+ entry?: ListEntry;
173
+ }
174
+
175
+ export function buildEntries(
176
+ query: string,
177
+ workflows: WorkflowWithMetadata[],
178
+ ): ListEntry[] {
179
+ type Scored = { wf: WorkflowWithMetadata; score: number };
180
+ const scored: Scored[] = [];
181
+ for (const wf of workflows) {
182
+ const nameScore = fuzzyMatch(query, wf.name);
183
+ const descScore = fuzzyMatch(query, wf.description);
184
+ const best =
185
+ nameScore !== null && descScore !== null
186
+ ? Math.min(nameScore, descScore + 2)
187
+ : nameScore !== null
188
+ ? nameScore
189
+ : descScore !== null
190
+ ? descScore + 2
191
+ : null;
192
+ if (best !== null) scored.push({ wf, score: best });
193
+ }
194
+
195
+ if (query === "") {
196
+ const rest: ListEntry[] = [];
197
+ for (const source of ["local", "global", "builtin"] as Source[]) {
198
+ const group = scored
199
+ .filter((s) => s.wf.source === source)
200
+ .sort((a, b) => a.wf.name.localeCompare(b.wf.name));
201
+ for (const s of group) rest.push({ workflow: s.wf, section: source });
202
+ }
203
+ return rest;
204
+ }
205
+
206
+ scored.sort((a, b) => a.score - b.score);
207
+ return scored.map<ListEntry>((s) => ({
208
+ workflow: s.wf,
209
+ section: s.wf.source,
210
+ }));
211
+ }
212
+
213
+ export function buildRows(entries: ListEntry[], query: string): ListRow[] {
214
+ const rows: ListRow[] = [];
215
+ if (query === "") {
216
+ let lastSection: string | null = null;
217
+ for (const e of entries) {
218
+ if (e.section !== lastSection) {
219
+ rows.push({ kind: "section", source: e.section });
220
+ lastSection = e.section;
221
+ }
222
+ rows.push({ kind: "entry", entry: e });
223
+ }
224
+ } else {
225
+ for (const e of entries) rows.push({ kind: "entry", entry: e });
226
+ }
227
+ return rows;
228
+ }
229
+
230
+ // ─── Validation ─────────────────────────────────
231
+
232
+ export function isFieldValid(field: WorkflowInput, value: string): boolean {
233
+ if (!field.required) return true;
234
+ if (field.type === "enum") return value !== "";
235
+ return value.trim() !== "";
236
+ }
237
+
238
+ // ─── Components ─────────────────────────────────
239
+
240
+ function SectionLabel({
241
+ theme,
242
+ label,
243
+ }: {
244
+ theme: PickerTheme;
245
+ label: string;
246
+ }) {
247
+ return (
248
+ <box height={1} flexDirection="row">
249
+ <text>
250
+ <span fg={theme.mauve}>▎ </span>
251
+ <span fg={theme.textMuted}>
252
+ <strong>{label}</strong>
253
+ </span>
254
+ </text>
255
+ </box>
256
+ );
257
+ }
258
+
259
+ function FilterBar({
260
+ theme,
261
+ query,
262
+ }: {
263
+ theme: PickerTheme;
264
+ query: string;
265
+ }) {
266
+ return (
267
+ <box
268
+ height={3}
269
+ border
270
+ borderStyle="rounded"
271
+ borderColor={theme.borderActive}
272
+ backgroundColor={theme.backgroundPanel}
273
+ flexDirection="row"
274
+ paddingLeft={2}
275
+ paddingRight={2}
276
+ alignItems="center"
277
+ >
278
+ <text>
279
+ <span fg={theme.primary}>
280
+ <strong>❯ </strong>
281
+ </span>
282
+ </text>
283
+ <text>
284
+ <span fg={theme.text}>{query}</span>
285
+ {/* Solid full-cell caret. Rendered as a space with a coloured
286
+ background so the cursor thickness stays stable — the
287
+ previous `▋` half-block halved in width compared to the
288
+ highlighted-char placeholder cursor. */}
289
+ <span bg={theme.primary}> </span>
290
+ </text>
291
+ </box>
292
+ );
293
+ }
294
+
295
+ function WorkflowList({
296
+ theme,
297
+ rows,
298
+ focusedEntryIdx,
299
+ }: {
300
+ theme: PickerTheme;
301
+ rows: ListRow[];
302
+ focusedEntryIdx: number;
303
+ }) {
304
+ if (rows.length === 0) {
305
+ return (
306
+ <box paddingLeft={2} paddingTop={2}>
307
+ <text>
308
+ <span fg={theme.textDim}>no matches</span>
309
+ </text>
310
+ </box>
311
+ );
312
+ }
313
+
314
+ let entryCounter = -1;
315
+ return (
316
+ <box flexDirection="column">
317
+ {rows.map((row, i) => {
318
+ if (row.kind === "section") {
319
+ const src = row.source!;
320
+ return (
321
+ <box
322
+ key={`s${i}`}
323
+ height={2}
324
+ paddingTop={1}
325
+ paddingLeft={2}
326
+ >
327
+ <text>
328
+ <span fg={theme[SOURCE_COLOR[src]]}>
329
+ {SOURCE_DISPLAY[src]}
330
+ </span>
331
+ <span fg={theme.textDim}>
332
+ {" (" + SOURCE_DIR[src] + ")"}
333
+ </span>
334
+ </text>
335
+ </box>
336
+ );
337
+ }
338
+ entryCounter++;
339
+ const isFocused = entryCounter === focusedEntryIdx;
340
+ const wf = row.entry!.workflow;
341
+
342
+ return (
343
+ <box
344
+ key={`e${i}`}
345
+ height={1}
346
+ flexDirection="row"
347
+ backgroundColor={isFocused ? theme.border : "transparent"}
348
+ paddingLeft={1}
349
+ paddingRight={2}
350
+ >
351
+ <text>
352
+ <span fg={isFocused ? theme.primary : theme.textDim}>
353
+ {isFocused ? "▸ " : " "}
354
+ </span>
355
+ <span fg={isFocused ? theme.text : theme.textMuted}>
356
+ {wf.name}
357
+ </span>
358
+ </text>
359
+ </box>
360
+ );
361
+ })}
362
+ </box>
363
+ );
364
+ }
365
+
366
+ function ArgumentRow({
367
+ theme,
368
+ field,
369
+ }: {
370
+ theme: PickerTheme;
371
+ field: WorkflowInput;
372
+ }) {
373
+ const isRequired = field.required ?? false;
374
+ const tagCol = isRequired ? theme.warning : theme.textDim;
375
+ const tagLabel = isRequired ? "required" : "optional";
376
+ const showEnumValues =
377
+ field.type === "enum" && field.values && field.values.length > 0;
378
+
379
+ return (
380
+ <box flexDirection="column" paddingLeft={2} paddingRight={2}>
381
+ <box flexDirection="row" height={1}>
382
+ <text>
383
+ <span fg={theme.text}>{field.name}</span>
384
+ </text>
385
+ <box flexGrow={1} />
386
+ <text>
387
+ <span fg={theme.textDim}>{field.type}</span>
388
+ <span fg={theme.textDim}>{" · "}</span>
389
+ <span fg={tagCol}>{tagLabel}</span>
390
+ </text>
391
+ </box>
392
+
393
+ {field.description ? (
394
+ <box height={1}>
395
+ <text>
396
+ <span fg={theme.textMuted}>{field.description}</span>
397
+ </text>
398
+ </box>
399
+ ) : null}
400
+
401
+ {showEnumValues ? (
402
+ <box height={1}>
403
+ <text>
404
+ <span fg={theme.textDim}>{field.values!.join(" · ")}</span>
405
+ </text>
406
+ </box>
407
+ ) : null}
408
+
409
+ <box height={1} />
410
+ </box>
411
+ );
412
+ }
413
+
414
+ function Preview({
415
+ theme,
416
+ wf,
417
+ }: {
418
+ theme: PickerTheme;
419
+ wf: WorkflowWithMetadata;
420
+ }) {
421
+ const args: WorkflowInput[] =
422
+ wf.inputs.length > 0 ? [...wf.inputs] : [DEFAULT_PROMPT_INPUT];
423
+
424
+ return (
425
+ <box
426
+ flexDirection="column"
427
+ paddingLeft={3}
428
+ paddingRight={3}
429
+ paddingTop={1}
430
+ >
431
+ <text>
432
+ <span fg={theme.text}>
433
+ <strong>{wf.name}</strong>
434
+ </span>
435
+ </text>
436
+
437
+ <box height={1} />
438
+
439
+ <text>
440
+ <span fg={theme[SOURCE_COLOR[wf.source as Source]]}>
441
+ {SOURCE_DISPLAY[wf.source as Source]}
442
+ </span>
443
+ <span fg={theme.textDim}>
444
+ {" (" + SOURCE_DIR[wf.source as Source] + ")"}
445
+ </span>
446
+ </text>
447
+
448
+ <box height={2} />
449
+
450
+ <text>
451
+ <span fg={theme.textMuted}>
452
+ {wf.description || "(no description)"}
453
+ </span>
454
+ </text>
455
+
456
+ <box height={2} />
457
+
458
+ <SectionLabel theme={theme} label="ARGUMENTS" />
459
+ <box height={1} />
460
+ {args.map((f) => (
461
+ <ArgumentRow key={f.name} theme={theme} field={f} />
462
+ ))}
463
+ </box>
464
+ );
465
+ }
466
+
467
+ function EmptyPreview({
468
+ theme,
469
+ query,
470
+ }: {
471
+ theme: PickerTheme;
472
+ query: string;
473
+ }) {
474
+ return (
475
+ <box
476
+ flexDirection="column"
477
+ paddingLeft={3}
478
+ paddingRight={3}
479
+ paddingTop={3}
480
+ >
481
+ <text>
482
+ <span fg={theme.textMuted}>No workflows match </span>
483
+ <span fg={theme.text}>"{query}"</span>
484
+ </text>
485
+ <box height={2} />
486
+ <text>
487
+ <span fg={theme.textDim}>
488
+ Press backspace to widen your search, or
489
+ </span>
490
+ </text>
491
+ <box height={2} />
492
+ <text>
493
+ <span fg={theme.textDim}>create a new one at</span>
494
+ </text>
495
+ <box height={1} />
496
+ <box paddingLeft={2}>
497
+ <text>
498
+ <span fg={theme.primary}>
499
+ .atomic/workflows/&lt;name&gt;/&lt;agent&gt;/index.ts
500
+ </span>
501
+ </text>
502
+ </box>
503
+ </box>
504
+ );
505
+ }
506
+
507
+ // ─── Field renderers ────────────────────────────
508
+
509
+ const TEXT_FIELD_LINES = 3;
510
+
511
+ /**
512
+ * Render a placeholder with a solid full-cell caret overlapping its
513
+ * first character. The caret is a full cell wide — same thickness as
514
+ * the trailing-caret cell used for typed text — so switching between
515
+ * "empty" and "has input" states never visually halves the cursor.
516
+ *
517
+ * When the field is not focused, the placeholder renders plain dim
518
+ * text without the caret highlight.
519
+ */
520
+ function PlaceholderWithCursor({
521
+ theme,
522
+ placeholder,
523
+ focused,
524
+ }: {
525
+ theme: PickerTheme;
526
+ placeholder: string;
527
+ focused: boolean;
528
+ }) {
529
+ const effective = placeholder.length > 0 ? placeholder : " ";
530
+ const first = effective.slice(0, 1);
531
+ const rest = effective.slice(1);
532
+
533
+ if (!focused) {
534
+ return (
535
+ <text>
536
+ <span fg={theme.textDim}>{effective}</span>
537
+ </text>
538
+ );
539
+ }
540
+
541
+ return (
542
+ <text>
543
+ <span fg={theme.surface} bg={theme.primary}>
544
+ {first}
545
+ </span>
546
+ <span fg={theme.textDim}>{rest}</span>
547
+ </text>
548
+ );
549
+ }
550
+
551
+ function TextAreaContent({
552
+ theme,
553
+ value,
554
+ placeholder,
555
+ focused,
556
+ lines,
557
+ }: {
558
+ theme: PickerTheme;
559
+ value: string;
560
+ placeholder: string;
561
+ focused: boolean;
562
+ lines: number;
563
+ }) {
564
+ const textLines = value.split("\n");
565
+ const start = Math.max(0, textLines.length - lines);
566
+ const visible: string[] = [];
567
+ for (let i = 0; i < lines; i++) {
568
+ visible.push(textLines[start + i] ?? "");
569
+ }
570
+ const cursorLine = Math.min(lines - 1, textLines.length - 1 - start);
571
+ const isEmpty = value === "";
572
+
573
+ return (
574
+ <box flexDirection="column">
575
+ {visible.map((line, i) => {
576
+ if (isEmpty && i === 0) {
577
+ return (
578
+ <box key={i} height={1}>
579
+ <PlaceholderWithCursor
580
+ theme={theme}
581
+ placeholder={placeholder}
582
+ focused={focused}
583
+ />
584
+ </box>
585
+ );
586
+ }
587
+ // Trailing caret on the active line. Rendered as a
588
+ // background-coloured space so the cell width matches the
589
+ // placeholder-overlap caret exactly — no thickness change
590
+ // between empty and typed states.
591
+ const showCursorHere = focused && !isEmpty && i === cursorLine;
592
+ return (
593
+ <box key={i} height={1}>
594
+ <text>
595
+ <span fg={theme.text}>{line}</span>
596
+ {showCursorHere ? <span bg={theme.primary}> </span> : null}
597
+ </text>
598
+ </box>
599
+ );
600
+ })}
601
+ </box>
602
+ );
603
+ }
604
+
605
+ function StringContent({
606
+ theme,
607
+ value,
608
+ placeholder,
609
+ focused,
610
+ }: {
611
+ theme: PickerTheme;
612
+ value: string;
613
+ placeholder: string;
614
+ focused: boolean;
615
+ }) {
616
+ const isEmpty = value === "";
617
+
618
+ if (isEmpty) {
619
+ return (
620
+ <box height={1} flexDirection="row">
621
+ <PlaceholderWithCursor
622
+ theme={theme}
623
+ placeholder={placeholder}
624
+ focused={focused}
625
+ />
626
+ </box>
627
+ );
628
+ }
629
+
630
+ return (
631
+ <box height={1} flexDirection="row">
632
+ <text>
633
+ <span fg={theme.text}>{value}</span>
634
+ {focused ? <span bg={theme.primary}> </span> : null}
635
+ </text>
636
+ </box>
637
+ );
638
+ }
639
+
640
+ function EnumContent({
641
+ theme,
642
+ values,
643
+ selected,
644
+ focused,
645
+ }: {
646
+ theme: PickerTheme;
647
+ values: string[];
648
+ selected: string;
649
+ focused: boolean;
650
+ }) {
651
+ return (
652
+ <box height={1} flexDirection="row">
653
+ {values.map((v, i) => {
654
+ const isSelected = v === selected;
655
+ const marker = isSelected ? "●" : "○";
656
+ const markerColor = isSelected
657
+ ? focused
658
+ ? theme.primary
659
+ : theme.success
660
+ : theme.textDim;
661
+ const textColor = isSelected
662
+ ? focused
663
+ ? theme.text
664
+ : theme.textMuted
665
+ : theme.textDim;
666
+ return (
667
+ <box
668
+ key={v}
669
+ flexDirection="row"
670
+ paddingLeft={i > 0 ? 3 : 0}
671
+ height={1}
672
+ >
673
+ <text>
674
+ <span fg={markerColor}>{marker} </span>
675
+ <span fg={textColor}>{v}</span>
676
+ </text>
677
+ </box>
678
+ );
679
+ })}
680
+ </box>
681
+ );
682
+ }
683
+
684
+ function Field({
685
+ theme,
686
+ field,
687
+ value,
688
+ focused,
689
+ }: {
690
+ theme: PickerTheme;
691
+ field: WorkflowInput;
692
+ value: string;
693
+ focused: boolean;
694
+ }) {
695
+ const borderCol = focused ? theme.primary : theme.border;
696
+ const bgCol = focused ? theme.backgroundPanel : theme.backgroundElement;
697
+
698
+ const boxHeight = field.type === "text" ? TEXT_FIELD_LINES + 2 : 3;
699
+
700
+ const tagCol = field.required ? theme.warning : theme.textDim;
701
+ const tagLabel = field.required ? "required" : "optional";
702
+ const captionDesc = field.description ? " · " + field.description : "";
703
+
704
+ return (
705
+ <box flexDirection="column">
706
+ <box
707
+ border
708
+ borderStyle="rounded"
709
+ borderColor={borderCol}
710
+ backgroundColor={bgCol}
711
+ flexDirection="column"
712
+ paddingLeft={2}
713
+ paddingRight={2}
714
+ height={boxHeight}
715
+ justifyContent={field.type === "text" ? "flex-start" : "center"}
716
+ title={` ${field.name} `}
717
+ titleAlignment="left"
718
+ >
719
+ {field.type === "text" ? (
720
+ <TextAreaContent
721
+ theme={theme}
722
+ value={value}
723
+ placeholder={field.placeholder ?? ""}
724
+ focused={focused}
725
+ lines={TEXT_FIELD_LINES}
726
+ />
727
+ ) : field.type === "string" ? (
728
+ <StringContent
729
+ theme={theme}
730
+ value={value}
731
+ placeholder={field.placeholder ?? ""}
732
+ focused={focused}
733
+ />
734
+ ) : field.type === "enum" ? (
735
+ <EnumContent
736
+ theme={theme}
737
+ values={field.values ?? []}
738
+ selected={value}
739
+ focused={focused}
740
+ />
741
+ ) : null}
742
+ </box>
743
+
744
+ <box paddingLeft={2} paddingRight={2} height={1}>
745
+ <text>
746
+ <span fg={theme.textDim}>{field.type}</span>
747
+ <span fg={theme.textDim}>{" · "}</span>
748
+ <span fg={tagCol}>{tagLabel}</span>
749
+ <span fg={theme.textDim}>{captionDesc}</span>
750
+ </text>
751
+ </box>
752
+
753
+ <box height={1} />
754
+ </box>
755
+ );
756
+ }
757
+
758
+ function InputPhase({
759
+ theme,
760
+ workflow,
761
+ agent,
762
+ fields,
763
+ values,
764
+ focusedFieldIdx,
765
+ }: {
766
+ theme: PickerTheme;
767
+ workflow: WorkflowWithMetadata;
768
+ agent: AgentType;
769
+ fields: WorkflowInput[];
770
+ values: Record<string, string>;
771
+ focusedFieldIdx: number;
772
+ }) {
773
+ const isStructured = workflow.inputs.length > 0;
774
+
775
+ return (
776
+ <box
777
+ flexDirection="column"
778
+ paddingLeft={3}
779
+ paddingRight={3}
780
+ paddingTop={2}
781
+ flexGrow={1}
782
+ >
783
+ <box
784
+ border
785
+ borderStyle="rounded"
786
+ borderColor={theme.border}
787
+ backgroundColor={theme.backgroundPanel}
788
+ flexDirection="column"
789
+ paddingLeft={2}
790
+ paddingRight={2}
791
+ paddingTop={1}
792
+ paddingBottom={1}
793
+ >
794
+ <text>
795
+ <span fg={theme.primary}>
796
+ <strong>▸ </strong>
797
+ </span>
798
+ <span fg={theme.text}>
799
+ <strong>{workflow.name}</strong>
800
+ </span>
801
+ <span fg={theme.textDim}>{" · "}</span>
802
+ <span fg={theme.mauve}>{agent}</span>
803
+ <span fg={theme.textDim}>{" · "}</span>
804
+ <span fg={theme[SOURCE_COLOR[workflow.source as Source]]}>
805
+ {SOURCE_DISPLAY[workflow.source as Source]}
806
+ </span>
807
+ <span fg={theme.textDim}>
808
+ {" (" + SOURCE_DIR[workflow.source as Source] + ")"}
809
+ </span>
810
+ </text>
811
+ <box height={1} />
812
+ <text>
813
+ <span fg={theme.textMuted}>
814
+ {workflow.description || "(no description)"}
815
+ </span>
816
+ </text>
817
+ </box>
818
+
819
+ <box height={2} />
820
+
821
+ <box flexDirection="row" height={1}>
822
+ <text>
823
+ <span fg={theme.textDim}>
824
+ <strong>{isStructured ? "INPUTS" : "PROMPT"}</strong>
825
+ </span>
826
+ </text>
827
+ <box flexGrow={1} />
828
+ <text>
829
+ <span fg={theme.textDim}>
830
+ {isStructured ? `${focusedFieldIdx + 1} / ${fields.length}` : ""}
831
+ </span>
832
+ </text>
833
+ </box>
834
+ <box height={1} />
835
+
836
+ {fields.map((f, i) => (
837
+ <Field
838
+ key={f.name}
839
+ theme={theme}
840
+ field={f}
841
+ value={values[f.name] ?? ""}
842
+ focused={i === focusedFieldIdx}
843
+ />
844
+ ))}
845
+ </box>
846
+ );
847
+ }
848
+
849
+ function ConfirmModal({
850
+ theme,
851
+ workflow,
852
+ agent,
853
+ }: {
854
+ theme: PickerTheme;
855
+ workflow: WorkflowWithMetadata;
856
+ agent: AgentType;
857
+ }) {
858
+ return (
859
+ <box
860
+ position="absolute"
861
+ left={0}
862
+ top={0}
863
+ width="100%"
864
+ height="100%"
865
+ justifyContent="center"
866
+ alignItems="center"
867
+ zIndex={100}
868
+ >
869
+ <box
870
+ border
871
+ borderStyle="rounded"
872
+ borderColor={theme.success}
873
+ backgroundColor={theme.backgroundPanel}
874
+ flexDirection="column"
875
+ paddingLeft={3}
876
+ paddingRight={3}
877
+ paddingTop={1}
878
+ paddingBottom={1}
879
+ title=" ready to run "
880
+ titleAlignment="center"
881
+ >
882
+ {/* Header — the form the user just filled in already shows the
883
+ workflow name, agent, and field values, so the modal stays
884
+ minimal and focuses on the submit/cancel question rather
885
+ than restating the full invocation. */}
886
+ <text>
887
+ <span fg={theme.success}>
888
+ <strong>✓ </strong>
889
+ </span>
890
+ <span fg={theme.text}>
891
+ <strong>{workflow.name}</strong>
892
+ </span>
893
+ <span fg={theme.textDim}>{" · "}</span>
894
+ <span fg={theme.mauve}>{agent}</span>
895
+ </text>
896
+
897
+ <box height={1} />
898
+
899
+ <text>
900
+ <span fg={theme.textDim}>
901
+ submit and run this workflow?{" "}
902
+ </span>
903
+ <span fg={theme.success}>
904
+ <strong>y</strong>
905
+ </span>
906
+ <span fg={theme.textDim}> submit · </span>
907
+ <span fg={theme.error}>
908
+ <strong>n</strong>
909
+ </span>
910
+ <span fg={theme.textDim}> cancel</span>
911
+ </text>
912
+ </box>
913
+ </box>
914
+ );
915
+ }
916
+
917
+ // Per-agent brand color used as the Header pill background.
918
+ const AGENT_PILL_COLOR: Record<AgentType, keyof PickerTheme> = {
919
+ claude: "warning",
920
+ copilot: "success",
921
+ opencode: "mauve",
922
+ };
923
+
924
+ function Header({
925
+ theme,
926
+ phase,
927
+ confirmOpen,
928
+ selectedAgent,
929
+ scopedCount,
930
+ }: {
931
+ theme: PickerTheme;
932
+ phase: Phase;
933
+ confirmOpen: boolean;
934
+ selectedAgent: AgentType;
935
+ scopedCount: number;
936
+ }) {
937
+ const phaseLabel = confirmOpen
938
+ ? "confirm"
939
+ : phase === "pick"
940
+ ? "select"
941
+ : "compose";
942
+ const pillBg = theme[AGENT_PILL_COLOR[selectedAgent]];
943
+
944
+ return (
945
+ <box
946
+ height={1}
947
+ backgroundColor={theme.surface}
948
+ flexDirection="row"
949
+ paddingRight={2}
950
+ alignItems="center"
951
+ >
952
+ <text>
953
+ <span fg={theme.surface} bg={pillBg}>
954
+ <strong>{" " + selectedAgent.toUpperCase() + " "}</strong>
955
+ </span>
956
+ </text>
957
+ <text>
958
+ <span fg={theme.textDim}>{" workflow "}</span>
959
+ </text>
960
+ <text>
961
+ <span fg={theme.textMuted}>›</span>
962
+ </text>
963
+ <text>
964
+ <span fg={theme.textDim}>{" " + phaseLabel}</span>
965
+ </text>
966
+ <box flexGrow={1} />
967
+ <text>
968
+ <span fg={theme.textDim}>
969
+ {scopedCount + (scopedCount === 1 ? " workflow" : " workflows")}
970
+ </span>
971
+ </text>
972
+ </box>
973
+ );
974
+ }
975
+
976
+ function Statusline({
977
+ theme,
978
+ phase,
979
+ confirmOpen,
980
+ hints,
981
+ focusedWf,
982
+ }: {
983
+ theme: PickerTheme;
984
+ phase: Phase;
985
+ confirmOpen: boolean;
986
+ hints: { key: string; label: string; dim?: boolean }[];
987
+ focusedWf: WorkflowWithMetadata | undefined;
988
+ }) {
989
+ const modeLabel = confirmOpen
990
+ ? "CONFIRM"
991
+ : phase === "pick"
992
+ ? "PICK"
993
+ : "PROMPT";
994
+ const modeColor = confirmOpen
995
+ ? theme.mauve
996
+ : phase === "pick"
997
+ ? theme.primary
998
+ : theme.success;
999
+
1000
+ return (
1001
+ <box height={1} flexDirection="row" backgroundColor={theme.surface}>
1002
+ <box
1003
+ backgroundColor={modeColor}
1004
+ paddingLeft={1}
1005
+ paddingRight={1}
1006
+ alignItems="center"
1007
+ >
1008
+ <text fg={theme.surface}>
1009
+ <strong>{modeLabel}</strong>
1010
+ </text>
1011
+ </box>
1012
+
1013
+ {focusedWf ? (
1014
+ <box paddingLeft={1} paddingRight={1} alignItems="center">
1015
+ <text>
1016
+ <span fg={theme.text}>{focusedWf.name}</span>
1017
+ </text>
1018
+ </box>
1019
+ ) : null}
1020
+
1021
+ <box flexGrow={1} />
1022
+
1023
+ <box paddingRight={2} alignItems="center" flexDirection="row">
1024
+ {hints.map((h, i) => (
1025
+ <box key={i} flexDirection="row">
1026
+ {i > 0 ? (
1027
+ <text>
1028
+ <span fg={theme.textDim}>{" · "}</span>
1029
+ </text>
1030
+ ) : null}
1031
+ <text>
1032
+ <span fg={h.dim ? theme.textDim : theme.text}>{h.key}</span>
1033
+ <span fg={h.dim ? theme.textDim : theme.textMuted}>
1034
+ {" " + h.label}
1035
+ </span>
1036
+ </text>
1037
+ </box>
1038
+ ))}
1039
+ </box>
1040
+ </box>
1041
+ );
1042
+ }
1043
+
1044
+ // ─── App ────────────────────────────────────────
1045
+
1046
+ interface PickerAppProps {
1047
+ theme: PickerTheme;
1048
+ agent: AgentType;
1049
+ workflows: WorkflowWithMetadata[];
1050
+ onSubmit: (result: WorkflowPickerResult) => void;
1051
+ onCancel: () => void;
1052
+ }
1053
+
1054
+ export function WorkflowPicker({
1055
+ theme,
1056
+ agent,
1057
+ workflows,
1058
+ onSubmit,
1059
+ onCancel,
1060
+ }: PickerAppProps) {
1061
+ const [phase, setPhase] = useState<Phase>("pick");
1062
+ const [query, setQuery] = useState("");
1063
+ const [entryIdx, setEntryIdx] = useState(0);
1064
+ const [fieldValues, setFieldValues] = useState<Record<string, string>>({});
1065
+ const [focusedFieldIdx, setFocusedFieldIdx] = useState(0);
1066
+ const [confirmOpen, setConfirmOpen] = useState(false);
1067
+
1068
+ // Note: the cursor is rendered as a steady full-cell block rather
1069
+ // than a blinking caret. Blinking was causing the caret cell to
1070
+ // flash visibly every 530ms, which read as a "text block flashing"
1071
+ // bug on top of the already-jarring thickness change that used to
1072
+ // happen when switching between placeholder and typed-text cursors.
1073
+ // Both issues go away with a stable caret.
1074
+
1075
+ const entries = useMemo(() => buildEntries(query, workflows), [query, workflows]);
1076
+ const rows = useMemo(() => buildRows(entries, query), [entries, query]);
1077
+
1078
+ useEffect(() => {
1079
+ if (entryIdx >= entries.length) {
1080
+ setEntryIdx(Math.max(0, entries.length - 1));
1081
+ }
1082
+ }, [entries.length, entryIdx]);
1083
+
1084
+ const focusedWf = entries[entryIdx]?.workflow;
1085
+
1086
+ const currentFields: WorkflowInput[] =
1087
+ focusedWf && focusedWf.inputs.length > 0
1088
+ ? [...focusedWf.inputs]
1089
+ : [DEFAULT_PROMPT_INPUT];
1090
+ const currentField = currentFields[focusedFieldIdx];
1091
+
1092
+ const invalidFieldIndices = useMemo(() => {
1093
+ const out: number[] = [];
1094
+ for (let i = 0; i < currentFields.length; i++) {
1095
+ const f = currentFields[i]!;
1096
+ const v = fieldValues[f.name] ?? "";
1097
+ if (!isFieldValid(f, v)) out.push(i);
1098
+ }
1099
+ return out;
1100
+ }, [currentFields, fieldValues]);
1101
+ const isFormValid = invalidFieldIndices.length === 0;
1102
+
1103
+ useKeyboard((key) => {
1104
+ if (key.ctrl && key.name === "c") {
1105
+ onCancel();
1106
+ return;
1107
+ }
1108
+
1109
+ if (confirmOpen) {
1110
+ if (key.name === "y" || key.name === "return") {
1111
+ if (!focusedWf) return;
1112
+ onSubmit({ workflow: focusedWf, inputs: { ...fieldValues } });
1113
+ return;
1114
+ }
1115
+ if (key.name === "n" || key.name === "escape") {
1116
+ setConfirmOpen(false);
1117
+ return;
1118
+ }
1119
+ return;
1120
+ }
1121
+
1122
+ if (phase === "pick") {
1123
+ if (key.name === "escape") {
1124
+ onCancel();
1125
+ return;
1126
+ }
1127
+ if (key.name === "up" || (key.ctrl && key.name === "k")) {
1128
+ setEntryIdx((i: number) => Math.max(0, i - 1));
1129
+ return;
1130
+ }
1131
+ if (key.name === "down" || (key.ctrl && key.name === "j")) {
1132
+ setEntryIdx((i: number) =>
1133
+ Math.min(entries.length - 1, i + 1),
1134
+ );
1135
+ return;
1136
+ }
1137
+ if (key.name === "return") {
1138
+ if (focusedWf) {
1139
+ const inputs: WorkflowInput[] =
1140
+ focusedWf.inputs.length > 0
1141
+ ? [...focusedWf.inputs]
1142
+ : [DEFAULT_PROMPT_INPUT];
1143
+ const initial: Record<string, string> = {};
1144
+ for (const f of inputs) {
1145
+ initial[f.name] =
1146
+ f.default ??
1147
+ (f.type === "enum" ? (f.values?.[0] ?? "") : "");
1148
+ }
1149
+ setFieldValues(initial);
1150
+ setFocusedFieldIdx(0);
1151
+ setPhase("prompt");
1152
+ }
1153
+ return;
1154
+ }
1155
+ if (key.name === "backspace") {
1156
+ setQuery((q: string) => q.slice(0, -1));
1157
+ return;
1158
+ }
1159
+ if (
1160
+ key.sequence &&
1161
+ key.sequence.length === 1 &&
1162
+ !key.ctrl &&
1163
+ !key.meta
1164
+ ) {
1165
+ const c = key.sequence;
1166
+ if (c >= " " && c <= "~") setQuery((q: string) => q + c);
1167
+ }
1168
+ return;
1169
+ }
1170
+
1171
+ // ── PROMPT phase ──
1172
+ if (key.name === "escape") {
1173
+ setPhase("pick");
1174
+ return;
1175
+ }
1176
+ if (key.ctrl && key.name === "s") {
1177
+ if (!isFormValid) {
1178
+ setFocusedFieldIdx(invalidFieldIndices[0]!);
1179
+ return;
1180
+ }
1181
+ setConfirmOpen(true);
1182
+ return;
1183
+ }
1184
+ if (key.name === "tab") {
1185
+ setFocusedFieldIdx((i: number) => {
1186
+ const len = currentFields.length;
1187
+ if (len <= 1) return 0;
1188
+ return key.shift ? (i - 1 + len) % len : (i + 1) % len;
1189
+ });
1190
+ return;
1191
+ }
1192
+ if (!currentField) return;
1193
+
1194
+ if (currentField.type === "enum") {
1195
+ const values = currentField.values ?? [];
1196
+ if (values.length === 0) return;
1197
+ if (key.name === "left" || key.name === "right") {
1198
+ setFieldValues((prev: Record<string, string>) => {
1199
+ const cur = prev[currentField.name] ?? values[0] ?? "";
1200
+ const idx = Math.max(0, values.indexOf(cur));
1201
+ const delta = key.name === "left" ? -1 : 1;
1202
+ const nextIdx = (idx + delta + values.length) % values.length;
1203
+ return { ...prev, [currentField.name]: values[nextIdx] ?? "" };
1204
+ });
1205
+ }
1206
+ return;
1207
+ }
1208
+
1209
+ if (key.name === "return") {
1210
+ if (currentField.type === "text") {
1211
+ setFieldValues((prev: Record<string, string>) => ({
1212
+ ...prev,
1213
+ [currentField.name]: (prev[currentField.name] ?? "") + "\n",
1214
+ }));
1215
+ } else {
1216
+ setFocusedFieldIdx((i: number) =>
1217
+ Math.min(currentFields.length - 1, i + 1),
1218
+ );
1219
+ }
1220
+ return;
1221
+ }
1222
+ if (key.name === "backspace") {
1223
+ setFieldValues((prev: Record<string, string>) => ({
1224
+ ...prev,
1225
+ [currentField.name]: (prev[currentField.name] ?? "").slice(0, -1),
1226
+ }));
1227
+ return;
1228
+ }
1229
+ if (
1230
+ key.sequence &&
1231
+ key.sequence.length === 1 &&
1232
+ !key.ctrl &&
1233
+ !key.meta
1234
+ ) {
1235
+ const c = key.sequence;
1236
+ if (c >= " " && c <= "~") {
1237
+ setFieldValues((prev: Record<string, string>) => ({
1238
+ ...prev,
1239
+ [currentField.name]: (prev[currentField.name] ?? "") + c,
1240
+ }));
1241
+ }
1242
+ }
1243
+ });
1244
+
1245
+ const pickHints = [
1246
+ { key: "↑↓", label: "navigate" },
1247
+ { key: "↵", label: "select" },
1248
+ { key: "esc", label: "quit" },
1249
+ ];
1250
+ const promptHints = [
1251
+ { key: "tab", label: "to navigate forward" },
1252
+ { key: "shift+tab", label: "to navigate backward" },
1253
+ { key: "ctrl+s", label: "to run", dim: !isFormValid },
1254
+ ];
1255
+ const confirmHints = [
1256
+ { key: "y", label: "submit" },
1257
+ { key: "n", label: "cancel" },
1258
+ ];
1259
+
1260
+ const hints = confirmOpen
1261
+ ? confirmHints
1262
+ : phase === "pick"
1263
+ ? pickHints
1264
+ : promptHints;
1265
+
1266
+ return (
1267
+ <box
1268
+ position="relative"
1269
+ width="100%"
1270
+ height="100%"
1271
+ flexDirection="column"
1272
+ backgroundColor={theme.background}
1273
+ >
1274
+ <Header
1275
+ theme={theme}
1276
+ phase={phase}
1277
+ confirmOpen={confirmOpen}
1278
+ selectedAgent={agent}
1279
+ scopedCount={workflows.length}
1280
+ />
1281
+
1282
+ {phase === "pick" ? (
1283
+ <box
1284
+ flexGrow={1}
1285
+ flexDirection="row"
1286
+ paddingLeft={2}
1287
+ paddingRight={2}
1288
+ paddingTop={1}
1289
+ >
1290
+ <box width={36} flexDirection="column">
1291
+ <FilterBar theme={theme} query={query} />
1292
+ <box height={1} />
1293
+ <WorkflowList
1294
+ theme={theme}
1295
+ rows={rows}
1296
+ focusedEntryIdx={entryIdx}
1297
+ />
1298
+ </box>
1299
+ <box width={1} backgroundColor={theme.border} />
1300
+ <box flexGrow={1} flexDirection="column">
1301
+ {focusedWf ? (
1302
+ <Preview theme={theme} wf={focusedWf} />
1303
+ ) : (
1304
+ <EmptyPreview theme={theme} query={query} />
1305
+ )}
1306
+ </box>
1307
+ </box>
1308
+ ) : phase === "prompt" && focusedWf ? (
1309
+ <InputPhase
1310
+ theme={theme}
1311
+ workflow={focusedWf}
1312
+ agent={agent}
1313
+ fields={currentFields}
1314
+ values={fieldValues}
1315
+ focusedFieldIdx={focusedFieldIdx}
1316
+ />
1317
+ ) : null}
1318
+
1319
+ <Statusline
1320
+ theme={theme}
1321
+ phase={phase}
1322
+ confirmOpen={confirmOpen}
1323
+ hints={hints}
1324
+ focusedWf={focusedWf}
1325
+ />
1326
+
1327
+ {confirmOpen && focusedWf ? (
1328
+ <ConfirmModal theme={theme} workflow={focusedWf} agent={agent} />
1329
+ ) : null}
1330
+ </box>
1331
+ );
1332
+ }
1333
+
1334
+ // ─── Public class API ──────────────────────────
1335
+
1336
+ export interface WorkflowPickerPanelOptions {
1337
+ agent: AgentType;
1338
+ /** Pre-loaded workflows to show. Must already be filtered to `agent`. */
1339
+ workflows: WorkflowWithMetadata[];
1340
+ }
1341
+
1342
+ /**
1343
+ * Imperative shell around the React picker tree — mirrors the
1344
+ * {@link OrchestratorPanel} lifecycle so both panels can be used
1345
+ * interchangeably by the CLI command layer.
1346
+ */
1347
+ export class WorkflowPickerPanel {
1348
+ private renderer: CliRenderer;
1349
+ private root: Root;
1350
+ private destroyed = false;
1351
+ private resolveSelection: ((r: WorkflowPickerResult | null) => void) | null =
1352
+ null;
1353
+ private selectionPromise: Promise<WorkflowPickerResult | null>;
1354
+
1355
+ private constructor(
1356
+ renderer: CliRenderer,
1357
+ options: WorkflowPickerPanelOptions,
1358
+ ) {
1359
+ this.renderer = renderer;
1360
+ this.selectionPromise = new Promise((resolve) => {
1361
+ this.resolveSelection = resolve;
1362
+ });
1363
+
1364
+ const theme = buildPickerTheme(resolveTheme(renderer.themeMode));
1365
+ this.root = createRoot(renderer);
1366
+ this.root.render(
1367
+ <ErrorBoundary
1368
+ fallback={(err) => (
1369
+ <box
1370
+ width="100%"
1371
+ height="100%"
1372
+ justifyContent="center"
1373
+ alignItems="center"
1374
+ backgroundColor={theme.background}
1375
+ >
1376
+ <text>
1377
+ <span fg={theme.error}>
1378
+ {`Fatal render error: ${err.message}`}
1379
+ </span>
1380
+ </text>
1381
+ </box>
1382
+ )}
1383
+ >
1384
+ <WorkflowPicker
1385
+ theme={theme}
1386
+ agent={options.agent}
1387
+ workflows={options.workflows}
1388
+ onSubmit={(result) => this.handleSubmit(result)}
1389
+ onCancel={() => this.handleCancel()}
1390
+ />
1391
+ </ErrorBoundary>,
1392
+ );
1393
+ }
1394
+
1395
+ /**
1396
+ * Create a new WorkflowPickerPanel. Initialises a CLI renderer and
1397
+ * mounts the interactive tree. The caller should `await
1398
+ * waitForSelection()` and then call `destroy()` regardless of outcome.
1399
+ */
1400
+ static async create(
1401
+ options: WorkflowPickerPanelOptions,
1402
+ ): Promise<WorkflowPickerPanel> {
1403
+ const renderer = await createCliRenderer({
1404
+ exitOnCtrlC: false,
1405
+ exitSignals: [
1406
+ "SIGTERM",
1407
+ "SIGQUIT",
1408
+ "SIGABRT",
1409
+ "SIGHUP",
1410
+ "SIGPIPE",
1411
+ "SIGBUS",
1412
+ "SIGFPE",
1413
+ ],
1414
+ });
1415
+ return new WorkflowPickerPanel(renderer, options);
1416
+ }
1417
+
1418
+ /** Create with an externally-provided renderer (e.g. a test renderer). */
1419
+ static createWithRenderer(
1420
+ renderer: CliRenderer,
1421
+ options: WorkflowPickerPanelOptions,
1422
+ ): WorkflowPickerPanel {
1423
+ return new WorkflowPickerPanel(renderer, options);
1424
+ }
1425
+
1426
+ /**
1427
+ * Resolve with the user's selection once they confirm, or `null` if
1428
+ * they exit the picker without committing. Idempotent — subsequent
1429
+ * calls return the same promise.
1430
+ */
1431
+ waitForSelection(): Promise<WorkflowPickerResult | null> {
1432
+ return this.selectionPromise;
1433
+ }
1434
+
1435
+ /** Tear down the CLI renderer. Idempotent. */
1436
+ destroy(): void {
1437
+ if (this.destroyed) return;
1438
+ this.destroyed = true;
1439
+ // Ensure anyone still awaiting the selection promise is released.
1440
+ if (this.resolveSelection) {
1441
+ this.resolveSelection(null);
1442
+ this.resolveSelection = null;
1443
+ }
1444
+ try {
1445
+ this.renderer.destroy();
1446
+ } catch {}
1447
+ }
1448
+
1449
+ private handleSubmit(result: WorkflowPickerResult): void {
1450
+ if (this.resolveSelection) {
1451
+ this.resolveSelection(result);
1452
+ this.resolveSelection = null;
1453
+ }
1454
+ }
1455
+
1456
+ private handleCancel(): void {
1457
+ if (this.resolveSelection) {
1458
+ this.resolveSelection(null);
1459
+ this.resolveSelection = null;
1460
+ }
1461
+ }
1462
+ }