@bastani/atomic 0.5.12-0 → 0.5.12-1

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-1",
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(() => {
@@ -556,12 +596,12 @@ function TextAreaContent({
556
596
  const ta = ref.current;
557
597
  if (!ta) return;
558
598
  ta.onContentChange = () => {
559
- onChangeRef.current?.(ta.plainText);
599
+ changeRef.current?.(ta.plainText);
560
600
  };
561
601
  return () => {
562
602
  ta.onContentChange = undefined;
563
603
  };
564
- }, [onChangeRef]);
604
+ }, [changeRef]);
565
605
 
566
606
  return (
567
607
  <textarea
@@ -581,18 +621,17 @@ function TextAreaContent({
581
621
  }
582
622
 
583
623
  function StringContent({
584
- theme,
585
624
  value,
586
625
  placeholder,
587
626
  focused,
588
627
  onInput,
589
628
  }: {
590
- theme: PickerTheme;
591
629
  value: string;
592
630
  placeholder: string;
593
631
  focused: boolean;
594
632
  onInput: (value: string) => void;
595
633
  }) {
634
+ const theme = usePickerTheme();
596
635
  return (
597
636
  <input
598
637
  value={value}
@@ -609,16 +648,15 @@ function StringContent({
609
648
  }
610
649
 
611
650
  function EnumContent({
612
- theme,
613
651
  values,
614
652
  selected,
615
653
  focused,
616
654
  }: {
617
- theme: PickerTheme;
618
655
  values: string[];
619
656
  selected: string;
620
657
  focused: boolean;
621
658
  }) {
659
+ const theme = usePickerTheme();
622
660
  return (
623
661
  <box height={1} flexDirection="row">
624
662
  {values.map((v, i) => {
@@ -652,21 +690,20 @@ function EnumContent({
652
690
  );
653
691
  }
654
692
 
655
- function Field({
656
- theme,
693
+ const Field = memo(function Field({
657
694
  field,
658
695
  value,
659
696
  focused,
660
- onInput,
697
+ onFieldInput,
661
698
  onTextChangeRef,
662
699
  }: {
663
- theme: PickerTheme;
664
700
  field: WorkflowInput;
665
701
  value: string;
666
702
  focused: boolean;
667
- onInput: (value: string) => void;
668
- onTextChangeRef: React.RefObject<((value: string) => void) | null>;
703
+ onFieldInput: (fieldName: string, value: string) => void;
704
+ onTextChangeRef?: React.RefObject<((value: string) => void) | null>;
669
705
  }) {
706
+ const theme = usePickerTheme();
670
707
  const borderCol = focused ? theme.primary : theme.border;
671
708
  const bgCol = focused ? theme.backgroundPanel : theme.backgroundElement;
672
709
 
@@ -676,6 +713,12 @@ function Field({
676
713
  const tagLabel = field.required ? "required" : "optional";
677
714
  const captionDesc = field.description ? " · " + field.description : "";
678
715
 
716
+ // Bind the field name once so the parent doesn't need a per-field closure.
717
+ const onInput = useCallback(
718
+ (v: string) => onFieldInput(field.name, v),
719
+ [onFieldInput, field.name],
720
+ );
721
+
679
722
  return (
680
723
  <box flexDirection="column">
681
724
  <box
@@ -693,7 +736,6 @@ function Field({
693
736
  >
694
737
  {field.type === "text" ? (
695
738
  <TextAreaContent
696
- theme={theme}
697
739
  value={value}
698
740
  placeholder={field.placeholder ?? ""}
699
741
  focused={focused}
@@ -701,7 +743,6 @@ function Field({
701
743
  />
702
744
  ) : field.type === "string" ? (
703
745
  <StringContent
704
- theme={theme}
705
746
  value={value}
706
747
  placeholder={field.placeholder ?? ""}
707
748
  focused={focused}
@@ -709,7 +750,6 @@ function Field({
709
750
  />
710
751
  ) : field.type === "enum" ? (
711
752
  <EnumContent
712
- theme={theme}
713
753
  values={field.values ?? []}
714
754
  selected={value}
715
755
  focused={focused}
@@ -729,10 +769,9 @@ function Field({
729
769
  <box height={1} />
730
770
  </box>
731
771
  );
732
- }
772
+ });
733
773
 
734
774
  function InputPhase({
735
- theme,
736
775
  workflow,
737
776
  agent,
738
777
  fields,
@@ -741,15 +780,15 @@ function InputPhase({
741
780
  onFieldInput,
742
781
  onTextChangeRef,
743
782
  }: {
744
- theme: PickerTheme;
745
783
  workflow: WorkflowWithMetadata;
746
784
  agent: AgentType;
747
- fields: WorkflowInput[];
785
+ fields: readonly WorkflowInput[];
748
786
  values: Record<string, string>;
749
787
  focusedFieldIdx: number;
750
788
  onFieldInput: (fieldName: string, value: string) => void;
751
789
  onTextChangeRef: React.RefObject<((value: string) => void) | null>;
752
790
  }) {
791
+ const theme = usePickerTheme();
753
792
  const isStructured = workflow.inputs.length > 0;
754
793
 
755
794
  return (
@@ -816,12 +855,15 @@ function InputPhase({
816
855
  {fields.map((f, i) => (
817
856
  <Field
818
857
  key={f.name}
819
- theme={theme}
820
858
  field={f}
821
859
  value={values[f.name] ?? ""}
822
860
  focused={i === focusedFieldIdx}
823
- onInput={(v) => onFieldInput(f.name, v)}
824
- onTextChangeRef={onTextChangeRef}
861
+ onFieldInput={onFieldInput}
862
+ onTextChangeRef={
863
+ f.type === "text" && i === focusedFieldIdx
864
+ ? onTextChangeRef
865
+ : undefined
866
+ }
825
867
  />
826
868
  ))}
827
869
  </box>
@@ -829,14 +871,13 @@ function InputPhase({
829
871
  }
830
872
 
831
873
  function ConfirmModal({
832
- theme,
833
874
  workflow,
834
875
  agent,
835
876
  }: {
836
- theme: PickerTheme;
837
877
  workflow: WorkflowWithMetadata;
838
878
  agent: AgentType;
839
879
  }) {
880
+ const theme = usePickerTheme();
840
881
  return (
841
882
  <box
842
883
  position="absolute"
@@ -897,16 +938,28 @@ function ConfirmModal({
897
938
  );
898
939
  }
899
940
 
900
- // Stable hint arrays — no need for useMemo since they never change.
901
- const PICK_HINTS: { key: string; label: string; dim?: boolean }[] = [
941
+ // Stable hint arrays — pre-built so they never create new references.
942
+ type Hint = { key: string; label: string; dim?: boolean };
943
+
944
+ const PICK_HINTS: Hint[] = [
902
945
  { key: "↑↓", label: "navigate" },
903
946
  { key: "↵", label: "select" },
904
947
  { key: "esc", label: "quit" },
905
948
  ];
906
- const CONFIRM_HINTS: { key: string; label: string; dim?: boolean }[] = [
949
+ const CONFIRM_HINTS: Hint[] = [
907
950
  { key: "y", label: "submit" },
908
951
  { key: "n", label: "cancel" },
909
952
  ];
953
+ const PROMPT_HINTS_VALID: Hint[] = [
954
+ { key: "tab", label: "to navigate forward" },
955
+ { key: "shift+tab", label: "to navigate backward" },
956
+ { key: "ctrl+d", label: "to run" },
957
+ ];
958
+ const PROMPT_HINTS_INVALID: Hint[] = [
959
+ { key: "tab", label: "to navigate forward" },
960
+ { key: "shift+tab", label: "to navigate backward" },
961
+ { key: "ctrl+d", label: "to run", dim: true },
962
+ ];
910
963
 
911
964
  // Per-agent brand color used as the Header pill background.
912
965
  const AGENT_PILL_COLOR: Record<AgentType, keyof PickerTheme> = {
@@ -915,19 +968,18 @@ const AGENT_PILL_COLOR: Record<AgentType, keyof PickerTheme> = {
915
968
  opencode: "mauve",
916
969
  };
917
970
 
918
- function Header({
919
- theme,
971
+ const Header = memo(function Header({
920
972
  phase,
921
973
  confirmOpen,
922
974
  selectedAgent,
923
975
  scopedCount,
924
976
  }: {
925
- theme: PickerTheme;
926
977
  phase: Phase;
927
978
  confirmOpen: boolean;
928
979
  selectedAgent: AgentType;
929
980
  scopedCount: number;
930
981
  }) {
982
+ const theme = usePickerTheme();
931
983
  const phaseLabel = confirmOpen
932
984
  ? "confirm"
933
985
  : phase === "pick"
@@ -965,21 +1017,20 @@ function Header({
965
1017
  </text>
966
1018
  </box>
967
1019
  );
968
- }
1020
+ });
969
1021
 
970
- function Statusline({
971
- theme,
1022
+ const Statusline = memo(function Statusline({
972
1023
  phase,
973
1024
  confirmOpen,
974
1025
  hints,
975
1026
  focusedWf,
976
1027
  }: {
977
- theme: PickerTheme;
978
1028
  phase: Phase;
979
1029
  confirmOpen: boolean;
980
1030
  hints: { key: string; label: string; dim?: boolean }[];
981
1031
  focusedWf: WorkflowWithMetadata | undefined;
982
1032
  }) {
1033
+ const theme = usePickerTheme();
983
1034
  const modeLabel = confirmOpen
984
1035
  ? "CONFIRM"
985
1036
  : phase === "pick"
@@ -1033,166 +1084,131 @@ function Statusline({
1033
1084
  </box>
1034
1085
  </box>
1035
1086
  );
1036
- }
1087
+ });
1037
1088
 
1038
- // ─── App ────────────────────────────────────────
1089
+ // ─── Keyboard hook ─────────────────────────────
1039
1090
 
1040
- interface PickerAppProps {
1041
- theme: PickerTheme;
1042
- agent: AgentType;
1043
- workflows: WorkflowWithMetadata[];
1091
+ interface PickerKeyboardState {
1092
+ entries: ListEntry[];
1093
+ clampedEntryIdx: number;
1094
+ savedEntryIdx: number;
1095
+ focusedWf: WorkflowWithMetadata | undefined;
1096
+ fieldValues: Record<string, string>;
1097
+ isFormValid: boolean;
1098
+ invalidFieldIndices: number[];
1099
+ currentFields: readonly WorkflowInput[];
1100
+ currentField: WorkflowInput | undefined;
1101
+ phase: Phase;
1102
+ confirmOpen: boolean;
1044
1103
  onSubmit: (result: WorkflowPickerResult) => void;
1045
1104
  onCancel: () => void;
1105
+ setPhase: (p: Phase) => void;
1106
+ setEntryIdx: React.Dispatch<React.SetStateAction<number>>;
1107
+ setSavedEntryIdx: (i: number) => void;
1108
+ setFieldValues: React.Dispatch<React.SetStateAction<Record<string, string>>>;
1109
+ setFocusedFieldIdx: React.Dispatch<React.SetStateAction<number>>;
1110
+ setConfirmOpen: (open: boolean) => void;
1046
1111
  }
1047
1112
 
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);
1113
+ /**
1114
+ * Encapsulates all keyboard handling for the picker's three phases
1115
+ * (pick, prompt, confirm). Reads state through refs to avoid stale
1116
+ * closures — useKeyboard captures the first callback identity.
1117
+ */
1118
+ function usePickerKeyboard(state: PickerKeyboardState): void {
1119
+ const onSubmitRef = useLatest(state.onSubmit);
1120
+ const onCancelRef = useLatest(state.onCancel);
1121
+ const entriesRef = useLatest(state.entries);
1122
+ const entryIdxRef = useLatest(state.clampedEntryIdx);
1123
+ const savedEntryIdxRef = useLatest(state.savedEntryIdx);
1124
+ const focusedWfRef = useLatest(state.focusedWf);
1125
+ const fieldValuesRef = useLatest(state.fieldValues);
1126
+ const isFormValidRef = useLatest(state.isFormValid);
1127
+ const invalidFieldIndicesRef = useLatest(state.invalidFieldIndices);
1128
+ const currentFieldsRef = useLatest(state.currentFields);
1129
+ const currentFieldRef = useLatest(state.currentField);
1130
+ const phaseRef = useLatest(state.phase);
1131
+ const confirmOpenRef = useLatest(state.confirmOpen);
1132
+
1133
+ const {
1134
+ setPhase,
1135
+ setEntryIdx,
1136
+ setSavedEntryIdx,
1137
+ setFieldValues,
1138
+ setFocusedFieldIdx,
1139
+ setConfirmOpen,
1140
+ } = state;
1141
+
1142
+ const onConfirmKey = useCallback((key: KeyEvent) => {
1143
+ key.stopPropagation();
1144
+ if (key.name === "y" || key.name === "return") {
1145
+ const wf = focusedWfRef.current;
1146
+ if (!wf) return;
1147
+ onSubmitRef.current({ workflow: wf, inputs: { ...fieldValuesRef.current } });
1148
+ return;
1090
1149
  }
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);
1150
+ if (key.name === "n" || key.name === "escape") {
1151
+ setConfirmOpen(false);
1152
+ }
1153
+ }, []);
1122
1154
 
1123
- useKeyboard((key) => {
1124
- if (key.ctrl && key.name === "c") {
1125
- onCancel();
1155
+ const onPickKey = useCallback((key: KeyEvent) => {
1156
+ if (key.name === "escape") {
1157
+ key.stopPropagation();
1158
+ onCancelRef.current();
1126
1159
  return;
1127
1160
  }
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
- }
1161
+ if (key.name === "up" || (key.ctrl && key.name === "k")) {
1162
+ key.stopPropagation();
1163
+ setEntryIdx(Math.max(0, entryIdxRef.current - 1));
1140
1164
  return;
1141
1165
  }
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");
1166
+ if (key.name === "down" || (key.ctrl && key.name === "j")) {
1167
+ key.stopPropagation();
1168
+ setEntryIdx(Math.min(entriesRef.current.length - 1, entryIdxRef.current + 1));
1169
+ return;
1170
+ }
1171
+ if (key.name === "return") {
1172
+ key.stopPropagation();
1173
+ const wf = focusedWfRef.current;
1174
+ if (wf) {
1175
+ const inputs: readonly WorkflowInput[] =
1176
+ wf.inputs.length > 0
1177
+ ? wf.inputs
1178
+ : DEFAULT_FIELDS;
1179
+ const initial: Record<string, string> = {};
1180
+ for (const f of inputs) {
1181
+ initial[f.name] =
1182
+ f.default ??
1183
+ (f.type === "enum" ? (f.values?.[0] ?? "") : "");
1174
1184
  }
1175
- return;
1185
+ setFieldValues(initial);
1186
+ setFocusedFieldIdx(0);
1187
+ setSavedEntryIdx(entryIdxRef.current);
1188
+ setPhase("prompt");
1176
1189
  }
1177
- // All other keys (typing, backspace, arrows) are handled by the
1178
- // native <input> component in the FilterBar.
1179
- return;
1180
1190
  }
1191
+ }, []);
1181
1192
 
1182
- // ── PROMPT phase ──
1193
+ const onPromptKey = useCallback((key: KeyEvent) => {
1183
1194
  if (key.name === "escape") {
1195
+ key.stopPropagation();
1196
+ setEntryIdx(savedEntryIdxRef.current);
1184
1197
  setPhase("pick");
1185
1198
  return;
1186
1199
  }
1187
- if (key.ctrl && key.name === "s") {
1200
+ if (key.ctrl && key.name === "d") {
1201
+ key.stopPropagation();
1188
1202
  if (!isFormValidRef.current) {
1189
- setFocusedFieldIdx(invalidFieldIndicesRef.current[0]!);
1203
+ const firstInvalid = invalidFieldIndicesRef.current[0];
1204
+ if (firstInvalid !== undefined) setFocusedFieldIdx(firstInvalid);
1190
1205
  return;
1191
1206
  }
1192
1207
  setConfirmOpen(true);
1193
1208
  return;
1194
1209
  }
1195
1210
  if (key.name === "tab") {
1211
+ key.stopPropagation();
1196
1212
  setFocusedFieldIdx((i: number) => {
1197
1213
  const len = currentFieldsRef.current.length;
1198
1214
  if (len <= 1) return 0;
@@ -1203,11 +1219,11 @@ export function WorkflowPicker({
1203
1219
  const field = currentFieldRef.current;
1204
1220
  if (!field) return;
1205
1221
 
1206
- // Enum fields use left/right to cycle values.
1207
1222
  if (field.type === "enum") {
1208
1223
  const values = field.values ?? [];
1209
1224
  if (values.length === 0) return;
1210
1225
  if (key.name === "left" || key.name === "right") {
1226
+ key.stopPropagation();
1211
1227
  setFieldValues((prev: Record<string, string>) => {
1212
1228
  const cur = prev[field.name] ?? values[0] ?? "";
1213
1229
  const idx = Math.max(0, values.indexOf(cur));
@@ -1219,100 +1235,192 @@ export function WorkflowPicker({
1219
1235
  return;
1220
1236
  }
1221
1237
 
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
1238
  if (field.type === "string" && key.name === "return") {
1239
+ key.stopPropagation();
1226
1240
  setFocusedFieldIdx((i: number) =>
1227
1241
  Math.min(currentFieldsRef.current.length - 1, i + 1),
1228
1242
  );
1243
+ }
1244
+ }, []);
1245
+
1246
+ useKeyboard((key) => {
1247
+ if (key.ctrl && key.name === "c") {
1248
+ key.stopPropagation();
1249
+ onCancelRef.current();
1229
1250
  return;
1230
1251
  }
1231
- // All other keys for string/text fields (typing, backspace,
1232
- // arrows, undo/redo) are handled by native <input>/<textarea>.
1252
+ if (confirmOpenRef.current) return onConfirmKey(key);
1253
+ if (phaseRef.current === "pick") return onPickKey(key);
1254
+ onPromptKey(key);
1233
1255
  });
1256
+ }
1257
+
1258
+ // ─── App ────────────────────────────────────────
1259
+
1260
+ interface PickerAppProps {
1261
+ theme: PickerTheme;
1262
+ agent: AgentType;
1263
+ workflows: WorkflowWithMetadata[];
1264
+ onSubmit: (result: WorkflowPickerResult) => void;
1265
+ onCancel: () => void;
1266
+ }
1234
1267
 
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]);
1268
+ export function WorkflowPicker({
1269
+ theme,
1270
+ agent,
1271
+ workflows,
1272
+ onSubmit,
1273
+ onCancel,
1274
+ }: PickerAppProps) {
1275
+ const [phase, setPhase] = useState<Phase>("pick");
1276
+ const [query, setQuery] = useState("");
1277
+ const [entryIdx, setEntryIdx] = useState(0);
1278
+ const [savedEntryIdx, setSavedEntryIdx] = useState(0);
1279
+ const [fieldValues, setFieldValues] = useState<Record<string, string>>({});
1280
+ const [focusedFieldIdx, setFocusedFieldIdx] = useState(0);
1281
+ const [confirmOpen, setConfirmOpen] = useState(false);
1282
+
1283
+ const entries = useMemo(() => buildEntries(query, workflows), [query, workflows]);
1284
+ const rows = useMemo(() => buildRows(entries, query), [entries, query]);
1285
+
1286
+ // Clamp index when the list shrinks (e.g. typing filters entries out).
1287
+ // Derived during render — keyboard handlers read the clamped value via
1288
+ // refs (useLatest) so no sync-back effect is needed.
1289
+ const clampedEntryIdx = Math.min(entryIdx, Math.max(0, entries.length - 1));
1290
+
1291
+ const focusedWf = entries[clampedEntryIdx]?.workflow;
1292
+
1293
+ const currentFields = useMemo<readonly WorkflowInput[]>(
1294
+ () => focusedWf && focusedWf.inputs.length > 0
1295
+ ? focusedWf.inputs
1296
+ : DEFAULT_FIELDS,
1297
+ [focusedWf],
1298
+ );
1299
+ const currentField = currentFields[focusedFieldIdx];
1300
+
1301
+ const invalidFieldIndices = useMemo(() => {
1302
+ const out: number[] = [];
1303
+ for (let i = 0; i < currentFields.length; i++) {
1304
+ const f = currentFields[i];
1305
+ if (!f) continue;
1306
+ const v = fieldValues[f.name] ?? "";
1307
+ if (!isFieldValid(f, v)) out.push(i);
1308
+ }
1309
+ return out;
1310
+ }, [currentFields, fieldValues]);
1311
+ const isFormValid = invalidFieldIndices.length === 0;
1312
+
1313
+ // Textarea change callback ref — useLatest keeps .current in sync
1314
+ // each render so the textarea effect doesn't need to re-attach.
1315
+ const textChangeRef = useLatest(
1316
+ currentField
1317
+ ? (text: string) => {
1318
+ setFieldValues((prev) => ({ ...prev, [currentField.name]: text }));
1319
+ }
1320
+ : null,
1321
+ );
1322
+
1323
+ // Stable callback for field input — the setter is referentially stable.
1324
+ const onFieldInput = useCallback(
1325
+ (name: string, v: string) => setFieldValues((prev) => ({ ...prev, [name]: v })),
1326
+ [],
1327
+ );
1328
+
1329
+ usePickerKeyboard({
1330
+ entries,
1331
+ clampedEntryIdx,
1332
+ savedEntryIdx,
1333
+ focusedWf,
1334
+ fieldValues,
1335
+ isFormValid,
1336
+ invalidFieldIndices,
1337
+ currentFields,
1338
+ currentField,
1339
+ phase,
1340
+ confirmOpen,
1341
+ onSubmit,
1342
+ onCancel,
1343
+ setPhase,
1344
+ setEntryIdx,
1345
+ setSavedEntryIdx,
1346
+ setFieldValues,
1347
+ setFocusedFieldIdx,
1348
+ setConfirmOpen,
1349
+ });
1242
1350
 
1243
1351
  const hints = confirmOpen
1244
- ? confirmHints
1352
+ ? CONFIRM_HINTS
1245
1353
  : phase === "pick"
1246
- ? pickHints
1247
- : promptHints;
1354
+ ? PICK_HINTS
1355
+ : isFormValid
1356
+ ? PROMPT_HINTS_VALID
1357
+ : PROMPT_HINTS_INVALID;
1248
1358
 
1249
1359
  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
- />
1360
+ <PickerThemeContext value={theme}>
1361
+ <box
1362
+ position="relative"
1363
+ width="100%"
1364
+ height="100%"
1365
+ flexDirection="column"
1366
+ backgroundColor={theme.background}
1367
+ >
1368
+ <Header
1369
+ phase={phase}
1370
+ confirmOpen={confirmOpen}
1371
+ selectedAgent={agent}
1372
+ scopedCount={workflows.length}
1373
+ />
1264
1374
 
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
- )}
1375
+ {phase === "pick" ? (
1376
+ <box
1377
+ flexGrow={1}
1378
+ flexDirection="row"
1379
+ paddingLeft={2}
1380
+ paddingRight={2}
1381
+ paddingTop={1}
1382
+ >
1383
+ <box width={36} flexDirection="column">
1384
+ <FilterBar query={query} focused={phase === "pick"} onInput={setQuery} />
1385
+ <box height={1} />
1386
+ <WorkflowList
1387
+ rows={rows}
1388
+ focusedEntryIdx={clampedEntryIdx}
1389
+ />
1390
+ </box>
1391
+ <box width={1} backgroundColor={theme.border} />
1392
+ <box flexGrow={1} flexDirection="column">
1393
+ {focusedWf ? (
1394
+ <Preview wf={focusedWf} />
1395
+ ) : (
1396
+ <EmptyPreview query={query} />
1397
+ )}
1398
+ </box>
1289
1399
  </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}
1400
+ ) : phase === "prompt" && focusedWf ? (
1401
+ <InputPhase
1402
+ workflow={focusedWf}
1403
+ agent={agent}
1404
+ fields={currentFields}
1405
+ values={fieldValues}
1406
+ focusedFieldIdx={confirmOpen ? -1 : focusedFieldIdx}
1407
+ onFieldInput={onFieldInput}
1408
+ onTextChangeRef={textChangeRef}
1409
+ />
1410
+ ) : null}
1303
1411
 
1304
- <Statusline
1305
- theme={theme}
1306
- phase={phase}
1307
- confirmOpen={confirmOpen}
1308
- hints={hints}
1309
- focusedWf={focusedWf}
1310
- />
1412
+ <Statusline
1413
+ phase={phase}
1414
+ confirmOpen={confirmOpen}
1415
+ hints={hints}
1416
+ focusedWf={focusedWf}
1417
+ />
1311
1418
 
1312
- {confirmOpen && focusedWf ? (
1313
- <ConfirmModal theme={theme} workflow={focusedWf} agent={agent} />
1314
- ) : null}
1315
- </box>
1419
+ {confirmOpen && focusedWf ? (
1420
+ <ConfirmModal workflow={focusedWf} agent={agent} />
1421
+ ) : null}
1422
+ </box>
1423
+ </PickerThemeContext>
1316
1424
  );
1317
1425
  }
1318
1426
 
@@ -1346,7 +1454,8 @@ export class WorkflowPickerPanel {
1346
1454
  this.resolveSelection = resolve;
1347
1455
  });
1348
1456
 
1349
- const theme = buildPickerTheme(resolveTheme(renderer.themeMode));
1457
+ const isDark = renderer.themeMode !== "light";
1458
+ const theme = buildPickerTheme(resolveTheme(renderer.themeMode), isDark);
1350
1459
  this.root = createRoot(renderer);
1351
1460
  this.root.render(
1352
1461
  <ErrorBoundary
@@ -1428,7 +1537,9 @@ export class WorkflowPickerPanel {
1428
1537
  }
1429
1538
  try {
1430
1539
  this.renderer.destroy();
1431
- } catch {}
1540
+ } catch (err) {
1541
+ console.error("[WorkflowPickerPanel] destroy failed:", err);
1542
+ }
1432
1543
  }
1433
1544
 
1434
1545
  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 ?? "");