@bastani/atomic 0.5.4 → 0.5.5

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 (173) hide show
  1. package/README.md +44 -1
  2. package/dist/lib/path-root-guard.d.ts +4 -0
  3. package/dist/lib/path-root-guard.d.ts.map +1 -0
  4. package/dist/sdk/components/color-utils.d.ts +1 -0
  5. package/dist/sdk/components/color-utils.d.ts.map +1 -0
  6. package/dist/sdk/components/connectors.d.ts +3 -2
  7. package/dist/sdk/components/connectors.d.ts.map +1 -0
  8. package/dist/sdk/components/connectors.test.d.ts +1 -0
  9. package/dist/sdk/components/connectors.test.d.ts.map +1 -0
  10. package/dist/sdk/components/edge.d.ts +2 -1
  11. package/dist/sdk/components/edge.d.ts.map +1 -0
  12. package/dist/sdk/components/error-boundary.d.ts +1 -0
  13. package/dist/sdk/components/error-boundary.d.ts.map +1 -0
  14. package/dist/sdk/components/graph-theme.d.ts +2 -1
  15. package/dist/sdk/components/graph-theme.d.ts.map +1 -0
  16. package/dist/sdk/components/header.d.ts +1 -0
  17. package/dist/sdk/components/header.d.ts.map +1 -0
  18. package/dist/sdk/components/hooks.d.ts +15 -0
  19. package/dist/sdk/components/hooks.d.ts.map +1 -0
  20. package/dist/sdk/components/layout.d.ts +2 -1
  21. package/dist/sdk/components/layout.d.ts.map +1 -0
  22. package/dist/sdk/components/layout.test.d.ts +1 -0
  23. package/dist/sdk/components/layout.test.d.ts.map +1 -0
  24. package/dist/sdk/components/node-card.d.ts +5 -3
  25. package/dist/sdk/components/node-card.d.ts.map +1 -0
  26. package/dist/sdk/components/orchestrator-panel-contexts.d.ts +3 -2
  27. package/dist/sdk/components/orchestrator-panel-contexts.d.ts.map +1 -0
  28. package/dist/sdk/components/orchestrator-panel-store.d.ts +2 -1
  29. package/dist/sdk/components/orchestrator-panel-store.d.ts.map +1 -0
  30. package/dist/sdk/components/orchestrator-panel-store.test.d.ts +1 -0
  31. package/dist/sdk/components/orchestrator-panel-store.test.d.ts.map +1 -0
  32. package/dist/sdk/components/orchestrator-panel-types.d.ts +1 -0
  33. package/dist/sdk/components/orchestrator-panel-types.d.ts.map +1 -0
  34. package/dist/sdk/components/orchestrator-panel.d.ts +2 -1
  35. package/dist/sdk/components/orchestrator-panel.d.ts.map +1 -0
  36. package/dist/sdk/components/session-graph-panel.d.ts +1 -0
  37. package/dist/sdk/components/session-graph-panel.d.ts.map +1 -0
  38. package/dist/sdk/components/status-helpers.d.ts +2 -1
  39. package/dist/sdk/components/status-helpers.d.ts.map +1 -0
  40. package/dist/sdk/components/statusline.d.ts +2 -1
  41. package/dist/sdk/components/statusline.d.ts.map +1 -0
  42. package/dist/sdk/components/workflow-picker-panel.d.ts +11 -8
  43. package/dist/sdk/components/workflow-picker-panel.d.ts.map +1 -0
  44. package/dist/sdk/define-workflow.d.ts +2 -1
  45. package/dist/sdk/define-workflow.d.ts.map +1 -0
  46. package/dist/sdk/define-workflow.test.d.ts +1 -0
  47. package/dist/sdk/define-workflow.test.d.ts.map +1 -0
  48. package/dist/sdk/errors.d.ts +3 -0
  49. package/dist/sdk/errors.d.ts.map +1 -0
  50. package/dist/sdk/errors.test.d.ts +2 -0
  51. package/dist/sdk/errors.test.d.ts.map +1 -0
  52. package/dist/sdk/index.d.ts +7 -6
  53. package/dist/sdk/index.d.ts.map +1 -0
  54. package/dist/sdk/providers/claude.d.ts +17 -6
  55. package/dist/sdk/providers/claude.d.ts.map +1 -0
  56. package/dist/sdk/providers/copilot.d.ts +2 -5
  57. package/dist/sdk/providers/copilot.d.ts.map +1 -0
  58. package/dist/sdk/providers/opencode.d.ts +2 -5
  59. package/dist/sdk/providers/opencode.d.ts.map +1 -0
  60. package/dist/sdk/runtime/discovery.d.ts +2 -1
  61. package/dist/sdk/runtime/discovery.d.ts.map +1 -0
  62. package/dist/sdk/runtime/executor-entry.d.ts +1 -0
  63. package/dist/sdk/runtime/executor-entry.d.ts.map +1 -0
  64. package/dist/sdk/runtime/executor.d.ts +3 -6
  65. package/dist/sdk/runtime/executor.d.ts.map +1 -0
  66. package/dist/sdk/runtime/executor.test.d.ts +1 -0
  67. package/dist/sdk/runtime/executor.test.d.ts.map +1 -0
  68. package/dist/sdk/runtime/graph-inference.d.ts +1 -0
  69. package/dist/sdk/runtime/graph-inference.d.ts.map +1 -0
  70. package/dist/sdk/runtime/loader.d.ts +5 -7
  71. package/dist/sdk/runtime/loader.d.ts.map +1 -0
  72. package/dist/sdk/runtime/panel.d.ts +3 -2
  73. package/dist/sdk/runtime/panel.d.ts.map +1 -0
  74. package/dist/sdk/runtime/theme.d.ts +1 -0
  75. package/dist/sdk/runtime/theme.d.ts.map +1 -0
  76. package/dist/sdk/runtime/tmux.d.ts +26 -8
  77. package/dist/sdk/runtime/tmux.d.ts.map +1 -0
  78. package/dist/sdk/types.d.ts +23 -1
  79. package/dist/sdk/types.d.ts.map +1 -0
  80. package/dist/sdk/workflows/builtin/deep-research-codebase/claude/index.d.ts +1 -0
  81. package/dist/sdk/workflows/builtin/deep-research-codebase/claude/index.d.ts.map +1 -0
  82. package/dist/sdk/workflows/builtin/deep-research-codebase/copilot/index.d.ts +1 -0
  83. package/dist/sdk/workflows/builtin/deep-research-codebase/copilot/index.d.ts.map +1 -0
  84. package/dist/sdk/workflows/builtin/deep-research-codebase/helpers/heuristic.d.ts +1 -0
  85. package/dist/sdk/workflows/builtin/deep-research-codebase/helpers/heuristic.d.ts.map +1 -0
  86. package/dist/sdk/workflows/builtin/deep-research-codebase/helpers/prompts.d.ts +2 -1
  87. package/dist/sdk/workflows/builtin/deep-research-codebase/helpers/prompts.d.ts.map +1 -0
  88. package/dist/sdk/workflows/builtin/deep-research-codebase/helpers/scout.d.ts +1 -0
  89. package/dist/sdk/workflows/builtin/deep-research-codebase/helpers/scout.d.ts.map +1 -0
  90. package/dist/sdk/workflows/builtin/deep-research-codebase/opencode/index.d.ts +1 -0
  91. package/dist/sdk/workflows/builtin/deep-research-codebase/opencode/index.d.ts.map +1 -0
  92. package/dist/sdk/workflows/builtin/ralph/claude/index.d.ts +1 -0
  93. package/dist/sdk/workflows/builtin/ralph/claude/index.d.ts.map +1 -0
  94. package/dist/sdk/workflows/builtin/ralph/copilot/index.d.ts +1 -0
  95. package/dist/sdk/workflows/builtin/ralph/copilot/index.d.ts.map +1 -0
  96. package/dist/sdk/workflows/builtin/ralph/helpers/git.d.ts +1 -0
  97. package/dist/sdk/workflows/builtin/ralph/helpers/git.d.ts.map +1 -0
  98. package/dist/sdk/workflows/builtin/ralph/helpers/prompts.d.ts +1 -0
  99. package/dist/sdk/workflows/builtin/ralph/helpers/prompts.d.ts.map +1 -0
  100. package/dist/sdk/workflows/builtin/ralph/helpers/review.d.ts +2 -1
  101. package/dist/sdk/workflows/builtin/ralph/helpers/review.d.ts.map +1 -0
  102. package/dist/sdk/workflows/builtin/ralph/opencode/index.d.ts +1 -0
  103. package/dist/sdk/workflows/builtin/ralph/opencode/index.d.ts.map +1 -0
  104. package/dist/sdk/workflows/index.d.ts +14 -14
  105. package/dist/sdk/workflows/index.d.ts.map +1 -0
  106. package/dist/services/config/definitions.d.ts +85 -0
  107. package/dist/services/config/definitions.d.ts.map +1 -0
  108. package/dist/services/system/copy.d.ts +77 -0
  109. package/dist/services/system/copy.d.ts.map +1 -0
  110. package/dist/services/system/detect.d.ts +75 -0
  111. package/dist/services/system/detect.d.ts.map +1 -0
  112. package/package.json +15 -34
  113. package/src/cli.ts +11 -10
  114. package/src/commands/cli/chat/index.ts +11 -11
  115. package/src/commands/cli/chat.ts +1 -1
  116. package/src/commands/cli/config.ts +10 -9
  117. package/src/commands/cli/init/index.ts +11 -11
  118. package/src/commands/cli/init/onboarding.ts +4 -4
  119. package/src/commands/cli/init/scm.ts +5 -5
  120. package/src/commands/cli/init.ts +1 -1
  121. package/src/commands/cli/workflow-command.test.ts +19 -11
  122. package/src/commands/cli/workflow.test.ts +2 -2
  123. package/src/commands/cli/workflow.ts +6 -6
  124. package/src/lib/merge.ts +17 -31
  125. package/src/lib/path-root-guard.ts +2 -2
  126. package/src/lib/spawn.ts +13 -7
  127. package/src/scripts/bump-version.ts +1 -1
  128. package/src/scripts/constants.ts +2 -2
  129. package/src/sdk/components/header.tsx +21 -23
  130. package/src/sdk/components/hooks.ts +21 -0
  131. package/src/sdk/components/node-card.tsx +3 -2
  132. package/src/sdk/components/session-graph-panel.tsx +14 -18
  133. package/src/sdk/components/workflow-picker-panel.tsx +201 -216
  134. package/src/sdk/errors.test.ts +56 -0
  135. package/src/sdk/errors.ts +5 -0
  136. package/src/sdk/providers/claude.ts +279 -70
  137. package/src/sdk/providers/copilot.ts +17 -27
  138. package/src/sdk/providers/opencode.ts +17 -27
  139. package/src/sdk/runtime/discovery.ts +18 -18
  140. package/src/sdk/runtime/executor.test.ts +15 -48
  141. package/src/sdk/runtime/executor.ts +152 -121
  142. package/src/sdk/runtime/loader.ts +16 -21
  143. package/src/sdk/runtime/tmux.ts +95 -32
  144. package/src/sdk/types.ts +45 -0
  145. package/src/sdk/workflows/builtin/deep-research-codebase/claude/index.ts +27 -0
  146. package/src/sdk/workflows/builtin/deep-research-codebase/helpers/prompts.ts +25 -16
  147. package/src/sdk/workflows/builtin/deep-research-codebase/helpers/scout.ts +25 -24
  148. package/src/sdk/workflows/builtin/ralph/claude/index.ts +5 -0
  149. package/src/sdk/workflows/index.ts +3 -3
  150. package/src/services/config/atomic-config.ts +7 -8
  151. package/src/services/config/atomic-global-config.ts +9 -9
  152. package/src/services/config/config-path.ts +1 -1
  153. package/src/services/config/definitions.ts +3 -4
  154. package/src/services/config/index.ts +1 -1
  155. package/src/services/config/settings.ts +30 -36
  156. package/src/services/system/agents.ts +3 -3
  157. package/src/services/system/auto-sync.ts +9 -9
  158. package/src/services/system/copy.ts +9 -9
  159. package/src/services/system/file-lock.ts +2 -2
  160. package/src/services/system/install-ui.ts +2 -2
  161. package/src/services/system/skills.ts +1 -1
  162. package/src/theme/colors.ts +1 -1
  163. package/src/theme/logo.ts +1 -1
  164. package/tsconfig.json +3 -4
  165. package/dist/chunk-1gb5qxz9.js +0 -1
  166. package/dist/chunk-fdk7tact.js +0 -417
  167. package/dist/chunk-xkxndz5g.js +0 -1041
  168. package/dist/sdk/index.js +0 -52
  169. package/dist/sdk/workflows/builtin/ralph/claude/index.js +0 -96
  170. package/dist/sdk/workflows/builtin/ralph/copilot/index.js +0 -119
  171. package/dist/sdk/workflows/builtin/ralph/opencode/index.js +0 -148
  172. package/dist/sdk/workflows/index.js +0 -100
  173. package/src/commands/cli/chat/client.ts +0 -18
