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.
Files changed (55) hide show
  1. package/dist/commands/action.d.ts +7 -0
  2. package/dist/commands/action.js +276 -0
  3. package/dist/commands/chat.js +95 -7
  4. package/dist/commands/config.js +6 -18
  5. package/dist/commands/fix-config.d.ts +4 -0
  6. package/dist/commands/fix-config.js +388 -0
  7. package/dist/commands/help.js +17 -0
  8. package/dist/commands/init.js +89 -2
  9. package/dist/commands/listen.js +50 -9
  10. package/dist/commands/prd.js +2 -2
  11. package/dist/config/languages.json +4 -0
  12. package/dist/index.js +4 -0
  13. package/dist/providers/telegram.d.ts +6 -2
  14. package/dist/providers/telegram.js +68 -2
  15. package/dist/templates/macos-scripts.d.ts +42 -0
  16. package/dist/templates/macos-scripts.js +448 -0
  17. package/dist/tui/ConfigEditor.d.ts +7 -0
  18. package/dist/tui/ConfigEditor.js +313 -0
  19. package/dist/tui/components/ArrayEditor.d.ts +22 -0
  20. package/dist/tui/components/ArrayEditor.js +193 -0
  21. package/dist/tui/components/BooleanToggle.d.ts +19 -0
  22. package/dist/tui/components/BooleanToggle.js +43 -0
  23. package/dist/tui/components/EditorPanel.d.ts +50 -0
  24. package/dist/tui/components/EditorPanel.js +232 -0
  25. package/dist/tui/components/HelpPanel.d.ts +13 -0
  26. package/dist/tui/components/HelpPanel.js +69 -0
  27. package/dist/tui/components/JsonSnippetEditor.d.ts +24 -0
  28. package/dist/tui/components/JsonSnippetEditor.js +380 -0
  29. package/dist/tui/components/KeyValueEditor.d.ts +34 -0
  30. package/dist/tui/components/KeyValueEditor.js +261 -0
  31. package/dist/tui/components/ObjectEditor.d.ts +23 -0
  32. package/dist/tui/components/ObjectEditor.js +227 -0
  33. package/dist/tui/components/PresetSelector.d.ts +23 -0
  34. package/dist/tui/components/PresetSelector.js +58 -0
  35. package/dist/tui/components/Preview.d.ts +18 -0
  36. package/dist/tui/components/Preview.js +190 -0
  37. package/dist/tui/components/ScrollableContainer.d.ts +38 -0
  38. package/dist/tui/components/ScrollableContainer.js +77 -0
  39. package/dist/tui/components/SectionNav.d.ts +31 -0
  40. package/dist/tui/components/SectionNav.js +130 -0
  41. package/dist/tui/components/StringEditor.d.ts +21 -0
  42. package/dist/tui/components/StringEditor.js +29 -0
  43. package/dist/tui/hooks/useConfig.d.ts +16 -0
  44. package/dist/tui/hooks/useConfig.js +89 -0
  45. package/dist/tui/hooks/useTerminalSize.d.ts +21 -0
  46. package/dist/tui/hooks/useTerminalSize.js +48 -0
  47. package/dist/tui/utils/presets.d.ts +52 -0
  48. package/dist/tui/utils/presets.js +191 -0
  49. package/dist/tui/utils/validation.d.ts +49 -0
  50. package/dist/tui/utils/validation.js +198 -0
  51. package/dist/utils/chat-client.d.ts +31 -1
  52. package/dist/utils/chat-client.js +27 -1
  53. package/dist/utils/config.d.ts +7 -2
  54. package/docs/MACOS-DEVELOPMENT.md +435 -0
  55. 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;