ralph-cli-sandboxed 0.2.9 → 0.4.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.
Files changed (73) hide show
  1. package/README.md +99 -15
  2. package/dist/commands/action.d.ts +7 -0
  3. package/dist/commands/action.js +276 -0
  4. package/dist/commands/chat.d.ts +8 -0
  5. package/dist/commands/chat.js +701 -0
  6. package/dist/commands/config.d.ts +1 -0
  7. package/dist/commands/config.js +51 -0
  8. package/dist/commands/daemon.d.ts +23 -0
  9. package/dist/commands/daemon.js +422 -0
  10. package/dist/commands/docker.js +82 -4
  11. package/dist/commands/fix-config.d.ts +4 -0
  12. package/dist/commands/fix-config.js +388 -0
  13. package/dist/commands/help.js +80 -0
  14. package/dist/commands/init.js +135 -1
  15. package/dist/commands/listen.d.ts +8 -0
  16. package/dist/commands/listen.js +280 -0
  17. package/dist/commands/notify.d.ts +7 -0
  18. package/dist/commands/notify.js +165 -0
  19. package/dist/commands/once.js +8 -8
  20. package/dist/commands/prd.js +2 -2
  21. package/dist/commands/run.js +25 -12
  22. package/dist/config/languages.json +4 -0
  23. package/dist/index.js +14 -0
  24. package/dist/providers/telegram.d.ts +39 -0
  25. package/dist/providers/telegram.js +256 -0
  26. package/dist/templates/macos-scripts.d.ts +42 -0
  27. package/dist/templates/macos-scripts.js +448 -0
  28. package/dist/tui/ConfigEditor.d.ts +7 -0
  29. package/dist/tui/ConfigEditor.js +313 -0
  30. package/dist/tui/components/ArrayEditor.d.ts +22 -0
  31. package/dist/tui/components/ArrayEditor.js +193 -0
  32. package/dist/tui/components/BooleanToggle.d.ts +19 -0
  33. package/dist/tui/components/BooleanToggle.js +43 -0
  34. package/dist/tui/components/EditorPanel.d.ts +50 -0
  35. package/dist/tui/components/EditorPanel.js +232 -0
  36. package/dist/tui/components/HelpPanel.d.ts +13 -0
  37. package/dist/tui/components/HelpPanel.js +69 -0
  38. package/dist/tui/components/JsonSnippetEditor.d.ts +24 -0
  39. package/dist/tui/components/JsonSnippetEditor.js +380 -0
  40. package/dist/tui/components/KeyValueEditor.d.ts +34 -0
  41. package/dist/tui/components/KeyValueEditor.js +261 -0
  42. package/dist/tui/components/ObjectEditor.d.ts +23 -0
  43. package/dist/tui/components/ObjectEditor.js +227 -0
  44. package/dist/tui/components/PresetSelector.d.ts +23 -0
  45. package/dist/tui/components/PresetSelector.js +58 -0
  46. package/dist/tui/components/Preview.d.ts +18 -0
  47. package/dist/tui/components/Preview.js +190 -0
  48. package/dist/tui/components/ScrollableContainer.d.ts +38 -0
  49. package/dist/tui/components/ScrollableContainer.js +77 -0
  50. package/dist/tui/components/SectionNav.d.ts +31 -0
  51. package/dist/tui/components/SectionNav.js +130 -0
  52. package/dist/tui/components/StringEditor.d.ts +21 -0
  53. package/dist/tui/components/StringEditor.js +29 -0
  54. package/dist/tui/hooks/useConfig.d.ts +16 -0
  55. package/dist/tui/hooks/useConfig.js +89 -0
  56. package/dist/tui/hooks/useTerminalSize.d.ts +21 -0
  57. package/dist/tui/hooks/useTerminalSize.js +48 -0
  58. package/dist/tui/utils/presets.d.ts +52 -0
  59. package/dist/tui/utils/presets.js +191 -0
  60. package/dist/tui/utils/validation.d.ts +49 -0
  61. package/dist/tui/utils/validation.js +198 -0
  62. package/dist/utils/chat-client.d.ts +144 -0
  63. package/dist/utils/chat-client.js +102 -0
  64. package/dist/utils/config.d.ts +52 -0
  65. package/dist/utils/daemon-client.d.ts +36 -0
  66. package/dist/utils/daemon-client.js +70 -0
  67. package/dist/utils/message-queue.d.ts +58 -0
  68. package/dist/utils/message-queue.js +133 -0
  69. package/dist/utils/notification.d.ts +28 -1
  70. package/dist/utils/notification.js +146 -20
  71. package/docs/MACOS-DEVELOPMENT.md +435 -0
  72. package/docs/RALPH-SETUP-TEMPLATE.md +262 -0
  73. package/package.json +6 -1