@@ -26,13 +26,18 @@
26
26
  * `{ workflow, inputs }` record if they confirm the run.
27
27
  */
28
28
 
29
- import { createCliRenderer, type CliRenderer } from "@opentui/core";
29
+ import {
30
+ createCliRenderer,
31
+ type CliRenderer,
32
+ type TextareaRenderable,
33
+ } from "@opentui/core";
30
34
  import {
31
35
  createRoot,
32
36
  useKeyboard,
33
37
  type Root,
34
38
  } from "@opentui/react";
35
- import { useState, useEffect, useMemo } from "react";
39
+ import { useState, useEffect, useMemo, useRef, useCallback } from "react";
40
+ import { useLatest } from "./hooks.ts";
36
41
  import { resolveTheme, type TerminalTheme } from "../runtime/theme.ts";
37
42
  import type { AgentType, WorkflowInput } from "../types.ts";
38
43
  import type { WorkflowWithMetadata } from "../runtime/discovery.ts";
@@ -166,11 +171,9 @@ interface ListEntry {
166
171
  section: Source;
167
172
  }
168
173
 
169
- interface ListRow {
170
- kind: "section" | "entry";
171
- source?: Source;
172
- entry?: ListEntry;
173
- }
174
+ type ListRow =
175
+ | { kind: "section"; source: Source }
176
+ | { kind: "entry"; entry: ListEntry };
174
177
 
