ralph-cli-sandboxed 0.3.0 → 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.
- package/dist/commands/action.d.ts +7 -0
- package/dist/commands/action.js +276 -0
- package/dist/commands/chat.js +95 -7
- package/dist/commands/config.js +6 -18
- package/dist/commands/fix-config.d.ts +4 -0
- package/dist/commands/fix-config.js +388 -0
- package/dist/commands/help.js +17 -0
- package/dist/commands/init.js +89 -2
- package/dist/commands/listen.js +50 -9
- package/dist/commands/prd.js +2 -2
- package/dist/config/languages.json +4 -0
- package/dist/index.js +4 -0
- package/dist/providers/telegram.d.ts +6 -2
- package/dist/providers/telegram.js +68 -2
- package/dist/templates/macos-scripts.d.ts +42 -0
- package/dist/templates/macos-scripts.js +448 -0
- package/dist/tui/ConfigEditor.d.ts +7 -0
- package/dist/tui/ConfigEditor.js +313 -0
- package/dist/tui/components/ArrayEditor.d.ts +22 -0
- package/dist/tui/components/ArrayEditor.js +193 -0
- package/dist/tui/components/BooleanToggle.d.ts +19 -0
- package/dist/tui/components/BooleanToggle.js +43 -0
- package/dist/tui/components/EditorPanel.d.ts +50 -0
- package/dist/tui/components/EditorPanel.js +232 -0
- package/dist/tui/components/HelpPanel.d.ts +13 -0
- package/dist/tui/components/HelpPanel.js +69 -0
- package/dist/tui/components/JsonSnippetEditor.d.ts +24 -0
- package/dist/tui/components/JsonSnippetEditor.js +380 -0
- package/dist/tui/components/KeyValueEditor.d.ts +34 -0
- package/dist/tui/components/KeyValueEditor.js +261 -0
- package/dist/tui/components/ObjectEditor.d.ts +23 -0
- package/dist/tui/components/ObjectEditor.js +227 -0
- package/dist/tui/components/PresetSelector.d.ts +23 -0
- package/dist/tui/components/PresetSelector.js +58 -0
- package/dist/tui/components/Preview.d.ts +18 -0
- package/dist/tui/components/Preview.js +190 -0
- package/dist/tui/components/ScrollableContainer.d.ts +38 -0
- package/dist/tui/components/ScrollableContainer.js +77 -0
- package/dist/tui/components/SectionNav.d.ts +31 -0
- package/dist/tui/components/SectionNav.js +130 -0
- package/dist/tui/components/StringEditor.d.ts +21 -0
- package/dist/tui/components/StringEditor.js +29 -0
- package/dist/tui/hooks/useConfig.d.ts +16 -0
- package/dist/tui/hooks/useConfig.js +89 -0
- package/dist/tui/hooks/useTerminalSize.d.ts +21 -0
- package/dist/tui/hooks/useTerminalSize.js +48 -0
- package/dist/tui/utils/presets.d.ts +52 -0
- package/dist/tui/utils/presets.js +191 -0
- package/dist/tui/utils/validation.d.ts +49 -0
- package/dist/tui/utils/validation.js +198 -0
- package/dist/utils/chat-client.d.ts +31 -1
- package/dist/utils/chat-client.js +27 -1
- package/dist/utils/config.d.ts +7 -2
- package/docs/MACOS-DEVELOPMENT.md +435 -0
- package/package.json +1 -1
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useMemo } from "react";
|
|
3
|
+
import { Box, Text } from "ink";
|
|
4
|
+
import { CONFIG_SECTIONS } from "./SectionNav.js";
|
|
5
|
+
import { getValueAtPath } from "./EditorPanel.js";
|
|
6
|
+
/**
|
|
7
|
+
* Get the color for a token type.
|
|
8
|
+
*/
|
|
9
|
+
function getTokenColor(type) {
|
|
10
|
+
switch (type) {
|
|
11
|
+
case "key":
|
|
12
|
+
return "cyan";
|
|
13
|
+
case "string":
|
|
14
|
+
return "green";
|
|
15
|
+
case "number":
|
|
16
|
+
return "yellow";
|
|
17
|
+
case "boolean":
|
|
18
|
+
return "magenta";
|
|
19
|
+
case "null":
|
|
20
|
+
return "gray";
|
|
21
|
+
case "bracket":
|
|
22
|
+
return undefined; // default color
|
|
23
|
+
case "colon":
|
|
24
|
+
return "gray";
|
|
25
|
+
case "comma":
|
|
26
|
+
return "gray";
|
|
27
|
+
default:
|
|
28
|
+
return undefined;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Parse JSON string into highlighted tokens.
|
|
33
|
+
* Returns an array of lines, each containing tokens with type information.
|
|
34
|
+
*/
|
|
35
|
+
function highlightJson(jsonString) {
|
|
36
|
+
const lines = [];
|
|
37
|
+
const rawLines = jsonString.split("\n");
|
|
38
|
+
for (const line of rawLines) {
|
|
39
|
+
const tokens = [];
|
|
40
|
+
let remaining = line;
|
|
41
|
+
let indent = 0;
|
|
42
|
+
// Count leading spaces for indent
|
|
43
|
+
const leadingSpaces = line.match(/^(\s*)/);
|
|
44
|
+
if (leadingSpaces) {
|
|
45
|
+
indent = leadingSpaces[1].length;
|
|
46
|
+
remaining = line.slice(indent);
|
|
47
|
+
}
|
|
48
|
+
// Tokenize the line
|
|
49
|
+
while (remaining.length > 0) {
|
|
50
|
+
// Skip whitespace
|
|
51
|
+
const wsMatch = remaining.match(/^(\s+)/);
|
|
52
|
+
if (wsMatch) {
|
|
53
|
+
remaining = remaining.slice(wsMatch[1].length);
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
// Match key (quoted string followed by colon)
|
|
57
|
+
const keyMatch = remaining.match(/^"([^"\\]|\\.)*"(?=\s*:)/);
|
|
58
|
+
if (keyMatch) {
|
|
59
|
+
tokens.push({ type: "key", value: keyMatch[0] });
|
|
60
|
+
remaining = remaining.slice(keyMatch[0].length);
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
// Match string value
|
|
64
|
+
const stringMatch = remaining.match(/^"([^"\\]|\\.)*"/);
|
|
65
|
+
if (stringMatch) {
|
|
66
|
+
tokens.push({ type: "string", value: stringMatch[0] });
|
|
67
|
+
remaining = remaining.slice(stringMatch[0].length);
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
// Match number
|
|
71
|
+
const numberMatch = remaining.match(/^-?\d+(\.\d+)?([eE][+-]?\d+)?/);
|
|
72
|
+
if (numberMatch) {
|
|
73
|
+
tokens.push({ type: "number", value: numberMatch[0] });
|
|
74
|
+
remaining = remaining.slice(numberMatch[0].length);
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
// Match boolean
|
|
78
|
+
const boolMatch = remaining.match(/^(true|false)/);
|
|
79
|
+
if (boolMatch) {
|
|
80
|
+
tokens.push({ type: "boolean", value: boolMatch[0] });
|
|
81
|
+
remaining = remaining.slice(boolMatch[0].length);
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
// Match null
|
|
85
|
+
const nullMatch = remaining.match(/^null/);
|
|
86
|
+
if (nullMatch) {
|
|
87
|
+
tokens.push({ type: "null", value: nullMatch[0] });
|
|
88
|
+
remaining = remaining.slice(nullMatch[0].length);
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
// Match brackets
|
|
92
|
+
const bracketMatch = remaining.match(/^[\[\]{}]/);
|
|
93
|
+
if (bracketMatch) {
|
|
94
|
+
tokens.push({ type: "bracket", value: bracketMatch[0] });
|
|
95
|
+
remaining = remaining.slice(1);
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
// Match colon
|
|
99
|
+
if (remaining.startsWith(":")) {
|
|
100
|
+
tokens.push({ type: "colon", value: ":" });
|
|
101
|
+
remaining = remaining.slice(1);
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
// Match comma
|
|
105
|
+
if (remaining.startsWith(",")) {
|
|
106
|
+
tokens.push({ type: "comma", value: "," });
|
|
107
|
+
remaining = remaining.slice(1);
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
// Unknown character - add as-is
|
|
111
|
+
tokens.push({ type: "bracket", value: remaining[0] });
|
|
112
|
+
remaining = remaining.slice(1);
|
|
113
|
+
}
|
|
114
|
+
lines.push({ tokens, indent });
|
|
115
|
+
}
|
|
116
|
+
return lines;
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* Render a single highlighted line.
|
|
120
|
+
*/
|
|
121
|
+
function HighlightedLineComponent({ line }) {
|
|
122
|
+
return (_jsxs(Box, { children: [_jsx(Text, { children: " ".repeat(line.indent) }), line.tokens.map((token, index) => (_jsx(Text, { color: getTokenColor(token.type), children: token.value }, index)))] }));
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* Preview component displays the current section's config as syntax-highlighted JSON.
|
|
126
|
+
* Updates live as edits are made.
|
|
127
|
+
*/
|
|
128
|
+
export function Preview({ config, selectedSection, visible = true, maxHeight = 20, }) {
|
|
129
|
+
// Don't render if not visible
|
|
130
|
+
if (!visible) {
|
|
131
|
+
return null;
|
|
132
|
+
}
|
|
133
|
+
// Get the current section's data
|
|
134
|
+
const sectionData = useMemo(() => {
|
|
135
|
+
if (!config)
|
|
136
|
+
return null;
|
|
137
|
+
const section = CONFIG_SECTIONS.find((s) => s.id === selectedSection);
|
|
138
|
+
if (!section)
|
|
139
|
+
return null;
|
|
140
|
+
// Build an object with just the section's fields
|
|
141
|
+
const data = {};
|
|
142
|
+
for (const fieldPath of section.fields) {
|
|
143
|
+
const value = getValueAtPath(config, fieldPath);
|
|
144
|
+
if (value !== undefined) {
|
|
145
|
+
// Store with the full path for clarity
|
|
146
|
+
const parts = fieldPath.split(".");
|
|
147
|
+
let current = data;
|
|
148
|
+
for (let i = 0; i < parts.length - 1; i++) {
|
|
149
|
+
const part = parts[i];
|
|
150
|
+
if (!current[part]) {
|
|
151
|
+
current[part] = {};
|
|
152
|
+
}
|
|
153
|
+
current = current[part];
|
|
154
|
+
}
|
|
155
|
+
current[parts[parts.length - 1]] = value;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
return data;
|
|
159
|
+
}, [config, selectedSection]);
|
|
160
|
+
// Generate highlighted JSON
|
|
161
|
+
const highlightedLines = useMemo(() => {
|
|
162
|
+
if (!sectionData)
|
|
163
|
+
return [];
|
|
164
|
+
try {
|
|
165
|
+
const jsonString = JSON.stringify(sectionData, null, 2);
|
|
166
|
+
return highlightJson(jsonString);
|
|
167
|
+
}
|
|
168
|
+
catch {
|
|
169
|
+
return [];
|
|
170
|
+
}
|
|
171
|
+
}, [sectionData]);
|
|
172
|
+
// Limit lines if needed
|
|
173
|
+
const displayLines = useMemo(() => {
|
|
174
|
+
if (highlightedLines.length <= maxHeight) {
|
|
175
|
+
return highlightedLines;
|
|
176
|
+
}
|
|
177
|
+
// Show first (maxHeight - 1) lines plus a "..." indicator
|
|
178
|
+
return highlightedLines.slice(0, maxHeight - 1);
|
|
179
|
+
}, [highlightedLines, maxHeight]);
|
|
180
|
+
const isOverflowing = highlightedLines.length > maxHeight;
|
|
181
|
+
// Get section info for header
|
|
182
|
+
const currentSection = useMemo(() => {
|
|
183
|
+
return CONFIG_SECTIONS.find((s) => s.id === selectedSection);
|
|
184
|
+
}, [selectedSection]);
|
|
185
|
+
if (!config || !sectionData || Object.keys(sectionData).length === 0) {
|
|
186
|
+
return (_jsxs(Box, { flexDirection: "column", borderStyle: "single", borderColor: "gray", paddingX: 1, width: 40, children: [_jsx(Box, { marginBottom: 1, children: _jsx(Text, { bold: true, color: "yellow", children: "JSON Preview" }) }), _jsx(Text, { dimColor: true, children: "No data to preview" })] }));
|
|
187
|
+
}
|
|
188
|
+
return (_jsxs(Box, { flexDirection: "column", borderStyle: "single", borderColor: "gray", paddingX: 1, width: 40, children: [_jsxs(Box, { marginBottom: 1, justifyContent: "space-between", children: [_jsx(Text, { bold: true, color: "yellow", children: "JSON Preview" }), _jsx(Text, { dimColor: true, children: currentSection?.label || selectedSection })] }), _jsxs(Box, { flexDirection: "column", children: [displayLines.map((line, index) => (_jsx(HighlightedLineComponent, { line: line }, index))), isOverflowing && (_jsxs(Text, { dimColor: true, children: [" ... (", highlightedLines.length - maxHeight + 1, " more lines)"] }))] }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: "[Tab] to hide" }) })] }));
|
|
189
|
+
}
|
|
190
|
+
export default Preview;
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
export interface ScrollableContainerProps {
|
|
3
|
+
/** The content items to display */
|
|
4
|
+
children: React.ReactNode[];
|
|
5
|
+
/** Maximum height in lines (excluding header/footer) */
|
|
6
|
+
maxHeight: number;
|
|
7
|
+
/** Current highlighted/selected index (for auto-scrolling to selection) */
|
|
8
|
+
highlightedIndex?: number;
|
|
9
|
+
/** Whether this component has focus for keyboard input (for Page Up/Down) */
|
|
10
|
+
isFocused?: boolean;
|
|
11
|
+
/** Callback when scroll position changes via Page Up/Down */
|
|
12
|
+
onScroll?: (direction: "up" | "down", amount: number) => void;
|
|
13
|
+
/** Header element to display above scrollable content */
|
|
14
|
+
header?: React.ReactNode;
|
|
15
|
+
/** Footer element to display below scrollable content */
|
|
16
|
+
footer?: React.ReactNode;
|
|
17
|
+
/** Show scroll indicators on the right side */
|
|
18
|
+
showScrollIndicators?: boolean;
|
|
19
|
+
/** Border style for the container */
|
|
20
|
+
borderStyle?: "single" | "double" | "round" | "bold" | "singleDouble" | "doubleSingle" | "classic";
|
|
21
|
+
/** Border color */
|
|
22
|
+
borderColor?: string;
|
|
23
|
+
/** Padding on the X axis */
|
|
24
|
+
paddingX?: number;
|
|
25
|
+
/** Width of the container */
|
|
26
|
+
width?: number;
|
|
27
|
+
/** Flex grow value */
|
|
28
|
+
flexGrow?: number;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* ScrollableContainer provides a viewport for content that may overflow.
|
|
32
|
+
* Features:
|
|
33
|
+
* - Auto-scroll to keep highlighted item visible
|
|
34
|
+
* - Page Up/Down keyboard shortcuts for faster scrolling
|
|
35
|
+
* - Scroll indicators (arrows) when content overflows
|
|
36
|
+
*/
|
|
37
|
+
export declare function ScrollableContainer({ children, maxHeight, highlightedIndex, isFocused, onScroll, header, footer, showScrollIndicators, borderStyle, borderColor, paddingX, width, flexGrow, }: ScrollableContainerProps): React.ReactElement;
|
|
38
|
+
export default ScrollableContainer;
|
|
@@ -0,0 +1,77 @@
|
|
|
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
|
+
/**
|
|
5
|
+
* ScrollableContainer provides a viewport for content that may overflow.
|
|
6
|
+
* Features:
|
|
7
|
+
* - Auto-scroll to keep highlighted item visible
|
|
8
|
+
* - Page Up/Down keyboard shortcuts for faster scrolling
|
|
9
|
+
* - Scroll indicators (arrows) when content overflows
|
|
10
|
+
*/
|
|
11
|
+
export function ScrollableContainer({ children, maxHeight, highlightedIndex = 0, isFocused = true, onScroll, header, footer, showScrollIndicators = true, borderStyle = "single", borderColor = "gray", paddingX = 1, width, flexGrow, }) {
|
|
12
|
+
const totalItems = children.length;
|
|
13
|
+
const [scrollOffset, setScrollOffset] = useState(0);
|
|
14
|
+
// Calculate visible range
|
|
15
|
+
const visibleItems = useMemo(() => {
|
|
16
|
+
const startIndex = scrollOffset;
|
|
17
|
+
const endIndex = Math.min(scrollOffset + maxHeight, totalItems);
|
|
18
|
+
return children.slice(startIndex, endIndex);
|
|
19
|
+
}, [children, scrollOffset, maxHeight, totalItems]);
|
|
20
|
+
// Check if we can scroll
|
|
21
|
+
const canScrollUp = scrollOffset > 0;
|
|
22
|
+
const canScrollDown = scrollOffset + maxHeight < totalItems;
|
|
23
|
+
const hasOverflow = totalItems > maxHeight;
|
|
24
|
+
// Auto-scroll to keep highlighted item visible
|
|
25
|
+
useEffect(() => {
|
|
26
|
+
if (highlightedIndex < scrollOffset) {
|
|
27
|
+
// Item is above viewport - scroll up
|
|
28
|
+
setScrollOffset(highlightedIndex);
|
|
29
|
+
}
|
|
30
|
+
else if (highlightedIndex >= scrollOffset + maxHeight) {
|
|
31
|
+
// Item is below viewport - scroll down
|
|
32
|
+
setScrollOffset(highlightedIndex - maxHeight + 1);
|
|
33
|
+
}
|
|
34
|
+
}, [highlightedIndex, scrollOffset, maxHeight]);
|
|
35
|
+
// Handle Page Up/Down keys
|
|
36
|
+
const handlePageUp = useCallback(() => {
|
|
37
|
+
const newOffset = Math.max(0, scrollOffset - maxHeight);
|
|
38
|
+
setScrollOffset(newOffset);
|
|
39
|
+
if (onScroll) {
|
|
40
|
+
onScroll("up", maxHeight);
|
|
41
|
+
}
|
|
42
|
+
}, [scrollOffset, maxHeight, onScroll]);
|
|
43
|
+
const handlePageDown = useCallback(() => {
|
|
44
|
+
const maxOffset = Math.max(0, totalItems - maxHeight);
|
|
45
|
+
const newOffset = Math.min(maxOffset, scrollOffset + maxHeight);
|
|
46
|
+
setScrollOffset(newOffset);
|
|
47
|
+
if (onScroll) {
|
|
48
|
+
onScroll("down", maxHeight);
|
|
49
|
+
}
|
|
50
|
+
}, [scrollOffset, maxHeight, totalItems, onScroll]);
|
|
51
|
+
// Handle keyboard input for Page Up/Down
|
|
52
|
+
useInput((_input, key) => {
|
|
53
|
+
if (!isFocused)
|
|
54
|
+
return;
|
|
55
|
+
if (key.pageUp) {
|
|
56
|
+
handlePageUp();
|
|
57
|
+
}
|
|
58
|
+
else if (key.pageDown) {
|
|
59
|
+
handlePageDown();
|
|
60
|
+
}
|
|
61
|
+
}, { isActive: isFocused });
|
|
62
|
+
// Build the container with optional scroll indicators
|
|
63
|
+
const containerProps = {
|
|
64
|
+
flexDirection: "column",
|
|
65
|
+
borderStyle,
|
|
66
|
+
borderColor,
|
|
67
|
+
paddingX,
|
|
68
|
+
};
|
|
69
|
+
if (width !== undefined) {
|
|
70
|
+
containerProps.width = width;
|
|
71
|
+
}
|
|
72
|
+
if (flexGrow !== undefined) {
|
|
73
|
+
containerProps.flexGrow = flexGrow;
|
|
74
|
+
}
|
|
75
|
+
return (_jsxs(Box, { ...containerProps, children: [header, _jsxs(Box, { flexDirection: "column", children: [showScrollIndicators && hasOverflow && (_jsx(Box, { justifyContent: "flex-end", children: _jsx(Text, { color: canScrollUp ? "cyan" : "gray", dimColor: !canScrollUp, children: canScrollUp ? "▲ more" : "" }) })), visibleItems, showScrollIndicators && hasOverflow && (_jsx(Box, { justifyContent: "flex-end", children: _jsx(Text, { color: canScrollDown ? "cyan" : "gray", dimColor: !canScrollDown, children: canScrollDown ? "▼ more" : "" }) }))] }), footer] }));
|
|
76
|
+
}
|
|
77
|
+
export default ScrollableContainer;
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
/**
|
|
3
|
+
* Configuration section definition for navigation tree.
|
|
4
|
+
*/
|
|
5
|
+
export interface ConfigSection {
|
|
6
|
+
id: string;
|
|
7
|
+
label: string;
|
|
8
|
+
icon?: string;
|
|
9
|
+
fields: string[];
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Default sections for ralph configuration.
|
|
13
|
+
* Each section groups related configuration fields.
|
|
14
|
+
*/
|
|
15
|
+
export declare const CONFIG_SECTIONS: ConfigSection[];
|
|
16
|
+
export interface SectionNavProps {
|
|
17
|
+
/** Currently selected section ID */
|
|
18
|
+
selectedSection: string;
|
|
19
|
+
/** Callback when section is selected */
|
|
20
|
+
onSelectSection: (sectionId: string) => void;
|
|
21
|
+
/** Whether this component has focus for keyboard input */
|
|
22
|
+
isFocused?: boolean;
|
|
23
|
+
/** Maximum height for the navigation list (for scrolling) */
|
|
24
|
+
maxHeight?: number;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* SectionNav component provides a vertical navigation menu for config sections.
|
|
28
|
+
* Supports j/k keyboard navigation, Enter to select, and Page Up/Down for scrolling.
|
|
29
|
+
*/
|
|
30
|
+
export declare function SectionNav({ selectedSection, onSelectSection, isFocused, maxHeight, }: SectionNavProps): React.ReactElement;
|
|
31
|
+
export default SectionNav;
|
|
@@ -0,0 +1,130 @@
|
|
|
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
|
+
/**
|
|
5
|
+
* Default sections for ralph configuration.
|
|
6
|
+
* Each section groups related configuration fields.
|
|
7
|
+
*/
|
|
8
|
+
export const CONFIG_SECTIONS = [
|
|
9
|
+
{
|
|
10
|
+
id: "basic",
|
|
11
|
+
label: "Basic",
|
|
12
|
+
icon: "⚙",
|
|
13
|
+
fields: ["language", "checkCommand", "testCommand", "imageName", "technologies", "javaVersion"],
|
|
14
|
+
},
|
|
15
|
+
{
|
|
16
|
+
id: "docker",
|
|
17
|
+
label: "Docker",
|
|
18
|
+
icon: "🐳",
|
|
19
|
+
fields: ["docker.ports", "docker.volumes", "docker.environment", "docker.packages", "docker.git", "docker.buildCommands", "docker.startCommand", "docker.firewall", "docker.autoStart", "docker.restartCount"],
|
|
20
|
+
},
|
|
21
|
+
{
|
|
22
|
+
id: "daemon",
|
|
23
|
+
label: "Daemon",
|
|
24
|
+
icon: "👹",
|
|
25
|
+
fields: ["daemon.enabled", "daemon.actions", "daemon.events"],
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
id: "claude",
|
|
29
|
+
label: "Claude",
|
|
30
|
+
icon: "🤖",
|
|
31
|
+
fields: ["claude.mcpServers", "claude.skills"],
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
id: "chat",
|
|
35
|
+
label: "Chat",
|
|
36
|
+
icon: "💬",
|
|
37
|
+
fields: ["chat.enabled", "chat.provider", "chat.telegram"],
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
id: "notifications",
|
|
41
|
+
label: "Notifications",
|
|
42
|
+
icon: "🔔",
|
|
43
|
+
fields: ["notifications.provider", "notifications.ntfy", "notifications.pushover", "notifications.gotify", "notifications.command", "notifyCommand"],
|
|
44
|
+
},
|
|
45
|
+
];
|
|
46
|
+
/**
|
|
47
|
+
* SectionNav component provides a vertical navigation menu for config sections.
|
|
48
|
+
* Supports j/k keyboard navigation, Enter to select, and Page Up/Down for scrolling.
|
|
49
|
+
*/
|
|
50
|
+
export function SectionNav({ selectedSection, onSelectSection, isFocused = true, maxHeight = 10, }) {
|
|
51
|
+
const [highlightedIndex, setHighlightedIndex] = useState(() => {
|
|
52
|
+
const idx = CONFIG_SECTIONS.findIndex((s) => s.id === selectedSection);
|
|
53
|
+
return idx >= 0 ? idx : 0;
|
|
54
|
+
});
|
|
55
|
+
const [scrollOffset, setScrollOffset] = useState(0);
|
|
56
|
+
const totalSections = CONFIG_SECTIONS.length;
|
|
57
|
+
// Sync highlighted index when selectedSection changes externally
|
|
58
|
+
useEffect(() => {
|
|
59
|
+
const idx = CONFIG_SECTIONS.findIndex((s) => s.id === selectedSection);
|
|
60
|
+
if (idx >= 0) {
|
|
61
|
+
setHighlightedIndex(idx);
|
|
62
|
+
}
|
|
63
|
+
}, [selectedSection]);
|
|
64
|
+
// Auto-scroll to keep highlighted item visible
|
|
65
|
+
useEffect(() => {
|
|
66
|
+
if (highlightedIndex < scrollOffset) {
|
|
67
|
+
setScrollOffset(highlightedIndex);
|
|
68
|
+
}
|
|
69
|
+
else if (highlightedIndex >= scrollOffset + maxHeight) {
|
|
70
|
+
setScrollOffset(highlightedIndex - maxHeight + 1);
|
|
71
|
+
}
|
|
72
|
+
}, [highlightedIndex, scrollOffset, maxHeight]);
|
|
73
|
+
const handleNavigateUp = useCallback(() => {
|
|
74
|
+
setHighlightedIndex((prev) => (prev > 0 ? prev - 1 : totalSections - 1));
|
|
75
|
+
}, [totalSections]);
|
|
76
|
+
const handleNavigateDown = useCallback(() => {
|
|
77
|
+
setHighlightedIndex((prev) => (prev < totalSections - 1 ? prev + 1 : 0));
|
|
78
|
+
}, [totalSections]);
|
|
79
|
+
const handlePageUp = useCallback(() => {
|
|
80
|
+
const newIndex = Math.max(0, highlightedIndex - maxHeight);
|
|
81
|
+
setHighlightedIndex(newIndex);
|
|
82
|
+
}, [highlightedIndex, maxHeight]);
|
|
83
|
+
const handlePageDown = useCallback(() => {
|
|
84
|
+
const newIndex = Math.min(totalSections - 1, highlightedIndex + maxHeight);
|
|
85
|
+
setHighlightedIndex(newIndex);
|
|
86
|
+
}, [highlightedIndex, maxHeight, totalSections]);
|
|
87
|
+
const handleSelect = useCallback(() => {
|
|
88
|
+
const section = CONFIG_SECTIONS[highlightedIndex];
|
|
89
|
+
if (section) {
|
|
90
|
+
onSelectSection(section.id);
|
|
91
|
+
}
|
|
92
|
+
}, [highlightedIndex, onSelectSection]);
|
|
93
|
+
// Handle keyboard input
|
|
94
|
+
useInput((input, key) => {
|
|
95
|
+
if (!isFocused)
|
|
96
|
+
return;
|
|
97
|
+
// j/k or arrow keys for navigation
|
|
98
|
+
if (input === "j" || key.downArrow) {
|
|
99
|
+
handleNavigateDown();
|
|
100
|
+
}
|
|
101
|
+
else if (input === "k" || key.upArrow) {
|
|
102
|
+
handleNavigateUp();
|
|
103
|
+
}
|
|
104
|
+
else if (key.pageUp) {
|
|
105
|
+
handlePageUp();
|
|
106
|
+
}
|
|
107
|
+
else if (key.pageDown) {
|
|
108
|
+
handlePageDown();
|
|
109
|
+
}
|
|
110
|
+
else if (key.return) {
|
|
111
|
+
handleSelect();
|
|
112
|
+
}
|
|
113
|
+
}, { isActive: isFocused });
|
|
114
|
+
// Calculate visible sections based on scroll offset
|
|
115
|
+
const visibleSections = useMemo(() => {
|
|
116
|
+
const endIndex = Math.min(scrollOffset + maxHeight, totalSections);
|
|
117
|
+
return CONFIG_SECTIONS.slice(scrollOffset, endIndex);
|
|
118
|
+
}, [scrollOffset, maxHeight, totalSections]);
|
|
119
|
+
// Check if we have overflow
|
|
120
|
+
const canScrollUp = scrollOffset > 0;
|
|
121
|
+
const canScrollDown = scrollOffset + maxHeight < totalSections;
|
|
122
|
+
const hasOverflow = totalSections > maxHeight;
|
|
123
|
+
return (_jsxs(Box, { flexDirection: "column", borderStyle: "single", borderColor: "gray", paddingX: 1, children: [_jsx(Box, { marginBottom: 1, children: _jsx(Text, { bold: true, color: "cyan", children: "Sections" }) }), hasOverflow && (_jsx(Box, { children: _jsx(Text, { color: canScrollUp ? "cyan" : "gray", dimColor: !canScrollUp, children: canScrollUp ? " ▲ more" : "" }) })), visibleSections.map((section) => {
|
|
124
|
+
const actualIndex = CONFIG_SECTIONS.findIndex((s) => s.id === section.id);
|
|
125
|
+
const isHighlighted = actualIndex === highlightedIndex;
|
|
126
|
+
const isSelected = section.id === selectedSection;
|
|
127
|
+
return (_jsxs(Box, { children: [_jsx(Text, { color: isHighlighted ? "cyan" : undefined, children: isHighlighted ? "▸ " : " " }), _jsx(Text, { bold: isSelected, color: isHighlighted ? "cyan" : isSelected ? "green" : undefined, inverse: isHighlighted, children: section.label })] }, section.id));
|
|
128
|
+
}), hasOverflow && (_jsx(Box, { children: _jsx(Text, { color: canScrollDown ? "cyan" : "gray", dimColor: !canScrollDown, children: canScrollDown ? " ▼ more" : "" }) })), _jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: "j/k: navigate" }) }), _jsx(Box, { children: _jsx(Text, { dimColor: true, children: "Enter: select" }) }), hasOverflow && (_jsx(Box, { children: _jsx(Text, { dimColor: true, children: "PgUp/Dn: scroll" }) }))] }));
|
|
129
|
+
}
|
|
130
|
+
export default SectionNav;
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
export interface StringEditorProps {
|
|
3
|
+
/** The label to display for this field */
|
|
4
|
+
label: string;
|
|
5
|
+
/** The current value of the string */
|
|
6
|
+
value: string;
|
|
7
|
+
/** Called when the user confirms the edit (Enter) */
|
|
8
|
+
onConfirm: (newValue: string) => void;
|
|
9
|
+
/** Called when the user cancels the edit (Esc) */
|
|
10
|
+
onCancel: () => void;
|
|
11
|
+
/** Optional placeholder text */
|
|
12
|
+
placeholder?: string;
|
|
13
|
+
/** Whether this editor has focus */
|
|
14
|
+
isFocused?: boolean;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* StringEditor component provides an inline text editor for string fields.
|
|
18
|
+
* Uses ink-text-input for text editing with Enter to confirm and Esc to cancel.
|
|
19
|
+
*/
|
|
20
|
+
export declare function StringEditor({ label, value, onConfirm, onCancel, placeholder, isFocused, }: StringEditorProps): React.ReactElement;
|
|
21
|
+
export default StringEditor;
|
|
@@ -0,0 +1,29 @@
|
|
|
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
|
+
import TextInput from "ink-text-input";
|
|
5
|
+
/**
|
|
6
|
+
* StringEditor component provides an inline text editor for string fields.
|
|
7
|
+
* Uses ink-text-input for text editing with Enter to confirm and Esc to cancel.
|
|
8
|
+
*/
|
|
9
|
+
export function StringEditor({ label, value, onConfirm, onCancel, placeholder = "", isFocused = true, }) {
|
|
10
|
+
const [editValue, setEditValue] = useState(value);
|
|
11
|
+
// Handle text input changes
|
|
12
|
+
const handleChange = useCallback((newValue) => {
|
|
13
|
+
setEditValue(newValue);
|
|
14
|
+
}, []);
|
|
15
|
+
// Handle submit (Enter key in TextInput)
|
|
16
|
+
const handleSubmit = useCallback(() => {
|
|
17
|
+
onConfirm(editValue);
|
|
18
|
+
}, [editValue, onConfirm]);
|
|
19
|
+
// Handle keyboard input for Esc key
|
|
20
|
+
useInput((_input, key) => {
|
|
21
|
+
if (!isFocused)
|
|
22
|
+
return;
|
|
23
|
+
if (key.escape) {
|
|
24
|
+
onCancel();
|
|
25
|
+
}
|
|
26
|
+
}, { isActive: isFocused });
|
|
27
|
+
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: [">", " "] }), _jsx(TextInput, { value: editValue, onChange: handleChange, onSubmit: handleSubmit, placeholder: placeholder, focus: isFocused })] }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: "Enter: confirm | Esc: cancel" }) })] }));
|
|
28
|
+
}
|
|
29
|
+
export default StringEditor;
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { RalphConfig } from "../../utils/config.js";
|
|
2
|
+
export interface UseConfigResult {
|
|
3
|
+
config: RalphConfig | null;
|
|
4
|
+
loading: boolean;
|
|
5
|
+
error: string | null;
|
|
6
|
+
hasChanges: boolean;
|
|
7
|
+
loadConfig: () => void;
|
|
8
|
+
saveConfig: () => boolean;
|
|
9
|
+
updateConfig: (updater: (config: RalphConfig) => RalphConfig) => void;
|
|
10
|
+
resetChanges: () => void;
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* React hook for loading and saving ralph configuration.
|
|
14
|
+
* Provides state management for the config editor with dirty state tracking.
|
|
15
|
+
*/
|
|
16
|
+
export declare function useConfig(): UseConfigResult;
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { useState, useCallback, useEffect } from "react";
|
|
2
|
+
import { readFileSync, writeFileSync, existsSync } from "fs";
|
|
3
|
+
import { getPaths } from "../../utils/config.js";
|
|
4
|
+
/**
|
|
5
|
+
* React hook for loading and saving ralph configuration.
|
|
6
|
+
* Provides state management for the config editor with dirty state tracking.
|
|
7
|
+
*/
|
|
8
|
+
export function useConfig() {
|
|
9
|
+
const [config, setConfig] = useState(null);
|
|
10
|
+
const [originalConfig, setOriginalConfig] = useState(null);
|
|
11
|
+
const [loading, setLoading] = useState(true);
|
|
12
|
+
const [error, setError] = useState(null);
|
|
13
|
+
const [hasChanges, setHasChanges] = useState(false);
|
|
14
|
+
const loadConfigFromFile = useCallback(() => {
|
|
15
|
+
setLoading(true);
|
|
16
|
+
setError(null);
|
|
17
|
+
try {
|
|
18
|
+
const paths = getPaths();
|
|
19
|
+
if (!existsSync(paths.config)) {
|
|
20
|
+
throw new Error(".ralph/config.json not found. Run 'ralph init' first.");
|
|
21
|
+
}
|
|
22
|
+
const content = readFileSync(paths.config, "utf-8");
|
|
23
|
+
const parsedConfig = JSON.parse(content);
|
|
24
|
+
setConfig(parsedConfig);
|
|
25
|
+
setOriginalConfig(JSON.parse(content)); // Deep copy for comparison
|
|
26
|
+
setHasChanges(false);
|
|
27
|
+
}
|
|
28
|
+
catch (err) {
|
|
29
|
+
const message = err instanceof Error ? err.message : "Unknown error loading config";
|
|
30
|
+
setError(message);
|
|
31
|
+
setConfig(null);
|
|
32
|
+
setOriginalConfig(null);
|
|
33
|
+
}
|
|
34
|
+
finally {
|
|
35
|
+
setLoading(false);
|
|
36
|
+
}
|
|
37
|
+
}, []);
|
|
38
|
+
const saveConfig = useCallback(() => {
|
|
39
|
+
if (!config) {
|
|
40
|
+
setError("No config to save");
|
|
41
|
+
return false;
|
|
42
|
+
}
|
|
43
|
+
try {
|
|
44
|
+
const paths = getPaths();
|
|
45
|
+
const jsonContent = JSON.stringify(config, null, 2);
|
|
46
|
+
writeFileSync(paths.config, jsonContent, "utf-8");
|
|
47
|
+
// Update original config after successful save
|
|
48
|
+
setOriginalConfig(JSON.parse(jsonContent));
|
|
49
|
+
setHasChanges(false);
|
|
50
|
+
setError(null);
|
|
51
|
+
return true;
|
|
52
|
+
}
|
|
53
|
+
catch (err) {
|
|
54
|
+
const message = err instanceof Error ? err.message : "Unknown error saving config";
|
|
55
|
+
setError(message);
|
|
56
|
+
return false;
|
|
57
|
+
}
|
|
58
|
+
}, [config]);
|
|
59
|
+
const updateConfig = useCallback((updater) => {
|
|
60
|
+
setConfig((currentConfig) => {
|
|
61
|
+
if (!currentConfig)
|
|
62
|
+
return null;
|
|
63
|
+
const newConfig = updater(currentConfig);
|
|
64
|
+
return newConfig;
|
|
65
|
+
});
|
|
66
|
+
setHasChanges(true);
|
|
67
|
+
}, []);
|
|
68
|
+
const resetChanges = useCallback(() => {
|
|
69
|
+
if (originalConfig) {
|
|
70
|
+
// Deep copy to avoid reference issues
|
|
71
|
+
setConfig(JSON.parse(JSON.stringify(originalConfig)));
|
|
72
|
+
setHasChanges(false);
|
|
73
|
+
}
|
|
74
|
+
}, [originalConfig]);
|
|
75
|
+
// Load config on mount
|
|
76
|
+
useEffect(() => {
|
|
77
|
+
loadConfigFromFile();
|
|
78
|
+
}, [loadConfigFromFile]);
|
|
79
|
+
return {
|
|
80
|
+
config,
|
|
81
|
+
loading,
|
|
82
|
+
error,
|
|
83
|
+
hasChanges,
|
|
84
|
+
loadConfig: loadConfigFromFile,
|
|
85
|
+
saveConfig,
|
|
86
|
+
updateConfig,
|
|
87
|
+
resetChanges,
|
|
88
|
+
};
|
|
89
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Terminal size dimensions.
|
|
3
|
+
*/
|
|
4
|
+
export interface TerminalSize {
|
|
5
|
+
columns: number;
|
|
6
|
+
rows: number;
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* Default terminal size (standard VT100 terminal dimensions).
|
|
10
|
+
*/
|
|
11
|
+
export declare const DEFAULT_TERMINAL_SIZE: TerminalSize;
|
|
12
|
+
/**
|
|
13
|
+
* Minimum terminal size for the config editor.
|
|
14
|
+
*/
|
|
15
|
+
export declare const MIN_TERMINAL_SIZE: TerminalSize;
|
|
16
|
+
/**
|
|
17
|
+
* Hook that returns the current terminal size and updates on resize.
|
|
18
|
+
* Falls back to default dimensions if terminal size cannot be determined.
|
|
19
|
+
*/
|
|
20
|
+
export declare function useTerminalSize(): TerminalSize;
|
|
21
|
+
export default useTerminalSize;
|