@@ -0,0 +1,313 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useState, useCallback, useMemo } from "react";
3
+ import { Box, Text, useApp, useInput } from "ink";
4
+ import { useConfig } from "./hooks/useConfig.js";
5
+ import { useTerminalSize } from "./hooks/useTerminalSize.js";
6
+ import { SectionNav } from "./components/SectionNav.js";
7
+ import { EditorPanel, getValueAtPath, inferFieldType } from "./components/EditorPanel.js";
8
+ import { StringEditor } from "./components/StringEditor.js";
9
+ import { BooleanToggle } from "./components/BooleanToggle.js";
10
+ import { ArrayEditor } from "./components/ArrayEditor.js";
11
+ import { ObjectEditor } from "./components/ObjectEditor.js";
12
+ import { KeyValueEditor } from "./components/KeyValueEditor.js";
13
+ import { JsonSnippetEditor } from "./components/JsonSnippetEditor.js";
14
+ import { Preview } from "./components/Preview.js";
15
+ import { HelpPanel } from "./components/HelpPanel.js";
16
+ import { PresetSelector } from "./components/PresetSelector.js";
17
+ import { validateConfig } from "./utils/validation.js";
18
+ import { sectionHasPresets, applyPreset } from "./utils/presets.js";
19
+ /**
20
+ * Set a value at a dot-notation path in an object (immutably).
21
+ */
22
+ function setValueAtPath(obj, path, value) {
23
+ const parts = path.split(".");
24
+ const result = JSON.parse(JSON.stringify(obj));
25
+ let current = result;
26
+ for (let i = 0; i < parts.length - 1; i++) {
27
+ const part = parts[i];
28
+ if (current[part] === undefined || current[part] === null) {
29
+ current[part] = {};
30
+ }
31
+ current = current[part];
32
+ }
33
+ const lastPart = parts[parts.length - 1];
34
+ current[lastPart] = value;
35
+ return result;
36
+ }
37
+ /**
38
+ * ConfigEditor is the main TUI application component.
39
+ * It provides a two-panel layout with section navigation and field editing.
40
+ */
41
+ export function ConfigEditor() {
42
+ const { exit } = useApp();
43
+ const terminalSize = useTerminalSize();
44
+ const { config, loading, error, hasChanges, saveConfig, updateConfig, } = useConfig();
45
+ // Calculate available height for scrollable content
46
+ // Reserve lines for: header (2), status message (1), footer (2), borders (2)
47
+ const availableHeight = Math.max(8, terminalSize.rows - 7);
48
+ // Nav panel gets slightly less height for its content
49
+ const navMaxHeight = Math.max(4, availableHeight - 4);
50
+ // Editor panel gets full available height
51
+ const editorMaxHeight = Math.max(6, availableHeight - 2);
52
+ // Navigation state
53
+ const [selectedSection, setSelectedSection] = useState("basic");
54
+ const [selectedField, setSelectedField] = useState(undefined);
55
+ const [focusPane, setFocusPane] = useState("nav");
56
+ // Preview panel visibility
57
+ const [previewVisible, setPreviewVisible] = useState(true);
58
+ // Help panel visibility
59
+ const [helpVisible, setHelpVisible] = useState(false);
60
+ // JSON edit mode - when true, use JsonSnippetEditor for complex fields
61
+ const [jsonEditMode, setJsonEditMode] = useState(false);
62
+ // Status message for feedback
63
+ const [statusMessage, setStatusMessage] = useState(null);
64
+ // Track which sections have shown preset selector (to avoid repeat prompts)
65
+ const [visitedSections, setVisitedSections] = useState(new Set(["basic"]));
66
+ // Validation errors
67
+ const validationErrors = useMemo(() => {
68
+ if (!config)
69
+ return [];
70
+ return validateConfig(config).errors;
71
+ }, [config]);
72
+ // Get the current field value and type for the field editor
73
+ const currentFieldValue = useMemo(() => {
74
+ if (!config || !selectedField)
75
+ return undefined;
76
+ return getValueAtPath(config, selectedField);
77
+ }, [config, selectedField]);
78
+ const currentFieldType = useMemo(() => {
79
+ return inferFieldType(currentFieldValue);
80
+ }, [currentFieldValue]);
81
+ // Get the label for the current field
82
+ const currentFieldLabel = useMemo(() => {
83
+ if (!selectedField)
84
+ return "";
85
+ const parts = selectedField.split(".");
86
+ const lastPart = parts[parts.length - 1];
87
+ return lastPart
88
+ .replace(/([A-Z])/g, " $1")
89
+ .replace(/^./, (str) => str.toUpperCase())
90
+ .trim();
91
+ }, [selectedField]);
92
+ // Handle section selection
93
+ const handleSelectSection = useCallback((sectionId) => {
94
+ setSelectedSection(sectionId);
95
+ setSelectedField(undefined);
96
+ // Check if this section has presets and hasn't been visited yet
97
+ if (sectionHasPresets(sectionId) && !visitedSections.has(sectionId)) {
98
+ setFocusPane("preset-selector");
99
+ }
100
+ else {
101
+ setFocusPane("editor");
102
+ }
103
+ }, [visitedSections]);
104
+ // Handle preset selection
105
+ const handleSelectPreset = useCallback((preset) => {
106
+ if (!config)
107
+ return;
108
+ // Apply the preset to the config
109
+ updateConfig((currentConfig) => {
110
+ return applyPreset(currentConfig, preset);
111
+ });
112
+ // Mark section as visited
113
+ setVisitedSections((prev) => new Set([...prev, selectedSection]));
114
+ // Show status message
115
+ setStatusMessage(`Applied "${preset.name}" preset`);
116
+ setTimeout(() => setStatusMessage(null), 2000);
117
+ // Move to editor
118
+ setFocusPane("editor");
119
+ }, [config, updateConfig, selectedSection]);
120
+ // Handle skipping preset selection
121
+ const handleSkipPreset = useCallback(() => {
122
+ // Mark section as visited
123
+ setVisitedSections((prev) => new Set([...prev, selectedSection]));
124
+ setFocusPane("editor");
125
+ }, [selectedSection]);
126
+ // Handle canceling preset selection (go back to nav)
127
+ const handleCancelPreset = useCallback(() => {
128
+ setFocusPane("nav");
129
+ }, []);
130
+ // Handle field selection
131
+ const handleSelectField = useCallback((fieldPath, useJsonEditor = false) => {
132
+ setSelectedField(fieldPath);
133
+ setJsonEditMode(useJsonEditor);
134
+ setFocusPane("field-editor");
135
+ }, []);
136
+ // Handle going back from editor to nav
137
+ const handleBack = useCallback(() => {
138
+ if (focusPane === "field-editor") {
139
+ setSelectedField(undefined);
140
+ setFocusPane("editor");
141
+ }
142
+ else if (focusPane === "editor") {
143
+ setFocusPane("nav");
144
+ }
145
+ }, [focusPane]);
146
+ // Handle field value confirmation
147
+ const handleFieldConfirm = useCallback((newValue) => {
148
+ if (!selectedField)
149
+ return;
150
+ updateConfig((currentConfig) => {
151
+ return setValueAtPath(currentConfig, selectedField, newValue);
152
+ });
153
+ setSelectedField(undefined);
154
+ setJsonEditMode(false);
155
+ setFocusPane("editor");
156
+ setStatusMessage("Field updated");
157
+ setTimeout(() => setStatusMessage(null), 2000);
158
+ }, [selectedField, updateConfig]);
159
+ // Handle field edit cancel
160
+ const handleFieldCancel = useCallback(() => {
161
+ setSelectedField(undefined);
162
+ setJsonEditMode(false);
163
+ setFocusPane("editor");
164
+ }, []);
165
+ // Handle save
166
+ const handleSave = useCallback(() => {
167
+ // Validate before saving
168
+ if (!config) {
169
+ setStatusMessage("No configuration to save");
170
+ setTimeout(() => setStatusMessage(null), 2000);
171
+ return;
172
+ }
173
+ const validation = validateConfig(config);
174
+ if (!validation.valid) {
175
+ const errorCount = validation.errors.length;
176
+ setStatusMessage(`Validation failed: ${errorCount} error${errorCount > 1 ? "s" : ""} found`);
177
+ setTimeout(() => setStatusMessage(null), 3000);
178
+ return;
179
+ }
180
+ const success = saveConfig();
181
+ if (success) {
182
+ setStatusMessage("Configuration saved!");
183
+ }
184
+ else {
185
+ setStatusMessage("Failed to save configuration");
186
+ }
187
+ setTimeout(() => setStatusMessage(null), 2000);
188
+ }, [saveConfig, config]);
189
+ // Handle quit
190
+ const handleQuit = useCallback(() => {
191
+ exit();
192
+ }, [exit]);
193
+ // Toggle preview visibility
194
+ const togglePreview = useCallback(() => {
195
+ setPreviewVisible((prev) => !prev);
196
+ }, []);
197
+ // Toggle help visibility
198
+ const toggleHelp = useCallback(() => {
199
+ setHelpVisible((prev) => !prev);
200
+ }, []);
201
+ // Global keyboard shortcuts (S for Save, Q for Quit, Tab for preview toggle, ? for help)
202
+ useInput((input, key) => {
203
+ // Only handle global shortcuts when not in field editor or preset selector
204
+ if (focusPane === "field-editor" || focusPane === "preset-selector")
205
+ return;
206
+ // ? key toggles help panel (takes priority when help is visible)
207
+ if (input === "?") {
208
+ toggleHelp();
209
+ return;
210
+ }
211
+ // When help panel is visible, don't process other shortcuts
212
+ if (helpVisible)
213
+ return;
214
+ if (input.toUpperCase() === "S") {
215
+ handleSave();
216
+ }
217
+ else if (input.toUpperCase() === "Q") {
218
+ handleQuit();
219
+ }
220
+ else if (key.tab) {
221
+ // Tab to toggle JSON preview visibility
222
+ togglePreview();
223
+ }
224
+ else if (input === "l" || key.rightArrow) {
225
+ // l or right arrow to move focus to editor
226
+ if (focusPane === "nav") {
227
+ setFocusPane("editor");
228
+ }
229
+ }
230
+ else if (input === "h" || key.leftArrow) {
231
+ // h or left arrow to move focus to nav
232
+ if (focusPane === "editor") {
233
+ setFocusPane("nav");
234
+ }
235
+ }
236
+ else if (input === "p") {
237
+ // p to open preset selector if available for current section
238
+ if (focusPane === "editor" && sectionHasPresets(selectedSection)) {
239
+ setFocusPane("preset-selector");
240
+ }
241
+ }
242
+ }, { isActive: focusPane !== "field-editor" && focusPane !== "preset-selector" });
243
+ // Render loading state
244
+ if (loading) {
245
+ return (_jsxs(Box, { flexDirection: "column", padding: 1, children: [_jsx(Text, { color: "cyan", bold: true, children: "ralph config" }), _jsx(Text, { dimColor: true, children: "Loading configuration..." })] }));
246
+ }
247
+ // Render error state
248
+ if (error || !config) {
249
+ return (_jsxs(Box, { flexDirection: "column", padding: 1, children: [_jsx(Text, { color: "cyan", bold: true, children: "ralph config" }), _jsxs(Text, { color: "red", children: ["Error: ", error || "Failed to load configuration"] }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: "Press Q to quit" }) })] }));
250
+ }
251
+ // Render field editor overlay if a field is selected
252
+ const renderFieldEditor = () => {
253
+ if (!selectedField || focusPane !== "field-editor")
254
+ return null;
255
+ // Use JsonSnippetEditor for complex fields when J key was pressed or for certain field types
256
+ if (jsonEditMode) {
257
+ return (_jsx(JsonSnippetEditor, { label: currentFieldLabel, value: currentFieldValue, onConfirm: handleFieldConfirm, onCancel: handleFieldCancel, isFocused: true, maxHeight: editorMaxHeight, maxWidth: terminalSize.columns }));
258
+ }
259
+ switch (currentFieldType) {
260
+ case "string":
261
+ return (_jsx(StringEditor, { label: currentFieldLabel, value: currentFieldValue || "", onConfirm: handleFieldConfirm, onCancel: handleFieldCancel, isFocused: true }));
262
+ case "boolean":
263
+ return (_jsx(BooleanToggle, { label: currentFieldLabel, value: currentFieldValue, onConfirm: handleFieldConfirm, onCancel: handleFieldCancel, isFocused: true }));
264
+ case "number":
265
+ return (_jsx(StringEditor, { label: currentFieldLabel, value: String(currentFieldValue || ""), onConfirm: (val) => handleFieldConfirm(Number(val) || 0), onCancel: handleFieldCancel, isFocused: true, placeholder: "Enter a number" }));
266
+ case "array":
267
+ return (_jsx(ArrayEditor, { label: currentFieldLabel, items: currentFieldValue || [], onConfirm: handleFieldConfirm, onCancel: handleFieldCancel, isFocused: true, maxHeight: editorMaxHeight }));
268
+ case "object":
269
+ // Handle object type - flatten to string key-value pairs
270
+ const objValue = (currentFieldValue || {});
271
+ const stringEntries = {};
272
+ for (const [k, v] of Object.entries(objValue)) {
273
+ stringEntries[k] = typeof v === "string" ? v : JSON.stringify(v);
274
+ }
275
+ // Check if this is a notification provider config field
276
+ const isNotificationProvider = selectedField &&
277
+ (selectedField === "notifications.ntfy" ||
278
+ selectedField === "notifications.pushover" ||
279
+ selectedField === "notifications.gotify");
280
+ if (isNotificationProvider) {
281
+ // Extract provider name from field path
282
+ const providerName = selectedField.split(".").pop() || "";
283
+ return (_jsx(KeyValueEditor, { label: currentFieldLabel, entries: stringEntries, providerName: providerName, onConfirm: (entries) => {
284
+ // For notification providers, keep values as strings
285
+ handleFieldConfirm(entries);
286
+ }, onCancel: handleFieldCancel, isFocused: true }));
287
+ }
288
+ return (_jsx(ObjectEditor, { label: currentFieldLabel, entries: stringEntries, onConfirm: (entries) => {
289
+ // Try to parse JSON values back if they look like objects
290
+ const parsedEntries = {};
291
+ for (const [k, v] of Object.entries(entries)) {
292
+ try {
293
+ if (v.startsWith("{") || v.startsWith("[")) {
294
+ parsedEntries[k] = JSON.parse(v);
295
+ }
296
+ else {
297
+ parsedEntries[k] = v;
298
+ }
299
+ }
300
+ catch {
301
+ parsedEntries[k] = v;
302
+ }
303
+ }
304
+ handleFieldConfirm(parsedEntries);
305
+ }, onCancel: handleFieldCancel, isFocused: true, maxHeight: editorMaxHeight }));
306
+ default:
307
+ // Unknown type - use string editor
308
+ return (_jsx(StringEditor, { label: currentFieldLabel, value: String(currentFieldValue || ""), onConfirm: handleFieldConfirm, onCancel: handleFieldCancel, isFocused: true }));
309
+ }
310
+ };
311
+ return (_jsxs(Box, { flexDirection: "column", children: [helpVisible && (_jsx(HelpPanel, { visible: helpVisible, onClose: toggleHelp })), _jsxs(Box, { marginBottom: 1, justifyContent: "space-between", children: [_jsxs(Box, { children: [_jsx(Text, { color: "cyan", bold: true, children: "ralph config" }), hasChanges && _jsx(Text, { color: "yellow", children: " (unsaved changes)" })] }), _jsxs(Box, { children: [_jsx(Text, { dimColor: true, children: "[S] Save" }), _jsx(Text, { dimColor: true, children: " | " }), _jsx(Text, { dimColor: true, children: "[Q] Quit" }), _jsx(Text, { dimColor: true, children: " | " }), _jsx(Text, { dimColor: true, children: "[?] Help" })] })] }), statusMessage && (_jsx(Box, { marginBottom: 1, children: _jsx(Text, { color: statusMessage.includes("Failed") || statusMessage.includes("Validation") ? "red" : "green", children: statusMessage }) })), focusPane === "field-editor" ? (_jsx(Box, { children: renderFieldEditor() })) : focusPane === "preset-selector" ? (_jsx(Box, { children: _jsx(PresetSelector, { sectionId: selectedSection, config: config, onSelectPreset: handleSelectPreset, onSkip: handleSkipPreset, onCancel: handleCancelPreset, isFocused: true }) })) : (_jsxs(Box, { children: [_jsx(Box, { width: 20, children: _jsx(SectionNav, { selectedSection: selectedSection, onSelectSection: handleSelectSection, isFocused: focusPane === "nav", maxHeight: navMaxHeight }) }), _jsx(Box, { flexGrow: 1, children: _jsx(EditorPanel, { config: config, selectedSection: selectedSection, selectedField: selectedField, onSelectField: handleSelectField, onBack: handleBack, isFocused: focusPane === "editor", validationErrors: validationErrors, maxHeight: editorMaxHeight }) }), _jsx(Preview, { config: config, selectedSection: selectedSection, visible: previewVisible })] })), _jsx(Box, { marginTop: 1, children: _jsxs(Text, { dimColor: true, children: [focusPane === "nav" && "j/k: navigate | Enter: select | l/→: editor | Tab: toggle preview", focusPane === "editor" && "j/k: navigate | Enter: edit | J: JSON | h/←: nav | Tab: preview | p: presets", focusPane === "field-editor" && "Follow editor hints", focusPane === "preset-selector" && "j/k: navigate | Enter: select | Esc: back"] }) })] }));
312
+ }
313
+ export default ConfigEditor;
@@ -0,0 +1,22 @@
1
+ import React from "react";
2
+ export interface ArrayEditorProps {
3
+ /** The label to display for this array field */
4
+ label: string;
5
+ /** The current array items */
6
+ items: string[];
7
+ /** Called when the user confirms the edit (Enter) */
8
+ onConfirm: (newItems: string[]) => void;
9
+ /** Called when the user cancels the edit (Esc) */
10
+ onCancel: () => void;
11
+ /** Whether this editor has focus */
12
+ isFocused?: boolean;
13
+ /** Maximum height for the items list (for scrolling) */
14
+ maxHeight?: number;
15
+ }
16
+ /**
17
+ * ArrayEditor component for editing arrays of strings.
18
+ * Shows numbered list with edit/delete options.
19
+ * Supports adding new items, reordering with move up/down keys, and scrolling for long lists.
20
+ */
21
+ export declare function ArrayEditor({ label, items, onConfirm, onCancel, isFocused, maxHeight, }: ArrayEditorProps): React.ReactElement;
22
+ export default ArrayEditor;
@@ -0,0 +1,193 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useState, useCallback, useEffect, useMemo } from "react";
3
+ import { Box, Text, useInput } from "ink";
4
+ import TextInput from "ink-text-input";
5
+ /**
6
+ * ArrayEditor component for editing arrays of strings.
7
+ * Shows numbered list with edit/delete options.
8
+ * Supports adding new items, reordering with move up/down keys, and scrolling for long lists.
9
+ */
10
+ export function ArrayEditor({ label, items, onConfirm, onCancel, isFocused = true, maxHeight = 10, }) {
11
+ const [editItems, setEditItems] = useState([...items]);
12
+ const [highlightedIndex, setHighlightedIndex] = useState(0);
13
+ const [scrollOffset, setScrollOffset] = useState(0);
14
+ const [mode, setMode] = useState("list");
15
+ const [editText, setEditText] = useState("");
16
+ const [editingIndex, setEditingIndex] = useState(-1);
17
+ // Total items including "+ Add item" option
18
+ const totalOptions = editItems.length + 1;
19
+ // Auto-scroll to keep highlighted item visible
20
+ useEffect(() => {
21
+ if (highlightedIndex < scrollOffset) {
22
+ setScrollOffset(highlightedIndex);
23
+ }
24
+ else if (highlightedIndex >= scrollOffset + maxHeight) {
25
+ setScrollOffset(highlightedIndex - maxHeight + 1);
26
+ }
27
+ }, [highlightedIndex, scrollOffset, maxHeight]);
28
+ // Navigation handlers
29
+ const handleNavigateUp = useCallback(() => {
30
+ setHighlightedIndex((prev) => (prev > 0 ? prev - 1 : totalOptions - 1));
31
+ }, [totalOptions]);
32
+ const handleNavigateDown = useCallback(() => {
33
+ setHighlightedIndex((prev) => (prev < totalOptions - 1 ? prev + 1 : 0));
34
+ }, [totalOptions]);
35
+ const handlePageUp = useCallback(() => {
36
+ const newIndex = Math.max(0, highlightedIndex - maxHeight);
37
+ setHighlightedIndex(newIndex);
38
+ }, [highlightedIndex, maxHeight]);
39
+ const handlePageDown = useCallback(() => {
40
+ const newIndex = Math.min(totalOptions - 1, highlightedIndex + maxHeight);
41
+ setHighlightedIndex(newIndex);
42
+ }, [highlightedIndex, maxHeight, totalOptions]);
43
+ // Move item up in the list
44
+ const handleMoveUp = useCallback(() => {
45
+ if (highlightedIndex > 0 && highlightedIndex < editItems.length) {
46
+ const newItems = [...editItems];
47
+ const temp = newItems[highlightedIndex - 1];
48
+ newItems[highlightedIndex - 1] = newItems[highlightedIndex];
49
+ newItems[highlightedIndex] = temp;
50
+ setEditItems(newItems);
51
+ setHighlightedIndex(highlightedIndex - 1);
52
+ }
53
+ }, [highlightedIndex, editItems]);
54
+ // Move item down in the list
55
+ const handleMoveDown = useCallback(() => {
56
+ if (highlightedIndex < editItems.length - 1) {
57
+ const newItems = [...editItems];
58
+ const temp = newItems[highlightedIndex + 1];
59
+ newItems[highlightedIndex + 1] = newItems[highlightedIndex];
60
+ newItems[highlightedIndex] = temp;
61
+ setEditItems(newItems);
62
+ setHighlightedIndex(highlightedIndex + 1);
63
+ }
64
+ }, [highlightedIndex, editItems]);
65
+ // Delete the highlighted item
66
+ const handleDelete = useCallback(() => {
67
+ if (highlightedIndex < editItems.length) {
68
+ const newItems = editItems.filter((_, i) => i !== highlightedIndex);
69
+ setEditItems(newItems);
70
+ // Adjust highlighted index if needed
71
+ if (highlightedIndex >= newItems.length && newItems.length > 0) {
72
+ setHighlightedIndex(newItems.length - 1);
73
+ }
74
+ else if (newItems.length === 0) {
75
+ setHighlightedIndex(0);
76
+ }
77
+ }
78
+ }, [highlightedIndex, editItems]);
79
+ // Start editing an item
80
+ const handleStartEdit = useCallback(() => {
81
+ if (highlightedIndex < editItems.length) {
82
+ setEditText(editItems[highlightedIndex]);
83
+ setEditingIndex(highlightedIndex);
84
+ setMode("edit");
85
+ }
86
+ else {
87
+ // Add new item
88
+ setEditText("");
89
+ setMode("add");
90
+ }
91
+ }, [highlightedIndex, editItems]);
92
+ // Confirm edit/add
93
+ const handleTextSubmit = useCallback(() => {
94
+ const trimmedText = editText.trim();
95
+ if (trimmedText) {
96
+ if (mode === "add") {
97
+ setEditItems([...editItems, trimmedText]);
98
+ setHighlightedIndex(editItems.length);
99
+ }
100
+ else if (mode === "edit") {
101
+ const newItems = [...editItems];
102
+ newItems[editingIndex] = trimmedText;
103
+ setEditItems(newItems);
104
+ }
105
+ }
106
+ setMode("list");
107
+ setEditText("");
108
+ setEditingIndex(-1);
109
+ }, [editText, mode, editItems, editingIndex]);
110
+ // Cancel edit/add
111
+ const handleTextCancel = useCallback(() => {
112
+ setMode("list");
113
+ setEditText("");
114
+ setEditingIndex(-1);
115
+ }, []);
116
+ // Handle keyboard input for list mode
117
+ useInput((input, key) => {
118
+ if (!isFocused || mode !== "list")
119
+ return;
120
+ // j/k or arrow keys for navigation
121
+ if (input === "j" || key.downArrow) {
122
+ handleNavigateDown();
123
+ }
124
+ else if (input === "k" || key.upArrow) {
125
+ handleNavigateUp();
126
+ }
127
+ else if (key.pageUp) {
128
+ handlePageUp();
129
+ }
130
+ else if (key.pageDown) {
131
+ handlePageDown();
132
+ }
133
+ else if (key.return || input === "e") {
134
+ // Enter or 'e' to edit/add
135
+ handleStartEdit();
136
+ }
137
+ else if (input === "d" || key.delete) {
138
+ // 'd' or Delete to remove
139
+ handleDelete();
140
+ }
141
+ else if (input === "K" || (key.shift && key.upArrow)) {
142
+ // Shift+K or Shift+Up to move up
143
+ handleMoveUp();
144
+ }
145
+ else if (input === "J" || (key.shift && key.downArrow)) {
146
+ // Shift+J or Shift+Down to move down
147
+ handleMoveDown();
148
+ }
149
+ else if (key.escape) {
150
+ onCancel();
151
+ }
152
+ else if (input === "s" || input === "S") {
153
+ // 's' to save and confirm
154
+ onConfirm(editItems);
155
+ }
156
+ }, { isActive: isFocused && mode === "list" });
157
+ // Handle keyboard input for text editing mode
158
+ useInput((_input, key) => {
159
+ if (!isFocused || mode === "list")
160
+ return;
161
+ if (key.escape) {
162
+ handleTextCancel();
163
+ }
164
+ }, { isActive: isFocused && mode !== "list" });
165
+ // Calculate visible items based on scroll offset
166
+ const visibleItems = useMemo(() => {
167
+ const allItems = [...editItems, { isAddOption: true }];
168
+ const endIndex = Math.min(scrollOffset + maxHeight, allItems.length);
169
+ return allItems.slice(scrollOffset, endIndex);
170
+ }, [editItems, scrollOffset, maxHeight]);
171
+ // Check if we have overflow
172
+ const canScrollUp = scrollOffset > 0;
173
+ const canScrollDown = scrollOffset + maxHeight < totalOptions;
174
+ const hasOverflow = totalOptions > maxHeight;
175
+ // Render text input mode (add or edit)
176
+ if (mode === "add" || mode === "edit") {
177
+ return (_jsxs(Box, { flexDirection: "column", borderStyle: "single", borderColor: "cyan", paddingX: 1, children: [_jsx(Box, { marginBottom: 1, children: _jsx(Text, { bold: true, color: "cyan", children: mode === "add" ? "Add New Item" : `Edit Item ${editingIndex + 1}` }) }), _jsxs(Box, { children: [_jsxs(Text, { dimColor: true, children: [">", " "] }), _jsx(TextInput, { value: editText, onChange: setEditText, onSubmit: handleTextSubmit, focus: isFocused })] }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: "Enter: confirm | Esc: cancel" }) })] }));
178
+ }
179
+ // Render list mode
180
+ return (_jsxs(Box, { flexDirection: "column", borderStyle: "single", borderColor: "cyan", paddingX: 1, children: [_jsxs(Box, { marginBottom: 1, children: [_jsxs(Text, { bold: true, color: "cyan", children: ["Edit: ", label] }), _jsxs(Text, { dimColor: true, children: [" (", editItems.length, " items)"] }), hasOverflow && (_jsxs(Text, { dimColor: true, children: [" [", highlightedIndex + 1, "/", totalOptions, "]"] }))] }), hasOverflow && (_jsx(Box, { children: _jsx(Text, { color: canScrollUp ? "cyan" : "gray", dimColor: !canScrollUp, children: canScrollUp ? " ▲ more" : "" }) })), editItems.length === 0 && scrollOffset === 0 ? (_jsx(Box, { marginBottom: 1, children: _jsx(Text, { dimColor: true, italic: true, children: "No items" }) })) : (visibleItems.map((item, visibleIndex) => {
181
+ // Check if this is the "Add item" option
182
+ if (typeof item === "object" && "isAddOption" in item) {
183
+ const actualIndex = editItems.length;
184
+ const isHighlighted = actualIndex === highlightedIndex;
185
+ return (_jsxs(Box, { children: [_jsx(Text, { color: isHighlighted ? "green" : undefined, children: isHighlighted ? "▸ " : " " }), _jsx(Text, { bold: isHighlighted, color: isHighlighted ? "green" : "gray", inverse: isHighlighted, children: "+ Add item" })] }, "add-item"));
186
+ }
187
+ // Regular item
188
+ const actualIndex = scrollOffset + visibleIndex;
189
+ const isHighlighted = actualIndex === highlightedIndex;
190
+ return (_jsxs(Box, { children: [_jsx(Text, { color: isHighlighted ? "cyan" : undefined, children: isHighlighted ? "▸ " : " " }), _jsxs(Text, { dimColor: true, children: [String(actualIndex + 1).padStart(2, " "), ". "] }), _jsx(Text, { bold: isHighlighted, color: isHighlighted ? "cyan" : undefined, inverse: isHighlighted, children: item })] }, `item-${actualIndex}`));
191
+ })), hasOverflow && (_jsx(Box, { children: _jsx(Text, { color: canScrollDown ? "cyan" : "gray", dimColor: !canScrollDown, children: canScrollDown ? " ▼ more" : "" }) })), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsx(Text, { dimColor: true, children: "j/k: navigate | Enter/e: edit | d: delete" }), _jsxs(Text, { dimColor: true, children: ["J/K: reorder | s: save | Esc: cancel", hasOverflow && " | PgUp/Dn: scroll"] })] })] }));
192
+ }
193
+ export default ArrayEditor;
@@ -0,0 +1,19 @@
1
+ import React from "react";
2
+ export interface BooleanToggleProps {
3
+ /** The label to display for this field */
4
+ label: string;
5
+ /** The current value of the boolean */
6
+ value: boolean;
7
+ /** Called when the user confirms the edit (Enter) */
8
+ onConfirm: (newValue: boolean) => void;
9
+ /** Called when the user cancels the edit (Esc) */
10
+ onCancel: () => void;
11
+ /** Whether this editor has focus */
12
+ isFocused?: boolean;
13
+ }
14
+ /**
15
+ * BooleanToggle component provides a toggle UI for boolean fields.
16
+ * Space or arrow keys to toggle, Enter to confirm, Esc to cancel.
17
+ */
18
+ export declare function BooleanToggle({ label, value, onConfirm, onCancel, isFocused, }: BooleanToggleProps): React.ReactElement;
19
+ export default BooleanToggle;
@@ -0,0 +1,43 @@
1
+ import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
2
+ import { useState, useCallback } from "react";
3
+ import { Box, Text, useInput } from "ink";
4
+ /**
5
+ * BooleanToggle component provides a toggle UI for boolean fields.
6
+ * Space or arrow keys to toggle, Enter to confirm, Esc to cancel.
7
+ */
8
+ export function BooleanToggle({ label, value, onConfirm, onCancel, isFocused = true, }) {
9
+ const [editValue, setEditValue] = useState(value);
10
+ // Toggle the value
11
+ const handleToggle = useCallback(() => {
12
+ setEditValue((prev) => !prev);
13
+ }, []);
14
+ // Confirm the value
15
+ const handleConfirm = useCallback(() => {
16
+ onConfirm(editValue);
17
+ }, [editValue, onConfirm]);
18
+ // Handle keyboard input
19
+ useInput((input, key) => {
20
+ if (!isFocused)
21
+ return;
22
+ // Space, left/right arrows, or 't'/'f' to toggle
23
+ if (input === " " || key.leftArrow || key.rightArrow || input === "t" || input === "f") {
24
+ if (input === "t") {
25
+ setEditValue(true);
26
+ }
27
+ else if (input === "f") {
28
+ setEditValue(false);
29
+ }
30
+ else {
31
+ handleToggle();
32
+ }
33
+ }
34
+ else if (key.return) {
35
+ handleConfirm();
36
+ }
37
+ else if (key.escape) {
38
+ onCancel();
39
+ }
40
+ }, { isActive: isFocused });
41
+ return (_jsxs(Box, { flexDirection: "column", borderStyle: "single", borderColor: "cyan", paddingX: 1, children: [_jsx(Box, { marginBottom: 1, children: _jsxs(Text, { bold: true, color: "cyan", children: ["Edit: ", label] }) }), _jsxs(Box, { children: [_jsxs(Text, { dimColor: true, children: [">", " "] }), _jsxs(Box, { marginRight: 2, children: [_jsx(Text, { color: !editValue ? "red" : undefined, bold: !editValue, inverse: !editValue, children: !editValue ? " ● " : " ○ " }), _jsx(Text, { color: !editValue ? "red" : "gray", bold: !editValue, children: "false" })] }), _jsxs(Box, { children: [_jsx(Text, { color: editValue ? "green" : undefined, bold: editValue, inverse: editValue, children: editValue ? " ● " : " ○ " }), _jsx(Text, { color: editValue ? "green" : "gray", bold: editValue, children: "true" })] })] }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: "Space/\u2190/\u2192: toggle | t/f: set | Enter: confirm | Esc: cancel" }) })] }));
42
+ }
43
+ export default BooleanToggle;
@@ -0,0 +1,50 @@
1
+ import React from "react";
2
+ import type { RalphConfig } from "../../utils/config.js";
3
+ import type { ValidationError } from "../utils/validation.js";
4
+ /**
5
+ * Field types for determining which editor to render.
6
+ */
7
+ export type FieldType = "string" | "boolean" | "number" | "array" | "object" | "unknown";
8
+ /**
9
+ * Field schema describes a configuration field for editing.
10
+ */
11
+ export interface FieldSchema {
12
+ path: string;
13
+ label: string;
14
+ type: FieldType;
15
+ description?: string;
16
+ required?: boolean;
17
+ }
18
+ /**
19
+ * Get the value at a dot-notation path from an object.
20
+ */
21
+ export declare function getValueAtPath(obj: unknown, path: string): unknown;
22
+ /**
23
+ * Determine the type of a field based on its current value.
24
+ */
25
+ export declare function inferFieldType(value: unknown): FieldType;
26
+ export interface EditorPanelProps {
27
+ /** The current configuration */
28
+ config: RalphConfig | null;
29
+ /** Currently selected section ID */
30
+ selectedSection: string;
31
+ /** Currently selected field path (for nested editing) */
32
+ selectedField?: string;
33
+ /** Callback when a field is selected for editing (useJsonEditor=true for J shortcut) */
34
+ onSelectField: (fieldPath: string, useJsonEditor?: boolean) => void;
35
+ /** Callback when navigating back (Esc) */
36
+ onBack: () => void;
37
+ /** Whether this component has focus for keyboard input */
38
+ isFocused?: boolean;
39
+ /** Validation errors to display inline */
40
+ validationErrors?: ValidationError[];
41
+ /** Maximum height for the fields list (for scrolling) */
42
+ maxHeight?: number;
43
+ }
44
+ /**
45
+ * EditorPanel component displays fields for the selected config section.
46
+ * It shows a breadcrumb of the current path and lists editable fields.
47
+ * Supports scrolling for long content with Page Up/Down and scroll indicators.
48
+ */
49
+ export declare function EditorPanel({ config, selectedSection, selectedField, onSelectField, onBack, isFocused, validationErrors, maxHeight, }: EditorPanelProps): React.ReactElement;
50
+ export default EditorPanel;