@bastani/atomic 0.5.12-2 → 0.5.12-4

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-2",
3
+ "version": "0.5.12-4",
4
4
  "description": "Configuration management CLI and SDK for coding agents",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -71,7 +71,7 @@
71
71
  "typescript-language-server": "^5.1.3"
72
72
  },
73
73
  "dependencies": {
74
- "@anthropic-ai/claude-agent-sdk": "^0.2.105",
74
+ "@anthropic-ai/claude-agent-sdk": "^0.2.107",
75
75
  "@clack/prompts": "^1.2.0",
76
76
  "@commander-js/extra-typings": "^14.0.0",
77
77
  "@github/copilot-sdk": "^0.2.2",
@@ -391,7 +391,7 @@ async function runPickerMode(
391
391
 
392
392
  /**
393
393
  * Execute a workflow selected via the picker. The picker already stores
394
- * free-form prompts under the `prompt` key (via `DEFAULT_PROMPT_INPUT`),
394
+ * free-form prompts under the canonical `prompt` key,
395
395
  * so we can hand the inputs record straight through — no split between
396
396
  * "prompt" and "structured inputs" is needed.
397
397
  */
@@ -30,6 +30,7 @@ import {
30
30
  createCliRenderer,
31
31
  type CliRenderer,
32
32
  type KeyEvent,
33
+ type ScrollBoxRenderable,
33
34
  type TextareaRenderable,
34
35
  } from "@opentui/core";
35
36
  import {
@@ -42,6 +43,11 @@ import { useLatest } from "./hooks.ts";
42
43
  import { resolveTheme, type TerminalTheme } from "../runtime/theme.ts";
43
44
  import type { AgentType, WorkflowInput } from "../types.ts";
44
45
  import type { WorkflowWithMetadata } from "../runtime/discovery.ts";
46
+ import {
47
+ DEFAULT_PROMPT_FIELDS,
48
+ isFreeformPromptSchema,
49
+ normalizePickerInputs,
50
+ } from "../workflow-inputs.ts";
45
51
  import { ErrorBoundary } from "./error-boundary.tsx";
46
52
 
47
53
  // ─── Theme ──────────────────────────────────────
@@ -117,19 +123,6 @@ export interface WorkflowPickerResult {
117
123
  inputs: Record<string, string>;
118
124
  }
119
125
 
120
- /** Fallback field used when a workflow has no structured input schema. */
121
- const DEFAULT_PROMPT_INPUT: WorkflowInput = {
122
- name: "prompt",
123
- type: "text",
124
- required: true,
125
- description: "what do you want this workflow to do?",
126
- placeholder: "describe your task…",
127
- };
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
-
133
126
  // ─── Helpers ────────────────────────────────────
134
127
 
135
128
  const SOURCE_DISPLAY: Record<Source, string> = {
@@ -477,8 +470,7 @@ const Preview = memo(function Preview({
477
470
  wf: WorkflowWithMetadata;
478
471
  }) {
479
472
  const theme = usePickerTheme();
480
- const args: readonly WorkflowInput[] =
481
- wf.inputs.length > 0 ? wf.inputs : DEFAULT_FIELDS;
473
+ const args = normalizePickerInputs(wf.inputs);
482
474
 
483
475
  return (
484
476
  <box
@@ -690,12 +682,14 @@ function EnumContent({
690
682
  }
691
683
 
692
684
  const Field = memo(function Field({
685
+ id,
693
686
  field,
694
687
  value,
695
688
  focused,
696
689
  onFieldInput,
697
690
  onTextChangeRef,
698
691
  }: {
692
+ id?: string;
699
693
  field: WorkflowInput;
700
694
  value: string;
701
695
  focused: boolean;
@@ -719,7 +713,7 @@ const Field = memo(function Field({
719
713
  );
720
714
 
721
715
  return (
722
- <box flexDirection="column">
716
+ <box id={id} flexDirection="column">
723
717
  <box
724
718
  border
725
719
  borderStyle="rounded"
@@ -788,7 +782,55 @@ function InputPhase({
788
782
  onTextChangeRef: React.RefObject<((value: string) => void) | null>;
789
783
  }) {
790
784
  const theme = usePickerTheme();
791
- const isStructured = workflow.inputs.length > 0;
785
+ const isStructured = !isFreeformPromptSchema(workflow.inputs);
786
+ const scrollboxRef = useRef<ScrollBoxRenderable>(null);
787
+ const [scrollTop, setScrollTop] = useState(0);
788
+
789
+ // Auto-scroll to keep the focused field visible.
790
+ // Sync scrollTop immediately so the visibility check below
791
+ // marks the field as visible on the same render pass.
792
+ useEffect(() => {
793
+ const sb = scrollboxRef.current;
794
+ const field = fields[focusedFieldIdx];
795
+ if (!sb || !field) return;
796
+ sb.scrollChildIntoView(`field-${field.name}`);
797
+ setScrollTop(sb.scrollTop);
798
+ }, [focusedFieldIdx, fields]);
799
+
800
+ // Sync scrollTop on every OpenTUI render frame via renderBefore.
801
+ // This replaces a polling timer — it fires at the renderer's native
802
+ // frame rate so the focused field defocuses within one frame of
803
+ // scrolling out of view, preventing the terminal cursor from
804
+ // bleeding into the fixed header above.
805
+ const syncScrollFrame = useCallback(function (this: unknown) {
806
+ const sb = scrollboxRef.current;
807
+ if (!sb) return;
808
+ setScrollTop((prev) => {
809
+ const cur = sb.scrollTop;
810
+ return cur !== prev ? cur : prev;
811
+ });
812
+ }, []);
813
+
814
+ // The bordered content box (where the cursor lives) must be fully
815
+ // inside the viewport. If even one row is clipped the field loses
816
+ // focus so the cursor can never land in a clipped row.
817
+ const isFocusedFieldVisible = useMemo(() => {
818
+ const sb = scrollboxRef.current;
819
+ if (!sb) return true;
820
+ const vpH = sb.viewport.height;
821
+ if (vpH <= 0) return true;
822
+ let y = 0;
823
+ for (let i = 0; i < fields.length; i++) {
824
+ const f = fields[i]!;
825
+ const inputH = f.type === "text" ? TEXT_FIELD_LINES + 2 : 3;
826
+ if (i === focusedFieldIdx) {
827
+ return y >= scrollTop && y + inputH <= scrollTop + vpH;
828
+ }
829
+ // Caption row (1) + spacer row (1) below the bordered box.
830
+ y += inputH + 2;
831
+ }
832
+ return true;
833
+ }, [fields, focusedFieldIdx, scrollTop]);
792
834
 
793
835
  return (
794
836
  <box
@@ -839,7 +881,7 @@ function InputPhase({
839
881
  <box flexDirection="row" height={1}>
840
882
  <text>
841
883
  <span fg={theme.textDim}>
842
- <strong>{isStructured ? "INPUTS" : "PROMPT"}</strong>
884
+ <strong>INPUTS</strong>
843
885
  </span>
844
886
  </text>
845
887
  <box flexGrow={1} />
@@ -851,20 +893,48 @@ function InputPhase({
851
893
  </box>
852
894
  <box height={1} />
853
895
 
854
- {fields.map((f, i) => (
855
- <Field
856
- key={f.name}
857
- field={f}
858
- value={values[f.name] ?? ""}
859
- focused={i === focusedFieldIdx}
860
- onFieldInput={onFieldInput}
861
- onTextChangeRef={
862
- f.type === "text" && i === focusedFieldIdx
863
- ? onTextChangeRef
864
- : undefined
865
- }
866
- />
867
- ))}
896
+ <scrollbox
897
+ ref={scrollboxRef}
898
+ scrollY
899
+ viewportCulling
900
+ flexGrow={1}
901
+ renderBefore={syncScrollFrame}
902
+ style={{
903
+ rootOptions: {
904
+ backgroundColor: "transparent",
905
+ border: false,
906
+ },
907
+ contentOptions: {
908
+ flexDirection: "column",
909
+ },
910
+ verticalScrollbarOptions: {
911
+ showArrows: false,
912
+ trackOptions: {
913
+ foregroundColor: theme.border,
914
+ backgroundColor: theme.backgroundElement,
915
+ },
916
+ },
917
+ }}
918
+ >
919
+ {fields.map((f, i) => {
920
+ const active = i === focusedFieldIdx && isFocusedFieldVisible;
921
+ return (
922
+ <Field
923
+ key={f.name}
924
+ id={`field-${f.name}`}
925
+ field={f}
926
+ value={values[f.name] ?? ""}
927
+ focused={active}
928
+ onFieldInput={onFieldInput}
929
+ onTextChangeRef={
930
+ f.type === "text" && active
931
+ ? onTextChangeRef
932
+ : undefined
933
+ }
934
+ />
935
+ );
936
+ })}
937
+ </scrollbox>
868
938
  </box>
869
939
  );
870
940
  }
@@ -1171,10 +1241,7 @@ function usePickerKeyboard(state: PickerKeyboardState): void {
1171
1241
  key.stopPropagation();
1172
1242
  const wf = focusedWfRef.current;
1173
1243
  if (wf) {
1174
- const inputs: readonly WorkflowInput[] =
1175
- wf.inputs.length > 0
1176
- ? wf.inputs
1177
- : DEFAULT_FIELDS;
1244
+ const inputs = normalizePickerInputs(wf.inputs);
1178
1245
  const initial: Record<string, string> = {};
1179
1246
  for (const f of inputs) {
1180
1247
  initial[f.name] =
@@ -1290,9 +1357,10 @@ export function WorkflowPicker({
1290
1357
  const focusedWf = entries[clampedEntryIdx]?.workflow;
1291
1358
 
1292
1359
  const currentFields = useMemo<readonly WorkflowInput[]>(
1293
- () => focusedWf && focusedWf.inputs.length > 0
1294
- ? focusedWf.inputs
1295
- : DEFAULT_FIELDS,
1360
+ () =>
1361
+ focusedWf
1362
+ ? normalizePickerInputs(focusedWf.inputs)
1363
+ : DEFAULT_PROMPT_FIELDS,
1296
1364
  [focusedWf],
1297
1365
  );
1298
1366
  const currentField = currentFields[focusedFieldIdx];
@@ -13,6 +13,7 @@ import { readdir } from "node:fs/promises";
13
13
  import { homedir } from "node:os";
14
14
  import ignore from "ignore";
15
15
  import type { AgentType, WorkflowInput } from "../types.ts";
16
+ import { normalizePickerInputs } from "../workflow-inputs.ts";
16
17
  import { WorkflowLoader } from "./loader.ts";
17
18
 
18
19
  export interface DiscoveredWorkflow {
@@ -288,12 +289,12 @@ export async function findWorkflow(
288
289
  export interface WorkflowWithMetadata extends DiscoveredWorkflow {
289
290
  /** Workflow description, empty string when none was declared. */
290
291
  description: string;
291
- /** Declared input schema, empty array for free-form workflows. */
292
+ /** Picker-ready input schema; free-form workflows materialize a prompt field. */
292
293
  inputs: readonly WorkflowInput[];
293
294
  }
294
295
 
295
296
  /**
296
- * Load metadata (description + inputs) for a batch of discovered workflows.
297
+ * Load metadata (description + picker-ready inputs) for a batch of discovered workflows.
297
298
  *
298
299
  * Workflows that fail to import are **skipped silently** so one broken
299
300
  * entry can never prevent the picker from rendering. Callers that need
@@ -311,7 +312,7 @@ export async function loadWorkflowsMetadata(
311
312
  return {
312
313
  ...wf,
313
314
  description: loaded.value.definition.description,
314
- inputs: loaded.value.definition.inputs,
315
+ inputs: normalizePickerInputs(loaded.value.definition.inputs),
315
316
  };
316
317
  }),
317
318
  );
@@ -320,4 +321,3 @@ export async function loadWorkflowsMetadata(
320
321
  );
321
322
  }
322
323
 
323
-
@@ -0,0 +1,54 @@
1
+ import type { WorkflowInput } from "./types.ts";
2
+
3
+ /** Canonical free-form prompt field used by the interactive picker. */
4
+ export const DEFAULT_PROMPT_INPUT: Readonly<WorkflowInput> = Object.freeze({
5
+ name: "prompt",
6
+ type: "text",
7
+ required: true,
8
+ description: "what do you want this workflow to do?",
9
+ placeholder: "describe your task…",
10
+ });
11
+
12
+ /** Stable single-field schema for free-form workflows. */
13
+ export const DEFAULT_PROMPT_FIELDS: readonly WorkflowInput[] = Object.freeze([
14
+ DEFAULT_PROMPT_INPUT,
15
+ ]);
16
+
17
+ /**
18
+ * Materialize the picker-facing input schema.
19
+ *
20
+ * Runtime workflow definitions keep `inputs: []` for free-form workflows so
21
+ * the CLI can preserve positional-prompt semantics. The interactive picker,
22
+ * however, benefits from a single normalized shape where every workflow has at
23
+ * least one field to render.
24
+ */
25
+ export function normalizePickerInputs(
26
+ inputs: readonly WorkflowInput[],
27
+ ): readonly WorkflowInput[] {
28
+ return inputs.length > 0 ? inputs : DEFAULT_PROMPT_FIELDS;
29
+ }
30
+
31
+ /**
32
+ * Whether a picker-facing schema represents the canonical free-form prompt.
33
+ *
34
+ * This accepts both the raw `[]` runtime shape and the normalized
35
+ * `[DEFAULT_PROMPT_INPUT]` picker shape so callers can treat both as the same
36
+ * conceptual "free-form prompt" mode.
37
+ */
38
+ export function isFreeformPromptSchema(
39
+ inputs: readonly WorkflowInput[],
40
+ ): boolean {
41
+ if (inputs.length === 0) return true;
42
+ if (inputs.length !== 1) return false;
43
+
44
+ const field = inputs[0];
45
+ return (
46
+ field?.name === DEFAULT_PROMPT_INPUT.name &&
47
+ field.type === DEFAULT_PROMPT_INPUT.type &&
48
+ field.required === DEFAULT_PROMPT_INPUT.required &&
49
+ field.description === DEFAULT_PROMPT_INPUT.description &&
50
+ field.placeholder === DEFAULT_PROMPT_INPUT.placeholder &&
51
+ field.default === DEFAULT_PROMPT_INPUT.default &&
52
+ field.values === DEFAULT_PROMPT_INPUT.values
53
+ );
54
+ }