@outputai/cli 0.8.2-next.e1cd79b.0 → 0.8.2-next.e658cc2.0
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/dist/api/generated/api.d.ts +79 -0
- package/dist/api/generated/api.js +32 -0
- package/dist/assets/docker/docker-compose-dev.yml +1 -1
- package/dist/commands/workflow/cost.d.ts +3 -2
- package/dist/commands/workflow/cost.js +4 -11
- package/dist/commands/workflow/cost.spec.js +1 -3
- package/dist/commands/workflow/dataset/generate.d.ts +1 -0
- package/dist/commands/workflow/dataset/generate.js +15 -6
- package/dist/commands/workflow/dataset/generate.spec.d.ts +1 -0
- package/dist/commands/workflow/dataset/generate.spec.js +69 -0
- package/dist/commands/workflow/dataset/list.d.ts +3 -1
- package/dist/commands/workflow/dataset/list.js +10 -14
- package/dist/commands/workflow/debug.d.ts +2 -6
- package/dist/commands/workflow/debug.js +10 -29
- package/dist/commands/workflow/debug.spec.js +2 -5
- package/dist/commands/workflow/generate.spec.js +2 -2
- package/dist/commands/workflow/list.d.ts +4 -1
- package/dist/commands/workflow/list.js +15 -19
- package/dist/commands/workflow/list.spec.js +2 -1
- package/dist/commands/workflow/result.d.ts +3 -4
- package/dist/commands/workflow/result.js +6 -15
- package/dist/commands/workflow/result.spec.js +2 -4
- package/dist/commands/workflow/run.d.ts +3 -2
- package/dist/commands/workflow/run.js +5 -12
- package/dist/commands/workflow/run.spec.js +18 -3
- package/dist/commands/workflow/runs/list.d.ts +3 -1
- package/dist/commands/workflow/runs/list.js +11 -15
- package/dist/commands/workflow/start.js +1 -1
- package/dist/commands/workflow/start.spec.js +53 -2
- package/dist/commands/workflow/status.d.ts +3 -4
- package/dist/commands/workflow/status.js +20 -30
- package/dist/commands/workflow/status.spec.js +2 -4
- package/dist/commands/workflow/test_eval.d.ts +4 -2
- package/dist/commands/workflow/test_eval.js +23 -23
- package/dist/commands/workflow/test_eval.spec.d.ts +1 -0
- package/dist/commands/workflow/test_eval.spec.js +121 -0
- package/dist/generated/framework_version.json +1 -1
- package/dist/hooks/init.d.ts +1 -0
- package/dist/hooks/init.js +40 -30
- package/dist/hooks/init.spec.js +28 -4
- package/dist/services/coding_agents.spec.js +10 -10
- package/dist/utils/format_workflow_result.spec.js +0 -13
- package/dist/utils/resolve_input.d.ts +1 -1
- package/dist/utils/resolve_input.js +2 -2
- package/dist/utils/scenario_resolver.d.ts +1 -1
- package/dist/utils/scenario_resolver.js +2 -2
- package/dist/utils/scenario_resolver.spec.js +14 -0
- package/dist/utils/trace_formatter.js +1 -2
- package/dist/utils/workflow_dir.d.ts +1 -1
- package/dist/utils/workflow_dir.js +2 -2
- package/dist/views/dev/modals/run_modal.d.ts +9 -0
- package/dist/views/dev/modals/run_modal.js +56 -56
- package/dist/views/dev/modals/run_modal.spec.d.ts +1 -0
- package/dist/views/dev/modals/run_modal.spec.js +34 -0
- package/dist/views/dev/panels/help_panel.js +1 -1
- package/dist/views/dev/utils/json_editor.d.ts +2 -0
- package/dist/views/dev/utils/json_editor.js +28 -15
- package/dist/views/dev/utils/json_editor.spec.js +16 -1
- package/oclif.manifest.json +114 -101
- package/package.json +5 -5
- package/dist/utils/constants.d.ts +0 -5
- package/dist/utils/constants.js +0 -4
- package/dist/utils/output_formatter.d.ts +0 -2
- package/dist/utils/output_formatter.js +0 -11
|
@@ -9,26 +9,40 @@ import { startWorkflow } from '#views/dev/services/run_workflow.js';
|
|
|
9
9
|
import { readScenario, writeScenario } from '#views/dev/services/scenario_io.js';
|
|
10
10
|
import { JsonEditor } from '#views/dev/utils/json_editor.js';
|
|
11
11
|
import { ModalFrame } from '#views/dev/modals/modal_frame.js';
|
|
12
|
-
const CUSTOM_SEED = { '': '' };
|
|
13
12
|
const SCENARIO_NAME_RE = /^[a-zA-Z0-9_-]+$/;
|
|
14
|
-
|
|
13
|
+
// Seed for a brand-new input — an empty "": "" pair reads friendlier than a bare {}.
|
|
14
|
+
const CUSTOM_SEED = { '': '' };
|
|
15
|
+
export const buildEntries = (scenarios) => {
|
|
15
16
|
const list = scenarios.map(s => ({
|
|
16
17
|
kind: 'scenario',
|
|
17
18
|
label: s,
|
|
18
19
|
scenarioName: s
|
|
19
20
|
}));
|
|
20
|
-
list.push({ kind: 'custom', label: '[
|
|
21
|
+
list.push({ kind: 'custom', label: '[Enter input]' });
|
|
21
22
|
return list;
|
|
22
23
|
};
|
|
24
|
+
export const validateScenarioName = (raw, existing) => {
|
|
25
|
+
const name = raw.trim();
|
|
26
|
+
if (!name) {
|
|
27
|
+
return 'Scenario name cannot be empty.';
|
|
28
|
+
}
|
|
29
|
+
if (!SCENARIO_NAME_RE.test(name)) {
|
|
30
|
+
return 'Use letters, numbers, dashes, and underscores only.';
|
|
31
|
+
}
|
|
32
|
+
if (existing.includes(name)) {
|
|
33
|
+
return `A scenario named '${name}' already exists.`;
|
|
34
|
+
}
|
|
35
|
+
return null;
|
|
36
|
+
};
|
|
23
37
|
const SELECT_SHORTCUTS = [
|
|
24
38
|
['↑/↓', 'navigate'],
|
|
25
39
|
['enter', 'run'],
|
|
26
40
|
['d', 'duplicate'],
|
|
27
41
|
['esc', 'cancel']
|
|
28
42
|
];
|
|
29
|
-
const
|
|
30
|
-
['enter', '
|
|
31
|
-
['esc', 'back']
|
|
43
|
+
const SAVE_SHORTCUTS = [
|
|
44
|
+
['enter', 'save & run'],
|
|
45
|
+
['esc', 'back to editor']
|
|
32
46
|
];
|
|
33
47
|
const ERROR_SHORTCUTS = [
|
|
34
48
|
{ key: 'enter', label: 'return' },
|
|
@@ -41,9 +55,11 @@ export const RunModal = ({ workflowName, workflowPath }) => {
|
|
|
41
55
|
const entries = useMemo(() => buildEntries(scenarios), [scenarios]);
|
|
42
56
|
const [mode, setMode] = useState('select');
|
|
43
57
|
const [index, setIndex] = useState(0);
|
|
44
|
-
const [
|
|
45
|
-
const [editSeed, setEditSeed] = useState(CUSTOM_SEED);
|
|
58
|
+
const [editSeed, setEditSeed] = useState({});
|
|
46
59
|
const [editFrameTitle, setEditFrameTitle] = useState('');
|
|
60
|
+
const [defaultSaveName, setDefaultSaveName] = useState('');
|
|
61
|
+
const [editName, setEditName] = useState('');
|
|
62
|
+
const [pendingValue, setPendingValue] = useState(null);
|
|
47
63
|
const [nameError, setNameError] = useState(null);
|
|
48
64
|
const [errorMessage, setErrorMessage] = useState(null);
|
|
49
65
|
const closeWith = (message, tone = 'info') => {
|
|
@@ -76,63 +92,55 @@ export const RunModal = ({ workflowName, workflowPath }) => {
|
|
|
76
92
|
setMode('error');
|
|
77
93
|
}
|
|
78
94
|
};
|
|
95
|
+
// Custom + duplicate both open the editor first; saving a scenario is opt-in (ctrl+s).
|
|
96
|
+
const startCustom = () => {
|
|
97
|
+
setEditSeed(CUSTOM_SEED);
|
|
98
|
+
setDefaultSaveName('');
|
|
99
|
+
setEditFrameTitle('Enter input');
|
|
100
|
+
setMode('edit_content');
|
|
101
|
+
};
|
|
79
102
|
const startDuplicate = async (scenarioName) => {
|
|
80
103
|
try {
|
|
81
104
|
const sourceContent = await readScenario(workflowName, scenarioName, workflowPath);
|
|
82
|
-
setEditName(`${scenarioName}_copy`);
|
|
83
105
|
setEditSeed(sourceContent);
|
|
106
|
+
setDefaultSaveName(`${scenarioName}_copy`);
|
|
84
107
|
setEditFrameTitle(`Duplicate '${scenarioName}'`);
|
|
85
|
-
|
|
86
|
-
setMode('edit_name');
|
|
108
|
+
setMode('edit_content');
|
|
87
109
|
}
|
|
88
110
|
catch (err) {
|
|
89
111
|
setErrorMessage(err instanceof Error ? err.message : String(err));
|
|
90
112
|
setMode('error');
|
|
91
113
|
}
|
|
92
114
|
};
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
setEditFrameTitle('New scenario');
|
|
97
|
-
setNameError(null);
|
|
98
|
-
setMode('edit_name');
|
|
115
|
+
// ctrl+r in the editor: run the payload as-is, nothing written to disk.
|
|
116
|
+
const runEphemeral = (value) => {
|
|
117
|
+
void submit(value, defaultSaveName || 'input');
|
|
99
118
|
};
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
}
|
|
108
|
-
if (scenarios.includes(name)) {
|
|
109
|
-
return `A scenario named '${name}' already exists.`;
|
|
110
|
-
}
|
|
111
|
-
return null;
|
|
119
|
+
// ctrl+s in the editor: keep the payload and ask for a name before saving + running.
|
|
120
|
+
const beginSave = (value) => {
|
|
121
|
+
setPendingValue(value);
|
|
122
|
+
setEditSeed(value);
|
|
123
|
+
setEditName(defaultSaveName);
|
|
124
|
+
setNameError(null);
|
|
125
|
+
setMode('name_for_save');
|
|
112
126
|
};
|
|
113
|
-
const
|
|
114
|
-
const
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
setNameError(writeError);
|
|
118
|
-
setMode('edit_name');
|
|
127
|
+
const confirmSave = async () => {
|
|
128
|
+
const validationError = validateScenarioName(editName, scenarios);
|
|
129
|
+
if (validationError) {
|
|
130
|
+
setNameError(validationError);
|
|
119
131
|
return;
|
|
120
132
|
}
|
|
121
133
|
setMode('submitting');
|
|
122
134
|
try {
|
|
123
|
-
const writtenPath = await writeScenario(workflowName,
|
|
135
|
+
const writtenPath = await writeScenario(workflowName, editName.trim(), pendingValue, workflowPath);
|
|
124
136
|
ui.pushToast(`Saved scenario at ${writtenPath}`, 'info');
|
|
125
|
-
await submit(
|
|
137
|
+
await submit(pendingValue, editName.trim());
|
|
126
138
|
}
|
|
127
139
|
catch (err) {
|
|
128
140
|
setErrorMessage(err instanceof Error ? err.message : String(err));
|
|
129
141
|
setMode('error');
|
|
130
142
|
}
|
|
131
143
|
};
|
|
132
|
-
const handleEditorCancel = () => {
|
|
133
|
-
// Bring the user back to the name step so they can adjust it or bail.
|
|
134
|
-
setMode('edit_name');
|
|
135
|
-
};
|
|
136
144
|
useInput((input, key) => {
|
|
137
145
|
if (mode === 'edit_content' || mode === 'submitting') {
|
|
138
146
|
return;
|
|
@@ -168,19 +176,13 @@ export const RunModal = ({ workflowName, workflowPath }) => {
|
|
|
168
176
|
}
|
|
169
177
|
return;
|
|
170
178
|
}
|
|
171
|
-
if (mode === '
|
|
179
|
+
if (mode === 'name_for_save') {
|
|
172
180
|
if (key.escape) {
|
|
173
|
-
setMode('
|
|
181
|
+
setMode('edit_content');
|
|
174
182
|
return;
|
|
175
183
|
}
|
|
176
184
|
if (key.return) {
|
|
177
|
-
|
|
178
|
-
if (err) {
|
|
179
|
-
setNameError(err);
|
|
180
|
-
return;
|
|
181
|
-
}
|
|
182
|
-
setNameError(null);
|
|
183
|
-
setMode('edit_content');
|
|
185
|
+
void confirmSave();
|
|
184
186
|
return;
|
|
185
187
|
}
|
|
186
188
|
if (key.backspace || key.delete) {
|
|
@@ -206,12 +208,10 @@ export const RunModal = ({ workflowName, workflowPath }) => {
|
|
|
206
208
|
}
|
|
207
209
|
});
|
|
208
210
|
if (mode === 'edit_content') {
|
|
209
|
-
return (_jsx(ModalFrame, { title: editFrameTitle, children: _jsx(JsonEditor, { seed: editSeed, title: `${
|
|
210
|
-
void handleEditorSubmit(value);
|
|
211
|
-
}, onCancel: handleEditorCancel }) }));
|
|
211
|
+
return (_jsx(ModalFrame, { title: editFrameTitle, children: _jsx(JsonEditor, { seed: editSeed, title: defaultSaveName ? `${defaultSaveName}.json` : 'input', isActive: true, onSubmit: runEphemeral, onSave: beginSave, onCancel: () => setMode('select') }) }));
|
|
212
212
|
}
|
|
213
|
-
if (mode === '
|
|
214
|
-
return (_jsxs(ModalFrame, { title:
|
|
213
|
+
if (mode === 'name_for_save') {
|
|
214
|
+
return (_jsxs(ModalFrame, { title: "Save & run", shortcuts: SAVE_SHORTCUTS, children: [_jsx(TextPrompt, { label: "Scenario name:", value: editName }), nameError ? (_jsx(Box, { marginTop: 1, children: _jsx(Text, { color: "red", children: nameError }) })) : null] }));
|
|
215
215
|
}
|
|
216
216
|
if (mode === 'submitting') {
|
|
217
217
|
return (_jsxs(ModalFrame, { title: `Run ${workflowName}`, children: [_jsx(Text, { color: "yellow", children: _jsx(Spinner, { type: "dots" }) }), _jsx(Text, { children: "\u00A0Starting workflow\u2026" })] }));
|
|
@@ -219,5 +219,5 @@ export const RunModal = ({ workflowName, workflowPath }) => {
|
|
|
219
219
|
if (mode === 'error') {
|
|
220
220
|
return (_jsx(ModalFrame, { title: `Run workflow "${workflowName}"`, shortcuts: ERROR_SHORTCUTS, children: _jsxs(Text, { color: "red", bold: true, children: ["\u2717 ", errorMessage ?? 'Something went wrong.'] }) }));
|
|
221
221
|
}
|
|
222
|
-
return (_jsx(ModalFrame, { title: `Run ${workflowName}`, shortcuts: SELECT_SHORTCUTS, children: _jsxs(Box, { flexDirection: "column", gap: 1, children: [_jsx(Text, { dimColor: true, children: scenarios.length === 0 ? 'No scenarios
|
|
222
|
+
return (_jsx(ModalFrame, { title: `Run ${workflowName}`, shortcuts: SELECT_SHORTCUTS, children: _jsxs(Box, { flexDirection: "column", gap: 1, children: [_jsx(Text, { dimColor: true, children: scenarios.length === 0 ? 'No saved scenarios. Enter input to run:' : 'Select a scenario:' }), _jsx(Box, { flexDirection: "column", children: entries.map((entry, i) => (_jsxs(Box, { children: [_jsx(SelectionIndicator, { selected: i === index }), _jsxs(Text, { bold: i === index, children: ["\u00A0", entry.label] })] }, `${entry.kind}-${entry.scenarioName ?? i}`))) })] }) }));
|
|
223
223
|
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { buildEntries, validateScenarioName } from './run_modal.js';
|
|
3
|
+
describe('buildEntries', () => {
|
|
4
|
+
it('lists scenarios then the custom-JSON entry', () => {
|
|
5
|
+
const entries = buildEntries(['basic', 'edge']);
|
|
6
|
+
expect(entries.map(e => e.label)).toEqual(['basic', 'edge', '[Enter input]']);
|
|
7
|
+
expect(entries[0]).toMatchObject({ kind: 'scenario', scenarioName: 'basic' });
|
|
8
|
+
expect(entries.at(-1)).toMatchObject({ kind: 'custom' });
|
|
9
|
+
});
|
|
10
|
+
it('offers the custom entry even with no saved scenarios', () => {
|
|
11
|
+
const entries = buildEntries([]);
|
|
12
|
+
expect(entries).toHaveLength(1);
|
|
13
|
+
expect(entries[0].kind).toBe('custom');
|
|
14
|
+
});
|
|
15
|
+
});
|
|
16
|
+
describe('validateScenarioName', () => {
|
|
17
|
+
it('rejects empty or whitespace-only names', () => {
|
|
18
|
+
expect(validateScenarioName('', [])).toMatch(/cannot be empty/);
|
|
19
|
+
expect(validateScenarioName(' ', [])).toMatch(/cannot be empty/);
|
|
20
|
+
});
|
|
21
|
+
it('rejects names with unsupported characters', () => {
|
|
22
|
+
expect(validateScenarioName('has space', [])).toMatch(/letters, numbers/);
|
|
23
|
+
expect(validateScenarioName('bad/name', [])).toMatch(/letters, numbers/);
|
|
24
|
+
});
|
|
25
|
+
it('rejects names that already exist', () => {
|
|
26
|
+
expect(validateScenarioName('basic', ['basic'])).toMatch(/already exists/);
|
|
27
|
+
});
|
|
28
|
+
it('trims before checking for duplicates', () => {
|
|
29
|
+
expect(validateScenarioName(' basic ', ['basic'])).toMatch(/already exists/);
|
|
30
|
+
});
|
|
31
|
+
it('accepts a unique name with allowed characters', () => {
|
|
32
|
+
expect(validateScenarioName('edge_case-1', ['basic'])).toBeNull();
|
|
33
|
+
});
|
|
34
|
+
});
|
|
@@ -10,7 +10,7 @@ const DOCS_URL = 'https://docs.output.ai';
|
|
|
10
10
|
export const Section = ({ children, title, direction = 'v' }) => (_jsxs(Box, { flexDirection: "column", gap: 1, children: [_jsx(Text, { bold: true, children: title }), _jsx(Box, { flexDirection: direction === 'v' ? 'column' : 'row', gap: 1, flexWrap: 'wrap', children: children })] }));
|
|
11
11
|
export const SubSection = ({ children, title }) => (_jsxs(Box, { flexDirection: "column", gap: 1, borderStyle: "single", borderColor: "blackBright", paddingLeft: 1, paddingRight: 1, children: [_jsx(Text, { italic: true, dimColor: true, children: title }), _jsx(Box, { flexDirection: "column", children: children })] }));
|
|
12
12
|
const KV = ({ label, value }) => (_jsxs(Box, { flexDirection: "row", children: [_jsx(Box, { width: 26, children: _jsx(Text, { children: label }) }), _jsx(Text, { bold: true, children: value })] }));
|
|
13
|
-
const RunFromCli = () => (_jsxs(Section, { title: "Running a workflow", children: [_jsxs(SubSection, { title: "From the CLI", children: [_jsx(InlineSnippet, { content: "npx output workflow run blog_evaluator paulgraham_hwh" }), _jsx(InlineSnippet, { content: 'npx output workflow run simple --input {"values":[1,2,3]}' }), _jsx(InlineSnippet, { content: "npx output workflow run simple --input scenario.json" })] }), _jsxs(SubSection, { title: "From the TUI", children: [_jsxs(Text, { children: ["Open Workflows tab, hover a workflow, press ", _jsx(Text, { bold: true, children: "r" }), "."] }), _jsx(Text, { children: "
|
|
13
|
+
const RunFromCli = () => (_jsxs(Section, { title: "Running a workflow", children: [_jsxs(SubSection, { title: "From the CLI", children: [_jsx(InlineSnippet, { content: "npx output workflow run blog_evaluator paulgraham_hwh" }), _jsx(InlineSnippet, { content: 'npx output workflow run simple --input {"values":[1,2,3]}' }), _jsx(InlineSnippet, { content: "npx output workflow run simple --input scenario.json" })] }), _jsxs(SubSection, { title: "From the TUI", children: [_jsxs(Text, { children: ["Open Workflows tab, hover a workflow, press ", _jsx(Text, { bold: true, children: "r" }), "."] }), _jsx(Text, { children: "Pick a saved scenario, or edit JSON with live validation." }), _jsxs(Text, { children: ["Press ", _jsx(Text, { bold: true, children: "ctrl+r" }), " to run as-is, or ", _jsx(Text, { bold: true, children: "ctrl+s" }), " to save it as a scenario first."] })] })] }));
|
|
14
14
|
const ServiceUrls = () => (_jsx(Section, { title: "Service URLs", children: _jsxs(SubSection, { title: "Where the services are available", children: [_jsx(KV, { label: "Temporal gRPC", value: "localhost:7233" }), _jsx(KV, { label: "Temporal UI", value: "http://localhost:8080" }), _jsx(KV, { label: "API server", value: "localhost:3001" }), _jsx(KV, { label: "Redis", value: "localhost:6379" })] }) }));
|
|
15
15
|
const UpdatingMigrating = () => (_jsxs(Section, { title: "Updating / Migrating", children: [_jsxs(SubSection, { title: "Update", children: [_jsx(Text, { wrap: "wrap", children: "Update the CLI to the latest published version:" }), _jsx(InlineSnippet, { content: "output update" })] }), _jsxs(SubSection, { title: "Migrate", children: [_jsx(Text, { wrap: "wrap", children: "Migrate a workflow project to the SDK version this CLI ships with:" }), _jsx(InlineSnippet, { content: "output migrate" }), _jsx(Text, { wrap: "wrap", children: "The migration walks `package.json` and project files, updates `@outputai/*` deps, and applies any code-mod steps the SDK ships with the new version." })] })] }));
|
|
16
16
|
const ClaudePlugins = () => (_jsx(Section, { title: "Claude Plugins", children: _jsxs(SubSection, { title: "Reinstall", children: [_jsx(Text, { wrap: "wrap", children: "Command `output init` already installs the Claude Code plugins (skills, commands, agents) into your project during scaffolding." }), _jsx(Text, { wrap: "wrap", children: "But if it is necessary to reinstall it, use the update command:" }), _jsx(InlineSnippet, { content: "output update --agents" }), _jsx(Text, { children: "This pulls the latest plugin bundle that ships with the installed CLI version." })] }) }));
|
|
@@ -11,11 +11,13 @@ export declare const tryParseJson: (text: string) => {
|
|
|
11
11
|
ok: false;
|
|
12
12
|
error: string;
|
|
13
13
|
};
|
|
14
|
+
export declare const initialCursor: (buffer: string) => number;
|
|
14
15
|
export declare const JsonEditor: React.FC<{
|
|
15
16
|
seed: unknown;
|
|
16
17
|
title: string;
|
|
17
18
|
isActive?: boolean;
|
|
18
19
|
onSubmit: (value: unknown) => void;
|
|
20
|
+
onSave?: (value: unknown) => void;
|
|
19
21
|
onCancel: () => void;
|
|
20
22
|
}>;
|
|
21
23
|
export {};
|
|
@@ -25,11 +25,17 @@ export const tryParseJson = (text) => {
|
|
|
25
25
|
return { ok: false, error: err instanceof Error ? err.message : String(err) };
|
|
26
26
|
}
|
|
27
27
|
};
|
|
28
|
+
// Start the cursor inside the first empty "" pair (e.g. the key of a fresh `{ "": "" }`
|
|
29
|
+
// seed) so the user can type immediately; otherwise sit at the end of the buffer.
|
|
30
|
+
export const initialCursor = (buffer) => {
|
|
31
|
+
const emptyPair = buffer.indexOf('""');
|
|
32
|
+
return emptyPair === -1 ? buffer.length : emptyPair + 1;
|
|
33
|
+
};
|
|
28
34
|
const VISIBLE_BUFFER = 6;
|
|
29
|
-
export const JsonEditor = ({ seed, title, isActive = true, onSubmit, onCancel }) => {
|
|
35
|
+
export const JsonEditor = ({ seed, title, isActive = true, onSubmit, onSave, onCancel }) => {
|
|
30
36
|
const initial = JSON.stringify(seed ?? {}, null, 2);
|
|
31
37
|
const [buffer, setBuffer] = useState(initial);
|
|
32
|
-
const [cursor, setCursor] = useState(initial
|
|
38
|
+
const [cursor, setCursor] = useState(() => initialCursor(initial));
|
|
33
39
|
const [status, setStatus] = useState(() => tryParseJson(initial));
|
|
34
40
|
const [submitMessage, setSubmitMessage] = useState(null);
|
|
35
41
|
useEffect(() => {
|
|
@@ -46,23 +52,30 @@ export const JsonEditor = ({ seed, title, isActive = true, onSubmit, onCancel })
|
|
|
46
52
|
setBuffer(b => b.slice(0, cursor - 1) + b.slice(cursor));
|
|
47
53
|
setCursor(c => Math.max(0, c - 1));
|
|
48
54
|
};
|
|
55
|
+
const commit = (handler) => {
|
|
56
|
+
const parsed = tryParseJson(buffer);
|
|
57
|
+
if (!parsed.ok) {
|
|
58
|
+
setSubmitMessage(`Invalid JSON — ${parsed.error}`);
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
try {
|
|
62
|
+
handler(JSON.parse(buffer));
|
|
63
|
+
}
|
|
64
|
+
catch (err) {
|
|
65
|
+
setSubmitMessage(err instanceof Error ? err.message : String(err));
|
|
66
|
+
}
|
|
67
|
+
};
|
|
49
68
|
useInput((input, key) => {
|
|
50
69
|
if (key.escape) {
|
|
51
70
|
onCancel();
|
|
52
71
|
return;
|
|
53
72
|
}
|
|
54
|
-
if (key.ctrl && input === '
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
try {
|
|
61
|
-
onSubmit(JSON.parse(buffer));
|
|
62
|
-
}
|
|
63
|
-
catch (err) {
|
|
64
|
-
setSubmitMessage(err instanceof Error ? err.message : String(err));
|
|
65
|
-
}
|
|
73
|
+
if (key.ctrl && input === 'r') {
|
|
74
|
+
commit(onSubmit);
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
if (key.ctrl && input === 's' && onSave) {
|
|
78
|
+
commit(onSave);
|
|
66
79
|
return;
|
|
67
80
|
}
|
|
68
81
|
if (key.return) {
|
|
@@ -113,5 +126,5 @@ export const JsonEditor = ({ seed, title, isActive = true, onSubmit, onCancel })
|
|
|
113
126
|
const at = line[cursorPos.col] ?? ' ';
|
|
114
127
|
const after = line.slice(cursorPos.col + 1);
|
|
115
128
|
return (_jsxs(Text, { bold: true, children: [_jsx(Text, { children: before }), _jsx(Text, { inverse: true, children: at }), _jsx(Text, { children: after })] }, lineIdx));
|
|
116
|
-
}) }), !status.ok && (_jsx(Box, { marginTop: 1, children: _jsx(Text, { color: "red", wrap: "truncate-end", children: status.error }) })), submitMessage && (_jsx(Box, { marginTop: 1, children: _jsx(Text, { color: "yellow", children: submitMessage }) })), _jsxs(Box, { marginTop: 1, columnGap: 2, children: [_jsxs(Box, { columnGap: 1, children: [_jsx(Text, { bold: true, children: "ctrl+s" }), _jsx(Text, { dimColor: true, children: "
|
|
129
|
+
}) }), !status.ok && (_jsx(Box, { marginTop: 1, children: _jsx(Text, { color: "red", wrap: "truncate-end", children: status.error }) })), submitMessage && (_jsx(Box, { marginTop: 1, children: _jsx(Text, { color: "yellow", children: submitMessage }) })), _jsxs(Box, { marginTop: 1, columnGap: 2, children: [_jsxs(Box, { columnGap: 1, children: [_jsx(Text, { bold: true, children: "ctrl+r" }), _jsx(Text, { dimColor: true, children: "run" })] }), onSave ? (_jsxs(Box, { columnGap: 1, children: [_jsx(Text, { bold: true, children: "ctrl+s" }), _jsx(Text, { dimColor: true, children: "save & run" })] })) : null, _jsxs(Box, { columnGap: 1, children: [_jsx(Text, { bold: true, children: "esc" }), _jsx(Text, { dimColor: true, children: "cancel" })] }), _jsxs(Box, { columnGap: 1, children: [_jsx(Text, { bold: true, children: "\u2191\u2193\u2190\u2192" }), _jsx(Text, { dimColor: true, children: "move" })] }), _jsxs(Box, { columnGap: 1, children: [_jsx(Text, { bold: true, children: "tab" }), _jsx(Text, { dimColor: true, children: "indent" })] })] })] }));
|
|
117
130
|
};
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, expect, it } from 'vitest';
|
|
2
|
-
import { cursorToPosition, positionToCursor, tryParseJson } from './json_editor.js';
|
|
2
|
+
import { cursorToPosition, initialCursor, positionToCursor, tryParseJson } from './json_editor.js';
|
|
3
3
|
describe('cursorToPosition', () => {
|
|
4
4
|
it('returns line 0 col 0 for an empty buffer', () => {
|
|
5
5
|
expect(cursorToPosition('', 0)).toEqual({ line: 0, col: 0 });
|
|
@@ -55,3 +55,18 @@ describe('tryParseJson', () => {
|
|
|
55
55
|
}
|
|
56
56
|
});
|
|
57
57
|
});
|
|
58
|
+
describe('initialCursor', () => {
|
|
59
|
+
it('lands inside the first empty "" pair so typing starts there', () => {
|
|
60
|
+
const buffer = JSON.stringify({ '': '' }, null, 2); // {\n "": ""\n}
|
|
61
|
+
const cursor = initialCursor(buffer);
|
|
62
|
+
expect(cursor).toBe(buffer.indexOf('""') + 1);
|
|
63
|
+
expect(buffer[cursor - 1]).toBe('"');
|
|
64
|
+
});
|
|
65
|
+
it('falls back to the end when there is no empty pair', () => {
|
|
66
|
+
const buffer = JSON.stringify({ values: [1, 2, 3] }, null, 2);
|
|
67
|
+
expect(initialCursor(buffer)).toBe(buffer.length);
|
|
68
|
+
});
|
|
69
|
+
it('returns 0 for an empty buffer', () => {
|
|
70
|
+
expect(initialCursor('')).toBe(0);
|
|
71
|
+
});
|
|
72
|
+
});
|