@bastani/atomic 0.5.12-3 → 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-
|
|
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.
|
|
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",
|
|
@@ -81,8 +81,6 @@
|
|
|
81
81
|
"commander": "^14.0.3",
|
|
82
82
|
"ignore": "^7.0.5",
|
|
83
83
|
"react": "^19.2.5",
|
|
84
|
-
"react-devtools-core": "^7.0.1",
|
|
85
|
-
"ws": "^8.18.0",
|
|
86
84
|
"yaml": "^2.8.3",
|
|
87
85
|
"zod": "^4.3.6"
|
|
88
86
|
}
|
|
@@ -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
|
|
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
|
|
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
|
|
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>
|
|
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
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
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
|
|
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
|
-
() =>
|
|
1294
|
-
|
|
1295
|
-
|
|
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
|
-
/**
|
|
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
|
+
}
|