175
178
  export function buildEntries(
176
179
  query: string,
@@ -259,13 +262,17 @@ function SectionLabel({
259
262
  function FilterBar({
260
263
  theme,
261
264
  query,
265
+ focused,
266
+ onInput,
262
267
  }: {
263
268
  theme: PickerTheme;
264
269
  query: string;
270
+ focused: boolean;
271
+ onInput: (value: string) => void;
265
272
  }) {
266
273
  return (
267
274
  <box
268
- height={3}
275
+ minHeight={3}
269
276
  border
270
277
  borderStyle="rounded"
271
278
  borderColor={theme.borderActive}
@@ -280,14 +287,16 @@ function FilterBar({
280
287
  <strong>❯ </strong>
281
288
  </span>
282
289
  </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>
290
+ <input
291
+ value={query}
292
+ focused={focused}
293
+ onInput={onInput}
294
+ textColor={theme.text}
295
+ backgroundColor={theme.backgroundPanel}
296
+ focusedBackgroundColor={theme.backgroundPanel}
297
+ focusedTextColor={theme.text}
298
+ flexGrow={1}
299
+ />
291
300
  </box>
292
301
  );
293
302
  }
@@ -311,12 +320,23 @@ function WorkflowList({
311
320
  );
312
321
  }
313
322
 
314
- let entryCounter = -1;
323
+ // Pre-compute entry indices so the render pass is side-effect-free.
324
+ const entryIndexByRow = useMemo(() => {
325
+ const map = new Map<number, number>();
326
+ let counter = 0;
327
+ for (let i = 0; i < rows.length; i++) {
328
+ if (rows[i]!.kind === "entry") {
329
+ map.set(i, counter++);
330
+ }
331
+ }
332
+ return map;
333
+ }, [rows]);
334
+
315
335
  return (
316
336
  <box flexDirection="column">
317
337
  {rows.map((row, i) => {
318
338
  if (row.kind === "section") {
319
- const src = row.source!;
339
+ const src = row.source;
320
340
  return (
321
341
  <box
322
342
  key={`s${i}`}
@@ -335,9 +355,9 @@ function WorkflowList({
335
355
  </box>
336
356
  );
337
357
  }
338
- entryCounter++;
339
- const isFocused = entryCounter === focusedEntryIdx;
340
- const wf = row.entry!.workflow;
358
+ const entryIdx = entryIndexByRow.get(i) ?? -1;
359
+ const isFocused = entryIdx === focusedEntryIdx;
360
+ const wf = row.entry.workflow;
341
361
 
342
362
  return (
343
363
  <box
@@ -437,11 +457,11 @@ function Preview({
437
457
  <box height={1} />
438
458
 
439
459
  <text>
440
- <span fg={theme[SOURCE_COLOR[wf.source as Source]]}>
441
- {SOURCE_DISPLAY[wf.source as Source]}
460
+ <span fg={theme[SOURCE_COLOR[wf.source]]}>
461
+ {SOURCE_DISPLAY[wf.source]}
442
462
  </span>
443
463
  <span fg={theme.textDim}>
444
- {" (" + SOURCE_DIR[wf.source as Source] + ")"}
464
+ {" (" + SOURCE_DIR[wf.source] + ")"}
445
465
  </span>
446
466
  </text>
447
467
 
@@ -508,97 +528,55 @@ function EmptyPreview({
508
528
 
509
529
  const TEXT_FIELD_LINES = 3;
510
530
 
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
531
 
551
532
  function TextAreaContent({
552
533
  theme,
553
534
  value,
554
535
  placeholder,
555
536
  focused,
556
- lines,
537
+ onChangeRef,
557
538
  }: {
558
539
  theme: PickerTheme;
559
540
  value: string;
560
541
  placeholder: string;
561
542
  focused: boolean;
562
- lines: number;
543
+ onChangeRef: React.RefObject<((value: string) => void) | null>;
563
544
  }) {
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 === "";
545
+ const ref = useRef<TextareaRenderable>(null);
546
+
547
+ // Sync external value → textarea when it diverges (e.g. initial value).
548
+ useEffect(() => {
549
+ if (ref.current && ref.current.plainText !== value) {
550
+ ref.current.setText(value);
551
+ }
552
+ }, [value]);
553
+
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]);
572
565
 
573
566
  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>
567
+ <textarea
568
+ ref={ref}
569
+ initialValue={value}
570
+ placeholder={placeholder}
571
+ focused={focused}
572
+ textColor={theme.text}
573
+ backgroundColor="transparent"
574
+ focusedBackgroundColor="transparent"
575
+ focusedTextColor={theme.text}
576
+ placeholderColor={theme.textDim}
577
+ wrapMode="word"
578
+ flexGrow={1}
579
+ />
602
580
  );
603
581
  }
604
582
 
@@ -607,33 +585,26 @@ function StringContent({
607
585
  value,
608
586
  placeholder,
609
587
  focused,
588
+ onInput,
610
589
  }: {
611
590
  theme: PickerTheme;
612
591
  value: string;
613
592
  placeholder: string;
614
593
  focused: boolean;
594
+ onInput: (value: string) => void;
615
595
  }) {
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
596
  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>
597
+ <input
598
+ value={value}
599
+ placeholder={placeholder}
600
+ focused={focused}
601
+ onInput={onInput}
602
+ textColor={theme.text}
603
+ backgroundColor="transparent"
604
+ focusedBackgroundColor="transparent"
605
+ focusedTextColor={theme.text}
606
+ flexGrow={1}
607
+ />
637
608
  );
638
609
  }
639
610
 
@@ -686,11 +657,15 @@ function Field({
686
657
  field,
687
658
  value,
688
659
  focused,
660
+ onInput,
661
+ onTextChangeRef,
689
662
  }: {
690
663
  theme: PickerTheme;
691
664
  field: WorkflowInput;
692
665
  value: string;
693
666
  focused: boolean;
667
+ onInput: (value: string) => void;
668
+ onTextChangeRef: React.RefObject<((value: string) => void) | null>;
694
669
  }) {
695
670
  const borderCol = focused ? theme.primary : theme.border;
696
671
  const bgCol = focused ? theme.backgroundPanel : theme.backgroundElement;
@@ -711,7 +686,7 @@ function Field({
711
686
  flexDirection="column"
712
687
  paddingLeft={2}
713
688
  paddingRight={2}
714
- height={boxHeight}
689
+ minHeight={boxHeight}
715
690
  justifyContent={field.type === "text" ? "flex-start" : "center"}
716
691
  title={` ${field.name} `}
717
692
  titleAlignment="left"
@@ -722,7 +697,7 @@ function Field({
722
697
  value={value}
723
698
  placeholder={field.placeholder ?? ""}
724
699
  focused={focused}
725
- lines={TEXT_FIELD_LINES}
700
+ onChangeRef={onTextChangeRef}
726
701
  />
727
702
  ) : field.type === "string" ? (
728
703
  <StringContent
@@ -730,6 +705,7 @@ function Field({
730
705
  value={value}
731
706
  placeholder={field.placeholder ?? ""}
732
707
  focused={focused}
708
+ onInput={onInput}
733
709
  />
734
710
  ) : field.type === "enum" ? (
735
711
  <EnumContent
@@ -762,6 +738,8 @@ function InputPhase({
762
738
  fields,
763
739
  values,
764
740
  focusedFieldIdx,
741
+ onFieldInput,
742
+ onTextChangeRef,
765
743
  }: {
766
744
  theme: PickerTheme;
767
745
  workflow: WorkflowWithMetadata;
@@ -769,6 +747,8 @@ function InputPhase({
769
747
  fields: WorkflowInput[];
770
748
  values: Record<string, string>;
771
749
  focusedFieldIdx: number;
750
+ onFieldInput: (fieldName: string, value: string) => void;
751
+ onTextChangeRef: React.RefObject<((value: string) => void) | null>;
772
752
  }) {
773
753
  const isStructured = workflow.inputs.length > 0;
774
754
 
@@ -801,11 +781,11 @@ function InputPhase({
801
781
  <span fg={theme.textDim}>{" · "}</span>
802
782
  <span fg={theme.mauve}>{agent}</span>
803
783
  <span fg={theme.textDim}>{" · "}</span>
804
- <span fg={theme[SOURCE_COLOR[workflow.source as Source]]}>
805
- {SOURCE_DISPLAY[workflow.source as Source]}
784
+ <span fg={theme[SOURCE_COLOR[workflow.source]]}>
785
+ {SOURCE_DISPLAY[workflow.source]}
806
786
  </span>
807
787
  <span fg={theme.textDim}>
808
- {" (" + SOURCE_DIR[workflow.source as Source] + ")"}
788
+ {" (" + SOURCE_DIR[workflow.source] + ")"}
809
789
  </span>
810
790
  </text>
811
791
  <box height={1} />
@@ -840,6 +820,8 @@ function InputPhase({
840
820
  field={f}
841
821
  value={values[f.name] ?? ""}
842
822
  focused={i === focusedFieldIdx}
823
+ onInput={(v) => onFieldInput(f.name, v)}
824
+ onTextChangeRef={onTextChangeRef}
843
825
  />
844
826
  ))}
845
827
  </box>
@@ -865,6 +847,7 @@ function ConfirmModal({
865
847
  justifyContent="center"
866
848
  alignItems="center"
867
849
  zIndex={100}
850
+ backgroundColor={theme.background}
868
851
  >
869
852
  <box
870
853
  border
@@ -914,6 +897,17 @@ function ConfirmModal({
914
897
  );
915
898
  }
916
899
 
900
+ // Stable hint arrays — no need for useMemo since they never change.
901
+ const PICK_HINTS: { key: string; label: string; dim?: boolean }[] = [
902
+ { key: "↑↓", label: "navigate" },
903
+ { key: "↵", label: "select" },
904
+ { key: "esc", label: "quit" },
905
+ ];
906
+ const CONFIRM_HINTS: { key: string; label: string; dim?: boolean }[] = [
907
+ { key: "y", label: "submit" },
908
+ { key: "n", label: "cancel" },
909
+ ];
910
+
917
911
  // Per-agent brand color used as the Header pill background.
918
912
  const AGENT_PILL_COLOR: Record<AgentType, keyof PickerTheme> = {
919
913
  claude: "warning",
@@ -998,7 +992,7 @@ function Statusline({
998
992
  : theme.success;
999
993
 
1000
994
  return (
1001
- <box height={1} flexDirection="row" backgroundColor={theme.surface}>
995
+ <box height={1} flexDirection="row" backgroundColor={theme.surface} position="relative" zIndex={101}>
1002
996
  <box
1003
997
  backgroundColor={modeColor}
1004
998
  paddingLeft={1}
@@ -1022,7 +1016,7 @@ function Statusline({
1022
1016
 
1023
1017
  <box paddingRight={2} alignItems="center" flexDirection="row">
1024
1018
  {hints.map((h, i) => (
1025
- <box key={i} flexDirection="row">
1019
+ <box key={h.key} flexDirection="row">
1026
1020
  {i > 0 ? (
1027
1021
  <text>
1028
1022
  <span fg={theme.textDim}>{" · "}</span>
@@ -1065,28 +1059,26 @@ export function WorkflowPicker({
1065
1059
  const [focusedFieldIdx, setFocusedFieldIdx] = useState(0);
1066
1060
  const [confirmOpen, setConfirmOpen] = useState(false);
1067
1061
 
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.
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);
1074
1065
 
1075
1066
  const entries = useMemo(() => buildEntries(query, workflows), [query, workflows]);
1076
1067
  const rows = useMemo(() => buildRows(entries, query), [entries, query]);
1077
1068
 
1069
+ // Clamp index when the list shrinks (e.g. typing filters entries out).
1078
1070
  useEffect(() => {
1079
- if (entryIdx >= entries.length) {
1080
- setEntryIdx(Math.max(0, entries.length - 1));
1081
- }
1082
- }, [entries.length, entryIdx]);
1071
+ setEntryIdx((i) => Math.min(i, Math.max(0, entries.length - 1)));
1072
+ }, [entries.length]);
1083
1073
 
1084
1074
  const focusedWf = entries[entryIdx]?.workflow;
1085
1075
 
1086
- const currentFields: WorkflowInput[] =
1087
- focusedWf && focusedWf.inputs.length > 0
1088
- ? [...focusedWf.inputs]
1089
- : [DEFAULT_PROMPT_INPUT];
1076
+ const currentFields = useMemo<WorkflowInput[]>(
1077
+ () => focusedWf && focusedWf.inputs.length > 0
1078
+ ? focusedWf.inputs.slice()
1079
+ : [DEFAULT_PROMPT_INPUT],
1080
+ [focusedWf],
1081
+ );
1090
1082
  const currentField = currentFields[focusedFieldIdx];
1091
1083
 
1092
1084
  const invalidFieldIndices = useMemo(() => {
@@ -1100,16 +1092,45 @@ export function WorkflowPicker({
1100
1092
  }, [currentFields, fieldValues]);
1101
1093
  const isFormValid = invalidFieldIndices.length === 0;
1102
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);
1122
+
1103
1123
  useKeyboard((key) => {
1104
1124
  if (key.ctrl && key.name === "c") {
1105
1125
  onCancel();
1106
1126
  return;
1107
1127
  }
1108
1128
 
1109
- if (confirmOpen) {
1129
+ if (confirmOpenRef.current) {
1110
1130
  if (key.name === "y" || key.name === "return") {
1111
- if (!focusedWf) return;
1112
- onSubmit({ workflow: focusedWf, inputs: { ...fieldValues } });
1131
+ const wf = focusedWfRef.current;
1132
+ if (!wf) return;
1133
+ onSubmit({ workflow: wf, inputs: { ...fieldValuesRef.current } });
1113
1134
  return;
1114
1135
  }
1115
1136
  if (key.name === "n" || key.name === "escape") {
@@ -1119,7 +1140,7 @@ export function WorkflowPicker({
1119
1140
  return;
1120
1141
  }
1121
1142
 
1122
- if (phase === "pick") {
1143
+ if (phaseRef.current === "pick") {
1123
1144
  if (key.name === "escape") {
1124
1145
  onCancel();
1125
1146
  return;
@@ -1130,15 +1151,16 @@ export function WorkflowPicker({
1130
1151
  }
1131
1152
  if (key.name === "down" || (key.ctrl && key.name === "j")) {
1132
1153
  setEntryIdx((i: number) =>
1133
- Math.min(entries.length - 1, i + 1),
1154
+ Math.min(entriesRef.current.length - 1, i + 1),
1134
1155
  );
1135
1156
  return;
1136
1157
  }
1137
1158
  if (key.name === "return") {
1138
- if (focusedWf) {
1159
+ const wf = focusedWfRef.current;
1160
+ if (wf) {
1139
1161
  const inputs: WorkflowInput[] =
1140
- focusedWf.inputs.length > 0
1141
- ? [...focusedWf.inputs]
1162
+ wf.inputs.length > 0
1163
+ ? [...wf.inputs]
1142
1164
  : [DEFAULT_PROMPT_INPUT];
1143
1165
  const initial: Record<string, string> = {};
1144
1166
  for (const f of inputs) {
@@ -1152,19 +1174,8 @@ export function WorkflowPicker({
1152
1174
  }
1153
1175
  return;
1154
1176
  }
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
- }
1177
+ // All other keys (typing, backspace, arrows) are handled by the
1178
+ // native <input> component in the FilterBar.
1168
1179
  return;
1169
1180
  }
1170
1181
 
@@ -1174,8 +1185,8 @@ export function WorkflowPicker({
1174
1185
  return;
1175
1186
  }
1176
1187
  if (key.ctrl && key.name === "s") {
1177
- if (!isFormValid) {
1178
- setFocusedFieldIdx(invalidFieldIndices[0]!);
1188
+ if (!isFormValidRef.current) {
1189
+ setFocusedFieldIdx(invalidFieldIndicesRef.current[0]!);
1179
1190
  return;
1180
1191
  }
1181
1192
  setConfirmOpen(true);
@@ -1183,79 +1194,51 @@ export function WorkflowPicker({
1183
1194
  }
1184
1195
  if (key.name === "tab") {
1185
1196
  setFocusedFieldIdx((i: number) => {
1186
- const len = currentFields.length;
1197
+ const len = currentFieldsRef.current.length;
1187
1198
  if (len <= 1) return 0;
1188
1199
  return key.shift ? (i - 1 + len) % len : (i + 1) % len;
1189
1200
  });
1190
1201
  return;
1191
1202
  }
1192
- if (!currentField) return;
1203
+ const field = currentFieldRef.current;
1204
+ if (!field) return;
1193
1205
 
1194
- if (currentField.type === "enum") {
1195
- const values = currentField.values ?? [];
1206
+ // Enum fields use left/right to cycle values.
1207
+ if (field.type === "enum") {
1208
+ const values = field.values ?? [];
1196
1209
  if (values.length === 0) return;
1197
1210
  if (key.name === "left" || key.name === "right") {
1198
1211
  setFieldValues((prev: Record<string, string>) => {
1199
- const cur = prev[currentField.name] ?? values[0] ?? "";
1212
+ const cur = prev[field.name] ?? values[0] ?? "";
1200
1213
  const idx = Math.max(0, values.indexOf(cur));
1201
1214
  const delta = key.name === "left" ? -1 : 1;
1202
1215
  const nextIdx = (idx + delta + values.length) % values.length;
1203
- return { ...prev, [currentField.name]: values[nextIdx] ?? "" };
1216
+ return { ...prev, [field.name]: values[nextIdx] ?? "" };
1204
1217
  });
1205
1218
  }
1206
1219
  return;
1207
1220
  }
1208
1221
 
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
- }));
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
+ if (field.type === "string" && key.name === "return") {
1226
+ setFocusedFieldIdx((i: number) =>
1227
+ Math.min(currentFieldsRef.current.length - 1, i + 1),
1228
+ );
1227
1229
  return;
1228
1230
  }
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
- }
1231
+ // All other keys for string/text fields (typing, backspace,
1232
+ // arrows, undo/redo) are handled by native <input>/<textarea>.
1243
1233
  });
1244
1234
 
1245
- const pickHints = [
1246
- { key: "↑↓", label: "navigate" },
1247
- { key: "↵", label: "select" },
1248
- { key: "esc", label: "quit" },
1249
- ];
1250
- const promptHints = [
1235
+ const pickHints = PICK_HINTS;
1236
+ const confirmHints = CONFIRM_HINTS;
1237
+ const promptHints = useMemo(() => [
1251
1238
  { key: "tab", label: "to navigate forward" },
1252
1239
  { key: "shift+tab", label: "to navigate backward" },
1253
1240
  { key: "ctrl+s", label: "to run", dim: !isFormValid },
1254
- ];
1255
- const confirmHints = [
1256
- { key: "y", label: "submit" },
1257
- { key: "n", label: "cancel" },
1258
- ];
1241
+ ], [isFormValid]);
1259
1242
 
1260
1243
  const hints = confirmOpen
1261
1244
  ? confirmHints
@@ -1288,7 +1271,7 @@ export function WorkflowPicker({
1288
1271
  paddingTop={1}
1289
1272
  >
1290
1273
  <box width={36} flexDirection="column">
1291
- <FilterBar theme={theme} query={query} />
1274
+ <FilterBar theme={theme} query={query} focused={phase === "pick"} onInput={setQuery} />
1292
1275
  <box height={1} />
1293
1276
  <WorkflowList
1294
1277
  theme={theme}
@@ -1312,7 +1295,9 @@ export function WorkflowPicker({
1312
1295
  agent={agent}
1313
1296
  fields={currentFields}
1314
1297
  values={fieldValues}
1315
- focusedFieldIdx={focusedFieldIdx}
1298
+ focusedFieldIdx={confirmOpen ? -1 : focusedFieldIdx}
1299
+ onFieldInput={onFieldInput}
1300
+ onTextChangeRef={textChangeRef}
1316
1301
  />
1317
1302
  ) : null}
1318
1303