@bastani/atomic 0.5.12-0 → 0.5.12-2

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bastani/atomic",
3
- "version": "0.5.12-0",
3
+ "version": "0.5.12-2",
4
4
  "description": "Configuration management CLI and SDK for coding agents",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -10,7 +10,7 @@
10
10
  * declared `WorkflowInput`). Free-form workflows fall back to
11
11
  * a single `prompt` text field.
12
12
  *
13
- * Pressing ⌃s in the prompt phase validates required fields and opens a
13
+ * Pressing ⌃d in the prompt phase validates required fields and opens a
14
14
  * CONFIRM modal that shows the fully-composed shell command before
15
15
  * submission. y/↵ confirms, n/esc cancels back to the form.
16
16
  *
@@ -29,6 +29,7 @@
29
29
  import {
30
30
  createCliRenderer,
31
31
  type CliRenderer,
32
+ type KeyEvent,
32
33
  type TextareaRenderable,
33
34
  } from "@opentui/core";
34
35
  import {
@@ -36,7 +37,7 @@ import {
36
37
  useKeyboard,
37
38
  type Root,
38
39
  } from "@opentui/react";
39
- import { useState, useEffect, useMemo, useRef, useCallback } from "react";
40
+ import { useState, useEffect, useMemo, useRef, useCallback, useContext, createContext, memo } from "react";
40
41
  import { useLatest } from "./hooks.ts";
41
42
  import { resolveTheme, type TerminalTheme } from "../runtime/theme.ts";
42
43
  import type { AgentType, WorkflowInput } from "../types.ts";
@@ -67,13 +68,12 @@ export interface PickerTheme {
67
68
  borderActive: string;
68
69
  }
69
70
 
70
- export function buildPickerTheme(base: TerminalTheme): PickerTheme {
71
+ export function buildPickerTheme(base: TerminalTheme, isDark: boolean): PickerTheme {
71
72
  // For dark mode the prototype values track Catppuccin Mocha. For light
72
73
  // mode we derive muted variants from the base palette — the specific
73
74
  // extras (`info`, `mauve`, the three-level background ladder) have no
74
75
  // direct entries in `TerminalTheme`, so we pick close-enough Catppuccin
75
76
  // values to keep the picker visually consistent with the orchestrator.
76
- const isDark = base.bg !== "#eff1f5";
77
77
  return {
78
78
  background: base.bg,
79
79
  backgroundPanel: isDark ? "#181825" : "#e6e9ef",
@@ -93,6 +93,17 @@ export function buildPickerTheme(base: TerminalTheme): PickerTheme {
93
93
  };
94
94
  }
95
95
 
96
+ // ─── Theme Context ─────────────────────────────
97
+ // Avoids drilling `theme` through every component in the tree.
98
+
99
+ const PickerThemeContext = createContext<PickerTheme | null>(null);
100
+
101
+ function usePickerTheme(): PickerTheme {
102
+ const theme = useContext(PickerThemeContext);
103
+ if (!theme) throw new Error("usePickerTheme must be used within a PickerThemeContext provider");
104
+ return theme;
105
+ }
106
+
96
107
  // ─── Types ──────────────────────────────────────
97
108
 
98
109
  type Source = "local" | "global" | "builtin";
@@ -115,6 +126,10 @@ const DEFAULT_PROMPT_INPUT: WorkflowInput = {
115
126
  placeholder: "describe your task…",
116
127
  };
117
128
 
129
+ /** Stable single-element array for free-form workflows — avoids allocating
130
+ * a new `[DEFAULT_PROMPT_INPUT]` on every useMemo recomputation. */
131
+ const DEFAULT_FIELDS: WorkflowInput[] = [DEFAULT_PROMPT_INPUT];
132
+
118
133
  // ─── Helpers ────────────────────────────────────
119
134
 
120
135
  const SOURCE_DISPLAY: Record<Source, string> = {
@@ -135,6 +150,13 @@ const SOURCE_COLOR: Record<Source, keyof PickerTheme> = {
135
150
  builtin: "info",
136
151
  };
137
152
 
153
+ /** Higher number wins when two workflows share a name. */
154
+ const SOURCE_PRECEDENCE: Record<Source, number> = {
155
+ global: 0,
156
+ local: 1,
157
+ builtin: 2,
158
+ };
159
+
138
160
  /**
139
161
  * Subsequence fuzzy match — Telescope-style. Returns a score (lower =
140
162
  * better) or null for no match. Adjacent matches are rewarded; jumps over
@@ -175,13 +197,31 @@ type ListRow =
175
197
  | { kind: "section"; source: Source }
176
198
  | { kind: "entry"; entry: ListEntry };
177
199
 
200
+ /**
201
+ * Deduplicate workflows by name using builtin > local > global precedence.
202
+ * When two workflows share a name, only the higher-precedence entry is kept.
203
+ */
204
+ export function deduplicateByName(
205
+ workflows: WorkflowWithMetadata[],
206
+ ): WorkflowWithMetadata[] {
207
+ const byName = new Map<string, WorkflowWithMetadata>();
208
+ for (const wf of workflows) {
209
+ const existing = byName.get(wf.name);
210
+ if (!existing || SOURCE_PRECEDENCE[wf.source] > SOURCE_PRECEDENCE[existing.source]) {
211
+ byName.set(wf.name, wf);
212
+ }
213
+ }
214
+ return Array.from(byName.values());
215
+ }
216
+
178
217
  export function buildEntries(
179
218
  query: string,
180
219
  workflows: WorkflowWithMetadata[],
181
220
  ): ListEntry[] {
221
+ const deduped = deduplicateByName(workflows);
182
222
  type Scored = { wf: WorkflowWithMetadata; score: number };
183
223
  const scored: Scored[] = [];
184
- for (const wf of workflows) {
224
+ for (const wf of deduped) {
185
225
  const nameScore = fuzzyMatch(query, wf.name);
186
226
  const descScore = fuzzyMatch(query, wf.description);
187
227
  const best =
@@ -240,13 +280,12 @@ export function isFieldValid(field: WorkflowInput, value: string): boolean {
240
280
 
241
281
  // ─── Components ─────────────────────────────────
242
282
 
243
- function SectionLabel({
244
- theme,
283
+ const SectionLabel = memo(function SectionLabel({
245
284
  label,
246
285
  }: {
247
- theme: PickerTheme;
248
286
  label: string;
249
287
  }) {
288
+ const theme = usePickerTheme();
250
289
  return (
251
290
  <box height={1} flexDirection="row">
252
291
  <text>
@@ -257,19 +296,18 @@ function SectionLabel({
257
296
  </text>
258
297
  </box>
259
298
  );
260
- }
299
+ });
261
300
 
262
301
  function FilterBar({
263
- theme,
264
302
  query,
265
303
  focused,
266
304
  onInput,
267
305
  }: {
268
- theme: PickerTheme;
269
306
  query: string;
270
307
  focused: boolean;
271
308
  onInput: (value: string) => void;
272
309
  }) {
310
+ const theme = usePickerTheme();
273
311
  return (
274
312
  <box
275
313
  minHeight={3}
@@ -301,37 +339,38 @@ function FilterBar({
301
339
  );
302
340
  }
303
341
 
304
- function WorkflowList({
305
- theme,
342
+ const WorkflowList = memo(function WorkflowList({
306
343
  rows,
307
344
  focusedEntryIdx,
308
345
  }: {
309
- theme: PickerTheme;
310
346
  rows: ListRow[];
311
347
  focusedEntryIdx: number;
312
348
  }) {
313
- if (rows.length === 0) {
314
- return (
315
- <box paddingLeft={2} paddingTop={2}>
316
- <text>
317
- <span fg={theme.textDim}>no matches</span>
318
- </text>
319
- </box>
320
- );
321
- }
322
-
349
+ const theme = usePickerTheme();
323
350
  // Pre-compute entry indices so the render pass is side-effect-free.
351
+ // Must live before any early return to satisfy the Rules of Hooks.
324
352
  const entryIndexByRow = useMemo(() => {
325
353
  const map = new Map<number, number>();
326
354
  let counter = 0;
327
355
  for (let i = 0; i < rows.length; i++) {
328
- if (rows[i]!.kind === "entry") {
356
+ const row = rows[i];
357
+ if (row && row.kind === "entry") {
329
358
  map.set(i, counter++);
330
359
  }
331
360
  }
332
361
  return map;
333
362
  }, [rows]);
334
363
 
364
+ if (rows.length === 0) {
365
+ return (
366
+ <box paddingLeft={2} paddingTop={2}>
367
+ <text>
368
+ <span fg={theme.textDim}>no matches</span>
369
+ </text>
370
+ </box>
371
+ );
372
+ }
373
+
335
374
  return (
336
375
  <box flexDirection="column">
337
376
  {rows.map((row, i) => {
@@ -339,7 +378,7 @@ function WorkflowList({
339
378
  const src = row.source;
340
379
  return (
341
380
  <box
342
- key={`s${i}`}
381
+ key={`section-${src}`}
343
382
  height={2}
344
383
  paddingTop={1}
345
384
  paddingLeft={2}
@@ -361,7 +400,7 @@ function WorkflowList({
361
400
 
362
401
  return (
363
402
  <box
364
- key={`e${i}`}
403
+ key={`wf-${wf.name}`}
365
404
  height={1}
366
405
  flexDirection="row"
367
406
  backgroundColor={isFocused ? theme.border : "transparent"}
@@ -381,20 +420,21 @@ function WorkflowList({
381
420
  })}
382
421
  </box>
383
422
  );
384
- }
423
+ });
385
424
 
386
- function ArgumentRow({
387
- theme,
425
+ const ArgumentRow = memo(function ArgumentRow({
388
426
  field,
389
427
  }: {
390
- theme: PickerTheme;
391
428
  field: WorkflowInput;
392
429
  }) {
430
+ const theme = usePickerTheme();
393
431
  const isRequired = field.required ?? false;
394
432
  const tagCol = isRequired ? theme.warning : theme.textDim;
395
433
  const tagLabel = isRequired ? "required" : "optional";
396
- const showEnumValues =
397
- field.type === "enum" && field.values && field.values.length > 0;
434
+ const enumValues =
435
+ field.type === "enum" && field.values && field.values.length > 0
436
+ ? field.values
437
+ : null;
398
438
 
399
439
  return (
400
440
  <box flexDirection="column" paddingLeft={2} paddingRight={2}>
@@ -418,10 +458,10 @@ function ArgumentRow({
418
458
  </box>
419
459
  ) : null}
420
460
 
421
- {showEnumValues ? (
461
+ {enumValues ? (
422
462
  <box height={1}>
423
463
  <text>
424
- <span fg={theme.textDim}>{field.values!.join(" · ")}</span>
464
+ <span fg={theme.textDim}>{enumValues.join(" · ")}</span>
425
465
  </text>
426
466
  </box>
427
467
  ) : null}
@@ -429,17 +469,16 @@ function ArgumentRow({
429
469
  <box height={1} />
430
470
  </box>
431
471
  );
432
- }
472
+ });
433
473
 
434
- function Preview({
435
- theme,
474
+ const Preview = memo(function Preview({
436
475
  wf,
437
476
  }: {
438
- theme: PickerTheme;
439
477
  wf: WorkflowWithMetadata;
440
478
  }) {
441
- const args: WorkflowInput[] =
442
- wf.inputs.length > 0 ? [...wf.inputs] : [DEFAULT_PROMPT_INPUT];
479
+ const theme = usePickerTheme();
480
+ const args: readonly WorkflowInput[] =
481
+ wf.inputs.length > 0 ? wf.inputs : DEFAULT_FIELDS;
443
482
 
444
483
  return (
445
484
  <box
@@ -475,22 +514,21 @@ function Preview({
475
514
 
476
515
  <box height={2} />
477
516
 
478
- <SectionLabel theme={theme} label="ARGUMENTS" />
517
+ <SectionLabel label="ARGUMENTS" />
479
518
  <box height={1} />
480
519
  {args.map((f) => (
481
- <ArgumentRow key={f.name} theme={theme} field={f} />
520
+ <ArgumentRow key={f.name} field={f} />
482
521
  ))}
483
522
  </box>
484
523
  );
485
- }
524
+ });
486
525
 
487
526
  function EmptyPreview({
488
- theme,
489
527
  query,
490
528
  }: {
491
- theme: PickerTheme;
492
529
  query: string;
493
530
  }) {
531
+ const theme = usePickerTheme();
494
532
  return (
495
533
  <box
496
534
  flexDirection="column"
@@ -529,20 +567,22 @@ function EmptyPreview({
529
567
  const TEXT_FIELD_LINES = 3;
530
568
 
531
569
 
570
+ const NOOP_CHANGE_REF: React.RefObject<((value: string) => void) | null> = { current: null };
571
+
532
572
  function TextAreaContent({
533
- theme,
534
573
  value,
535
574
  placeholder,
536
575
  focused,
537
576
  onChangeRef,
538
577
  }: {
539
- theme: PickerTheme;
540
578
  value: string;
541
579
  placeholder: string;
542
580
  focused: boolean;
543
- onChangeRef: React.RefObject<((value: string) => void) | null>;
581
+ onChangeRef?: React.RefObject<((value: string) => void) | null>;
544
582
  }) {
583
+ const theme = usePickerTheme();
545
584
  const ref = useRef<TextareaRenderable>(null);
585
+ const changeRef = onChangeRef ?? NOOP_CHANGE_REF;
546
586
 
547
587
  // Sync external value → textarea when it diverges (e.g. initial value).
548
588
  useEffect(() => {
@@ -551,18 +591,14 @@ function TextAreaContent({
551
591
  }
552
592
  }, [value]);
553
593
 
554
- // Report changes back to parent via onContentChange.
555
- useEffect(() => {
556
- const ta = ref.current;
557
- if (!ta) return;
558
- ta.onContentChange = () => {
559
- onChangeRef.current?.(ta.plainText);
560
- };
561
- return () => {
562
- ta.onContentChange = undefined;
563
- };
564
- }, [onChangeRef]);
565
-
594
+ // Wire onContentChange as a prop so the reconciler sets it during the
595
+ // commit phase (via the constructor and setProperty's default branch),
596
+ // guaranteeing it is active before the textarea can receive key events.
597
+ // The previous useEffect approach deferred setup as a passive effect
598
+ // (ConcurrentRoot schedules these via setTimeout) creating a window
599
+ // where keystrokes reached the focused textarea before the listener
600
+ // existed, silently dropping content-change notifications and leaving
601
+ // fieldValues stale.
566
602
  return (
567
603
  <textarea
568
604
  ref={ref}
@@ -576,23 +612,25 @@ function TextAreaContent({
576
612
  placeholderColor={theme.textDim}
577
613
  wrapMode="word"
578
614
  flexGrow={1}
615
+ onContentChange={() => {
616
+ changeRef.current?.(ref.current?.plainText ?? "");
617
+ }}
579
618
  />
580
619
  );
581
620
  }
582
621
 
583
622
  function StringContent({
584
- theme,
585
623
  value,
586
624
  placeholder,
587
625
  focused,
588
626
  onInput,
589
627
  }: {
590
- theme: PickerTheme;
591
628
  value: string;
592
629
  placeholder: string;
593
630
  focused: boolean;
594
631
  onInput: (value: string) => void;
595
632
  }) {
633
+ const theme = usePickerTheme();
596
634
  return (
597
635
  <input
598
636
  value={value}
@@ -609,16 +647,15 @@ function StringContent({
609
647
  }
610
648
 
611
649
  function EnumContent({
612
- theme,
613
650
  values,
614
651
  selected,
615
652
  focused,
616
653
  }: {
617
- theme: PickerTheme;
618
654
  values: string[];
619
655
  selected: string;
620
656
  focused: boolean;
621
657
  }) {
658
+ const theme = usePickerTheme();
622
659
  return (
623
660
  <box height={1} flexDirection="row">
624
661
  {values.map((v, i) => {
@@ -652,21 +689,20 @@ function EnumContent({
652
689
  );
653
690
  }
654
691
 
655
- function Field({
656
- theme,
692
+ const Field = memo(function Field({
657
693
  field,
658
694
  value,
659
695
  focused,
660
- onInput,
696
+ onFieldInput,
661
697
  onTextChangeRef,
662
698
  }: {
663
- theme: PickerTheme;
664
699
  field: WorkflowInput;
665
700
  value: string;
666
701
  focused: boolean;
667
- onInput: (value: string) => void;
668
- onTextChangeRef: React.RefObject<((value: string) => void) | null>;
702
+ onFieldInput: (fieldName: string, value: string) => void;
703
+ onTextChangeRef?: React.RefObject<((value: string) => void) | null>;
669
704
  }) {
705
+ const theme = usePickerTheme();
670
706
  const borderCol = focused ? theme.primary : theme.border;
671
707
  const bgCol = focused ? theme.backgroundPanel : theme.backgroundElement;
672
708
 
@@ -676,6 +712,12 @@ function Field({
676
712
  const tagLabel = field.required ? "required" : "optional";
677
713
  const captionDesc = field.description ? " · " + field.description : "";
678
714
 
715
+ // Bind the field name once so the parent doesn't need a per-field closure.
716
+ const onInput = useCallback(
717
+ (v: string) => onFieldInput(field.name, v),
718
+ [onFieldInput, field.name],
719
+ );
720
+
679
721
  return (
680
722
  <box flexDirection="column">
681
723
  <box
@@ -693,7 +735,6 @@ function Field({
693
735
  >
694
736
  {field.type === "text" ? (
695
737
  <TextAreaContent
696
- theme={theme}
697
738
  value={value}
698
739
  placeholder={field.placeholder ?? ""}
699
740
  focused={focused}
@@ -701,7 +742,6 @@ function Field({
701
742
  />
702
743
  ) : field.type === "string" ? (
703
744
  <StringContent
704
- theme={theme}
705
745
  value={value}
706
746
  placeholder={field.placeholder ?? ""}
707
747
  focused={focused}
@@ -709,7 +749,6 @@ function Field({
709
749
  />
710
750
  ) : field.type === "enum" ? (
711
751
  <EnumContent
712
- theme={theme}
713
752
  values={field.values ?? []}
714
753
  selected={value}
715
754
  focused={focused}
@@ -729,10 +768,9 @@ function Field({
729
768
  <box height={1} />
730
769
  </box>
731
770
  );
732
- }
771
+ });
733
772
 
734
773
  function InputPhase({
735
- theme,
736
774
  workflow,
737
775
  agent,
738
776
  fields,
@@ -741,15 +779,15 @@ function InputPhase({
741
779
  onFieldInput,
742
780
  onTextChangeRef,
743
781
  }: {
744
- theme: PickerTheme;
745
782
  workflow: WorkflowWithMetadata;
746
783
  agent: AgentType;
747
- fields: WorkflowInput[];
784
+ fields: readonly WorkflowInput[];
748
785
  values: Record<string, string>;
749
786
  focusedFieldIdx: number;
750
787
  onFieldInput: (fieldName: string, value: string) => void;
751
788
  onTextChangeRef: React.RefObject<((value: string) => void) | null>;
752
789
  }) {
790
+ const theme = usePickerTheme();
753
791
  const isStructured = workflow.inputs.length > 0;
754
792
 
755
793
  return (
@@ -816,12 +854,15 @@ function InputPhase({
816
854
  {fields.map((f, i) => (
817
855
  <Field
818
856
  key={f.name}
819
- theme={theme}
820
857
  field={f}
821
858
  value={values[f.name] ?? ""}
822
859
  focused={i === focusedFieldIdx}
823
- onInput={(v) => onFieldInput(f.name, v)}
824
- onTextChangeRef={onTextChangeRef}
860
+ onFieldInput={onFieldInput}
861
+ onTextChangeRef={
862
+ f.type === "text" && i === focusedFieldIdx
863
+ ? onTextChangeRef
864
+ : undefined
865
+ }
825
866
  />
826
867
  ))}
827
868
  </box>
@@ -829,14 +870,13 @@ function InputPhase({
829
870
  }
830
871
 
831
872
  function ConfirmModal({
832
- theme,
833
873
  workflow,
834
874
  agent,
835
875
  }: {
836
- theme: PickerTheme;
837
876
  workflow: WorkflowWithMetadata;
838
877
  agent: AgentType;
839
878
  }) {
879
+ const theme = usePickerTheme();
840
880
  return (
841
881
  <box
842
882
  position="absolute"
@@ -897,16 +937,28 @@ function ConfirmModal({
897
937
  );
898
938
  }
899
939
 
900
- // Stable hint arrays — no need for useMemo since they never change.
901
- const PICK_HINTS: { key: string; label: string; dim?: boolean }[] = [
940
+ // Stable hint arrays — pre-built so they never create new references.
941
+ type Hint = { key: string; label: string; dim?: boolean };
942
+
943
+ const PICK_HINTS: Hint[] = [
902
944
  { key: "↑↓", label: "navigate" },
903
945
  { key: "↵", label: "select" },
904
946
  { key: "esc", label: "quit" },
905
947
  ];
906
- const CONFIRM_HINTS: { key: string; label: string; dim?: boolean }[] = [
948
+ const CONFIRM_HINTS: Hint[] = [
907
949
  { key: "y", label: "submit" },
908
950
  { key: "n", label: "cancel" },
909
951
  ];
952
+ const PROMPT_HINTS_VALID: Hint[] = [
953
+ { key: "tab", label: "to navigate forward" },
954
+ { key: "shift+tab", label: "to navigate backward" },
955
+ { key: "ctrl+d", label: "to run" },
956
+ ];
957
+ const PROMPT_HINTS_INVALID: Hint[] = [
958
+ { key: "tab", label: "to navigate forward" },
959
+ { key: "shift+tab", label: "to navigate backward" },
960
+ { key: "ctrl+d", label: "to run", dim: true },
961
+ ];
910
962
 
911
963
  // Per-agent brand color used as the Header pill background.
912
964
  const AGENT_PILL_COLOR: Record<AgentType, keyof PickerTheme> = {
@@ -915,19 +967,18 @@ const AGENT_PILL_COLOR: Record<AgentType, keyof PickerTheme> = {
915
967
  opencode: "mauve",
916
968
  };
917
969
 
918
- function Header({
919
- theme,
970
+ const Header = memo(function Header({
920
971
  phase,
921
972
  confirmOpen,
922
973
  selectedAgent,
923
974
  scopedCount,
924
975
  }: {
925
- theme: PickerTheme;
926
976
  phase: Phase;
927
977
  confirmOpen: boolean;
928
978
  selectedAgent: AgentType;
929
979
  scopedCount: number;
930
980
  }) {
981
+ const theme = usePickerTheme();
931
982
  const phaseLabel = confirmOpen
932
983
  ? "confirm"
933
984
  : phase === "pick"
@@ -965,21 +1016,20 @@ function Header({
965
1016
  </text>
966
1017
  </box>
967
1018
  );
968
- }
1019
+ });
969
1020
 
970
- function Statusline({
971
- theme,
1021
+ const Statusline = memo(function Statusline({
972
1022
  phase,
973
1023
  confirmOpen,
974
1024
  hints,
975
1025
  focusedWf,
976
1026
  }: {
977
- theme: PickerTheme;
978
1027
  phase: Phase;
979
1028
  confirmOpen: boolean;
980
1029
  hints: { key: string; label: string; dim?: boolean }[];
981
1030
  focusedWf: WorkflowWithMetadata | undefined;
982
1031
  }) {
1032
+ const theme = usePickerTheme();
983
1033
  const modeLabel = confirmOpen
984
1034
  ? "CONFIRM"
985
1035
  : phase === "pick"
@@ -1033,166 +1083,131 @@ function Statusline({
1033
1083
  </box>
1034
1084
  </box>
1035
1085
  );
1036
- }
1086
+ });
1037
1087
 
1038
- // ─── App ────────────────────────────────────────
1088
+ // ─── Keyboard hook ─────────────────────────────
1039
1089
 
1040
- interface PickerAppProps {
1041
- theme: PickerTheme;
1042
- agent: AgentType;
1043
- workflows: WorkflowWithMetadata[];
1090
+ interface PickerKeyboardState {
1091
+ entries: ListEntry[];
1092
+ clampedEntryIdx: number;
1093
+ savedEntryIdx: number;
1094
+ focusedWf: WorkflowWithMetadata | undefined;
1095
+ fieldValues: Record<string, string>;
1096
+ isFormValid: boolean;
1097
+ invalidFieldIndices: number[];
1098
+ currentFields: readonly WorkflowInput[];
1099
+ currentField: WorkflowInput | undefined;
1100
+ phase: Phase;
1101
+ confirmOpen: boolean;
1044
1102
  onSubmit: (result: WorkflowPickerResult) => void;
1045
1103
  onCancel: () => void;
1104
+ setPhase: (p: Phase) => void;
1105
+ setEntryIdx: React.Dispatch<React.SetStateAction<number>>;
1106
+ setSavedEntryIdx: (i: number) => void;
1107
+ setFieldValues: React.Dispatch<React.SetStateAction<Record<string, string>>>;
1108
+ setFocusedFieldIdx: React.Dispatch<React.SetStateAction<number>>;
1109
+ setConfirmOpen: (open: boolean) => void;
1046
1110
  }
1047
1111
 
1048
- export function WorkflowPicker({
1049
- theme,
1050
- agent,
1051
- workflows,
1052
- onSubmit,
1053
- onCancel,
1054
- }: PickerAppProps) {
1055
- const [phase, setPhase] = useState<Phase>("pick");
1056
- const [query, setQuery] = useState("");
1057
- const [entryIdx, setEntryIdx] = useState(0);
1058
- const [fieldValues, setFieldValues] = useState<Record<string, string>>({});
1059
- const [focusedFieldIdx, setFocusedFieldIdx] = useState(0);
1060
- const [confirmOpen, setConfirmOpen] = useState(false);
1061
-
1062
- // Ref-based callback for textarea change notifications. The ref is
1063
- // stable across renders so the textarea effect doesn't re-attach.
1064
- const textChangeRef = useRef<((value: string) => void) | null>(null);
1065
-
1066
- const entries = useMemo(() => buildEntries(query, workflows), [query, workflows]);
1067
- const rows = useMemo(() => buildRows(entries, query), [entries, query]);
1068
-
1069
- // Clamp index when the list shrinks (e.g. typing filters entries out).
1070
- useEffect(() => {
1071
- setEntryIdx((i) => Math.min(i, Math.max(0, entries.length - 1)));
1072
- }, [entries.length]);
1073
-
1074
- const focusedWf = entries[entryIdx]?.workflow;
1075
-
1076
- const currentFields = useMemo<WorkflowInput[]>(
1077
- () => focusedWf && focusedWf.inputs.length > 0
1078
- ? focusedWf.inputs.slice()
1079
- : [DEFAULT_PROMPT_INPUT],
1080
- [focusedWf],
1081
- );
1082
- const currentField = currentFields[focusedFieldIdx];
1083
-
1084
- const invalidFieldIndices = useMemo(() => {
1085
- const out: number[] = [];
1086
- for (let i = 0; i < currentFields.length; i++) {
1087
- const f = currentFields[i]!;
1088
- const v = fieldValues[f.name] ?? "";
1089
- if (!isFieldValid(f, v)) out.push(i);
1112
+ /**
1113
+ * Encapsulates all keyboard handling for the picker's three phases
1114
+ * (pick, prompt, confirm). Reads state through refs to avoid stale
1115
+ * closures — useKeyboard captures the first callback identity.
1116
+ */
1117
+ function usePickerKeyboard(state: PickerKeyboardState): void {
1118
+ const onSubmitRef = useLatest(state.onSubmit);
1119
+ const onCancelRef = useLatest(state.onCancel);
1120
+ const entriesRef = useLatest(state.entries);
1121
+ const entryIdxRef = useLatest(state.clampedEntryIdx);
1122
+ const savedEntryIdxRef = useLatest(state.savedEntryIdx);
1123
+ const focusedWfRef = useLatest(state.focusedWf);
1124
+ const fieldValuesRef = useLatest(state.fieldValues);
1125
+ const isFormValidRef = useLatest(state.isFormValid);
1126
+ const invalidFieldIndicesRef = useLatest(state.invalidFieldIndices);
1127
+ const currentFieldsRef = useLatest(state.currentFields);
1128
+ const currentFieldRef = useLatest(state.currentField);
1129
+ const phaseRef = useLatest(state.phase);
1130
+ const confirmOpenRef = useLatest(state.confirmOpen);
1131
+
1132
+ const {
1133
+ setPhase,
1134
+ setEntryIdx,
1135
+ setSavedEntryIdx,
1136
+ setFieldValues,
1137
+ setFocusedFieldIdx,
1138
+ setConfirmOpen,
1139
+ } = state;
1140
+
1141
+ const onConfirmKey = useCallback((key: KeyEvent) => {
1142
+ key.stopPropagation();
1143
+ if (key.name === "y" || key.name === "return") {
1144
+ const wf = focusedWfRef.current;
1145
+ if (!wf) return;
1146
+ onSubmitRef.current({ workflow: wf, inputs: { ...fieldValuesRef.current } });
1147
+ return;
1090
1148
  }
1091
- return out;
1092
- }, [currentFields, fieldValues]);
1093
- const isFormValid = invalidFieldIndices.length === 0;
1094
-
1095
- // Wire the textarea change callback so field values stay in sync.
1096
- // The ref is written here (not in a child) so the parent state
1097
- // always reflects the latest textarea content.
1098
- const focusedField = currentField;
1099
- textChangeRef.current = focusedField
1100
- ? (text: string) => {
1101
- setFieldValues((prev) => ({ ...prev, [focusedField.name]: text }));
1102
- }
1103
- : null;
1104
-
1105
- // Stable callback for field input — the setter is referentially stable.
1106
- const onFieldInput = useCallback(
1107
- (name: string, v: string) => setFieldValues((prev) => ({ ...prev, [name]: v })),
1108
- [],
1109
- );
1110
-
1111
- // Stable refs for values read inside the keyboard handler,
1112
- // preventing stale closures when useKeyboard holds the first callback.
1113
- const entriesRef = useLatest(entries);
1114
- const focusedWfRef = useLatest(focusedWf);
1115
- const fieldValuesRef = useLatest(fieldValues);
1116
- const isFormValidRef = useLatest(isFormValid);
1117
- const invalidFieldIndicesRef = useLatest(invalidFieldIndices);
1118
- const currentFieldsRef = useLatest(currentFields);
1119
- const currentFieldRef = useLatest(currentField);
1120
- const phaseRef = useLatest(phase);
1121
- const confirmOpenRef = useLatest(confirmOpen);
1149
+ if (key.name === "n" || key.name === "escape") {
1150
+ setConfirmOpen(false);
1151
+ }
1152
+ }, []);
1122
1153
 
1123
- useKeyboard((key) => {
1124
- if (key.ctrl && key.name === "c") {
1125
- onCancel();
1154
+ const onPickKey = useCallback((key: KeyEvent) => {
1155
+ if (key.name === "escape") {
1156
+ key.stopPropagation();
1157
+ onCancelRef.current();
1126
1158
  return;
1127
1159
  }
1128
-
1129
- if (confirmOpenRef.current) {
1130
- if (key.name === "y" || key.name === "return") {
1131
- const wf = focusedWfRef.current;
1132
- if (!wf) return;
1133
- onSubmit({ workflow: wf, inputs: { ...fieldValuesRef.current } });
1134
- return;
1135
- }
1136
- if (key.name === "n" || key.name === "escape") {
1137
- setConfirmOpen(false);
1138
- return;
1139
- }
1160
+ if (key.name === "up" || (key.ctrl && key.name === "k")) {
1161
+ key.stopPropagation();
1162
+ setEntryIdx(Math.max(0, entryIdxRef.current - 1));
1140
1163
  return;
1141
1164
  }
1142
-
1143
- if (phaseRef.current === "pick") {
1144
- if (key.name === "escape") {
1145
- onCancel();
1146
- return;
1147
- }
1148
- if (key.name === "up" || (key.ctrl && key.name === "k")) {
1149
- setEntryIdx((i: number) => Math.max(0, i - 1));
1150
- return;
1151
- }
1152
- if (key.name === "down" || (key.ctrl && key.name === "j")) {
1153
- setEntryIdx((i: number) =>
1154
- Math.min(entriesRef.current.length - 1, i + 1),
1155
- );
1156
- return;
1157
- }
1158
- if (key.name === "return") {
1159
- const wf = focusedWfRef.current;
1160
- if (wf) {
1161
- const inputs: WorkflowInput[] =
1162
- wf.inputs.length > 0
1163
- ? [...wf.inputs]
1164
- : [DEFAULT_PROMPT_INPUT];
1165
- const initial: Record<string, string> = {};
1166
- for (const f of inputs) {
1167
- initial[f.name] =
1168
- f.default ??
1169
- (f.type === "enum" ? (f.values?.[0] ?? "") : "");
1170
- }
1171
- setFieldValues(initial);
1172
- setFocusedFieldIdx(0);
1173
- setPhase("prompt");
1165
+ if (key.name === "down" || (key.ctrl && key.name === "j")) {
1166
+ key.stopPropagation();
1167
+ setEntryIdx(Math.min(entriesRef.current.length - 1, entryIdxRef.current + 1));
1168
+ return;
1169
+ }
1170
+ if (key.name === "return") {
1171
+ key.stopPropagation();
1172
+ const wf = focusedWfRef.current;
1173
+ if (wf) {
1174
+ const inputs: readonly WorkflowInput[] =
1175
+ wf.inputs.length > 0
1176
+ ? wf.inputs
1177
+ : DEFAULT_FIELDS;
1178
+ const initial: Record<string, string> = {};
1179
+ for (const f of inputs) {
1180
+ initial[f.name] =
1181
+ f.default ??
1182
+ (f.type === "enum" ? (f.values?.[0] ?? "") : "");
1174
1183
  }
1175
- return;
1184
+ setFieldValues(initial);
1185
+ setFocusedFieldIdx(0);
1186
+ setSavedEntryIdx(entryIdxRef.current);
1187
+ setPhase("prompt");
1176
1188
  }
1177
- // All other keys (typing, backspace, arrows) are handled by the
1178
- // native <input> component in the FilterBar.
1179
- return;
1180
1189
  }
1190
+ }, []);
1181
1191
 
1182
- // ── PROMPT phase ──
1192
+ const onPromptKey = useCallback((key: KeyEvent) => {
1183
1193
  if (key.name === "escape") {
1194
+ key.stopPropagation();
1195
+ setEntryIdx(savedEntryIdxRef.current);
1184
1196
  setPhase("pick");
1185
1197
  return;
1186
1198
  }
1187
- if (key.ctrl && key.name === "s") {
1199
+ if (key.ctrl && key.name === "d") {
1200
+ key.stopPropagation();
1188
1201
  if (!isFormValidRef.current) {
1189
- setFocusedFieldIdx(invalidFieldIndicesRef.current[0]!);
1202
+ const firstInvalid = invalidFieldIndicesRef.current[0];
1203
+ if (firstInvalid !== undefined) setFocusedFieldIdx(firstInvalid);
1190
1204
  return;
1191
1205
  }
1192
1206
  setConfirmOpen(true);
1193
1207
  return;
1194
1208
  }
1195
1209
  if (key.name === "tab") {
1210
+ key.stopPropagation();
1196
1211
  setFocusedFieldIdx((i: number) => {
1197
1212
  const len = currentFieldsRef.current.length;
1198
1213
  if (len <= 1) return 0;
@@ -1203,11 +1218,11 @@ export function WorkflowPicker({
1203
1218
  const field = currentFieldRef.current;
1204
1219
  if (!field) return;
1205
1220
 
1206
- // Enum fields use left/right to cycle values.
1207
1221
  if (field.type === "enum") {
1208
1222
  const values = field.values ?? [];
1209
1223
  if (values.length === 0) return;
1210
1224
  if (key.name === "left" || key.name === "right") {
1225
+ key.stopPropagation();
1211
1226
  setFieldValues((prev: Record<string, string>) => {
1212
1227
  const cur = prev[field.name] ?? values[0] ?? "";
1213
1228
  const idx = Math.max(0, values.indexOf(cur));
@@ -1219,100 +1234,192 @@ export function WorkflowPicker({
1219
1234
  return;
1220
1235
  }
1221
1236
 
1222
- // For string fields, return advances focus to the next field
1223
- // (the native <input> fires onSubmit, but we handle it here so
1224
- // the focus-cycling logic stays in one place).
1225
1237
  if (field.type === "string" && key.name === "return") {
1238
+ key.stopPropagation();
1226
1239
  setFocusedFieldIdx((i: number) =>
1227
1240
  Math.min(currentFieldsRef.current.length - 1, i + 1),
1228
1241
  );
1242
+ }
1243
+ }, []);
1244
+
1245
+ useKeyboard((key) => {
1246
+ if (key.ctrl && key.name === "c") {
1247
+ key.stopPropagation();
1248
+ onCancelRef.current();
1229
1249
  return;
1230
1250
  }
1231
- // All other keys for string/text fields (typing, backspace,
1232
- // arrows, undo/redo) are handled by native <input>/<textarea>.
1251
+ if (confirmOpenRef.current) return onConfirmKey(key);
1252
+ if (phaseRef.current === "pick") return onPickKey(key);
1253
+ onPromptKey(key);
1233
1254
  });
1255
+ }
1256
+
1257
+ // ─── App ────────────────────────────────────────
1258
+
1259
+ interface PickerAppProps {
1260
+ theme: PickerTheme;
1261
+ agent: AgentType;
1262
+ workflows: WorkflowWithMetadata[];
1263
+ onSubmit: (result: WorkflowPickerResult) => void;
1264
+ onCancel: () => void;
1265
+ }
1266
+
1267
+ export function WorkflowPicker({
1268
+ theme,
1269
+ agent,
1270
+ workflows,
1271
+ onSubmit,
1272
+ onCancel,
1273
+ }: PickerAppProps) {
1274
+ const [phase, setPhase] = useState<Phase>("pick");
1275
+ const [query, setQuery] = useState("");
1276
+ const [entryIdx, setEntryIdx] = useState(0);
1277
+ const [savedEntryIdx, setSavedEntryIdx] = useState(0);
1278
+ const [fieldValues, setFieldValues] = useState<Record<string, string>>({});
1279
+ const [focusedFieldIdx, setFocusedFieldIdx] = useState(0);
1280
+ const [confirmOpen, setConfirmOpen] = useState(false);
1281
+
1282
+ const entries = useMemo(() => buildEntries(query, workflows), [query, workflows]);
1283
+ const rows = useMemo(() => buildRows(entries, query), [entries, query]);
1284
+
1285
+ // Clamp index when the list shrinks (e.g. typing filters entries out).
1286
+ // Derived during render — keyboard handlers read the clamped value via
1287
+ // refs (useLatest) so no sync-back effect is needed.
1288
+ const clampedEntryIdx = Math.min(entryIdx, Math.max(0, entries.length - 1));
1289
+
1290
+ const focusedWf = entries[clampedEntryIdx]?.workflow;
1291
+
1292
+ const currentFields = useMemo<readonly WorkflowInput[]>(
1293
+ () => focusedWf && focusedWf.inputs.length > 0
1294
+ ? focusedWf.inputs
1295
+ : DEFAULT_FIELDS,
1296
+ [focusedWf],
1297
+ );
1298
+ const currentField = currentFields[focusedFieldIdx];
1299
+
1300
+ const invalidFieldIndices = useMemo(() => {
1301
+ const out: number[] = [];
1302
+ for (let i = 0; i < currentFields.length; i++) {
1303
+ const f = currentFields[i];
1304
+ if (!f) continue;
1305
+ const v = fieldValues[f.name] ?? "";
1306
+ if (!isFieldValid(f, v)) out.push(i);
1307
+ }
1308
+ return out;
1309
+ }, [currentFields, fieldValues]);
1310
+ const isFormValid = invalidFieldIndices.length === 0;
1311
+
1312
+ // Textarea change callback ref — useLatest keeps .current in sync
1313
+ // each render so the textarea effect doesn't need to re-attach.
1314
+ const textChangeRef = useLatest(
1315
+ currentField
1316
+ ? (text: string) => {
1317
+ setFieldValues((prev) => ({ ...prev, [currentField.name]: text }));
1318
+ }
1319
+ : null,
1320
+ );
1321
+
1322
+ // Stable callback for field input — the setter is referentially stable.
1323
+ const onFieldInput = useCallback(
1324
+ (name: string, v: string) => setFieldValues((prev) => ({ ...prev, [name]: v })),
1325
+ [],
1326
+ );
1234
1327
 
1235
- const pickHints = PICK_HINTS;
1236
- const confirmHints = CONFIRM_HINTS;
1237
- const promptHints = useMemo(() => [
1238
- { key: "tab", label: "to navigate forward" },
1239
- { key: "shift+tab", label: "to navigate backward" },
1240
- { key: "ctrl+s", label: "to run", dim: !isFormValid },
1241
- ], [isFormValid]);
1328
+ usePickerKeyboard({
1329
+ entries,
1330
+ clampedEntryIdx,
1331
+ savedEntryIdx,
1332
+ focusedWf,
1333
+ fieldValues,
1334
+ isFormValid,
1335
+ invalidFieldIndices,
1336
+ currentFields,
1337
+ currentField,
1338
+ phase,
1339
+ confirmOpen,
1340
+ onSubmit,
1341
+ onCancel,
1342
+ setPhase,
1343
+ setEntryIdx,
1344
+ setSavedEntryIdx,
1345
+ setFieldValues,
1346
+ setFocusedFieldIdx,
1347
+ setConfirmOpen,
1348
+ });
1242
1349
 
1243
1350
  const hints = confirmOpen
1244
- ? confirmHints
1351
+ ? CONFIRM_HINTS
1245
1352
  : phase === "pick"
1246
- ? pickHints
1247
- : promptHints;
1353
+ ? PICK_HINTS
1354
+ : isFormValid
1355
+ ? PROMPT_HINTS_VALID
1356
+ : PROMPT_HINTS_INVALID;
1248
1357
 
1249
1358
  return (
1250
- <box
1251
- position="relative"
1252
- width="100%"
1253
- height="100%"
1254
- flexDirection="column"
1255
- backgroundColor={theme.background}
1256
- >
1257
- <Header
1258
- theme={theme}
1259
- phase={phase}
1260
- confirmOpen={confirmOpen}
1261
- selectedAgent={agent}
1262
- scopedCount={workflows.length}
1263
- />
1359
+ <PickerThemeContext value={theme}>
1360
+ <box
1361
+ position="relative"
1362
+ width="100%"
1363
+ height="100%"
1364
+ flexDirection="column"
1365
+ backgroundColor={theme.background}
1366
+ >
1367
+ <Header
1368
+ phase={phase}
1369
+ confirmOpen={confirmOpen}
1370
+ selectedAgent={agent}
1371
+ scopedCount={workflows.length}
1372
+ />
1264
1373
 
1265
- {phase === "pick" ? (
1266
- <box
1267
- flexGrow={1}
1268
- flexDirection="row"
1269
- paddingLeft={2}
1270
- paddingRight={2}
1271
- paddingTop={1}
1272
- >
1273
- <box width={36} flexDirection="column">
1274
- <FilterBar theme={theme} query={query} focused={phase === "pick"} onInput={setQuery} />
1275
- <box height={1} />
1276
- <WorkflowList
1277
- theme={theme}
1278
- rows={rows}
1279
- focusedEntryIdx={entryIdx}
1280
- />
1281
- </box>
1282
- <box width={1} backgroundColor={theme.border} />
1283
- <box flexGrow={1} flexDirection="column">
1284
- {focusedWf ? (
1285
- <Preview theme={theme} wf={focusedWf} />
1286
- ) : (
1287
- <EmptyPreview theme={theme} query={query} />
1288
- )}
1374
+ {phase === "pick" ? (
1375
+ <box
1376
+ flexGrow={1}
1377
+ flexDirection="row"
1378
+ paddingLeft={2}
1379
+ paddingRight={2}
1380
+ paddingTop={1}
1381
+ >
1382
+ <box width={36} flexDirection="column">
1383
+ <FilterBar query={query} focused={phase === "pick"} onInput={setQuery} />
1384
+ <box height={1} />
1385
+ <WorkflowList
1386
+ rows={rows}
1387
+ focusedEntryIdx={clampedEntryIdx}
1388
+ />
1389
+ </box>
1390
+ <box width={1} backgroundColor={theme.border} />
1391
+ <box flexGrow={1} flexDirection="column">
1392
+ {focusedWf ? (
1393
+ <Preview wf={focusedWf} />
1394
+ ) : (
1395
+ <EmptyPreview query={query} />
1396
+ )}
1397
+ </box>
1289
1398
  </box>
1290
- </box>
1291
- ) : phase === "prompt" && focusedWf ? (
1292
- <InputPhase
1293
- theme={theme}
1294
- workflow={focusedWf}
1295
- agent={agent}
1296
- fields={currentFields}
1297
- values={fieldValues}
1298
- focusedFieldIdx={confirmOpen ? -1 : focusedFieldIdx}
1299
- onFieldInput={onFieldInput}
1300
- onTextChangeRef={textChangeRef}
1301
- />
1302
- ) : null}
1399
+ ) : phase === "prompt" && focusedWf ? (
1400
+ <InputPhase
1401
+ workflow={focusedWf}
1402
+ agent={agent}
1403
+ fields={currentFields}
1404
+ values={fieldValues}
1405
+ focusedFieldIdx={confirmOpen ? -1 : focusedFieldIdx}
1406
+ onFieldInput={onFieldInput}
1407
+ onTextChangeRef={textChangeRef}
1408
+ />
1409
+ ) : null}
1303
1410
 
1304
- <Statusline
1305
- theme={theme}
1306
- phase={phase}
1307
- confirmOpen={confirmOpen}
1308
- hints={hints}
1309
- focusedWf={focusedWf}
1310
- />
1411
+ <Statusline
1412
+ phase={phase}
1413
+ confirmOpen={confirmOpen}
1414
+ hints={hints}
1415
+ focusedWf={focusedWf}
1416
+ />
1311
1417
 
1312
- {confirmOpen && focusedWf ? (
1313
- <ConfirmModal theme={theme} workflow={focusedWf} agent={agent} />
1314
- ) : null}
1315
- </box>
1418
+ {confirmOpen && focusedWf ? (
1419
+ <ConfirmModal workflow={focusedWf} agent={agent} />
1420
+ ) : null}
1421
+ </box>
1422
+ </PickerThemeContext>
1316
1423
  );
1317
1424
  }
1318
1425
 
@@ -1346,7 +1453,8 @@ export class WorkflowPickerPanel {
1346
1453
  this.resolveSelection = resolve;
1347
1454
  });
1348
1455
 
1349
- const theme = buildPickerTheme(resolveTheme(renderer.themeMode));
1456
+ const isDark = renderer.themeMode !== "light";
1457
+ const theme = buildPickerTheme(resolveTheme(renderer.themeMode), isDark);
1350
1458
  this.root = createRoot(renderer);
1351
1459
  this.root.render(
1352
1460
  <ErrorBoundary
@@ -1428,7 +1536,9 @@ export class WorkflowPickerPanel {
1428
1536
  }
1429
1537
  try {
1430
1538
  this.renderer.destroy();
1431
- } catch {}
1539
+ } catch (err) {
1540
+ console.error("[WorkflowPickerPanel] destroy failed:", err);
1541
+ }
1432
1542
  }
1433
1543
 
1434
1544
  private handleSubmit(result: WorkflowPickerResult): void {
@@ -606,6 +606,11 @@ export class HeadlessClaudeClientWrapper {
606
606
  * directly instead of tmux pane operations. Implements the same `query()`
607
607
  * interface as {@link ClaudeSessionWrapper} so workflow callbacks work
608
608
  * identically for headless and interactive stages.
609
+ *
610
+ * The `query()` method accepts the full Agent SDK parameter types —
611
+ * `prompt` can be a plain string or an `AsyncIterable<SDKUserMessage>`
612
+ * for multi-turn streaming, and `options` passes through SDK-level
613
+ * configuration (abort controllers, allowed tools, agents, etc.).
609
614
  */
610
615
  export class HeadlessClaudeSessionWrapper {
611
616
  readonly paneId = "";
@@ -615,10 +620,13 @@ export class HeadlessClaudeSessionWrapper {
615
620
  this.sessionId = sessionId;
616
621
  }
617
622
 
618
- async query(prompt: string): Promise<ClaudeQueryResult> {
623
+ async query(
624
+ prompt: string | AsyncIterable<import("@anthropic-ai/claude-agent-sdk").SDKUserMessage>,
625
+ options?: import("@anthropic-ai/claude-agent-sdk").Options,
626
+ ): Promise<ClaudeQueryResult> {
619
627
  const { query } = await import("@anthropic-ai/claude-agent-sdk");
620
628
  let output = "";
621
- for await (const msg of query({ prompt })) {
629
+ for await (const msg of query({ prompt, options })) {
622
630
  if (msg.type === "result") {
623
631
  // SDKResultSuccess has `result: string`, not `output`.
624
632
  output = String((msg as Record<string, unknown>).result ?? "");