rl-rockcli 0.0.9 → 0.0.10
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/commands/attach/basic-repl.js +212 -0
- package/commands/attach/cleanup-history.js +189 -0
- package/commands/attach/cleanup-manager.js +163 -0
- package/commands/attach/copy-ui/copyRepl.js +195 -0
- package/commands/attach/copy-ui/index.js +7 -0
- package/commands/attach/copy-ui/render/outputBlock.js +25 -0
- package/commands/attach/copy-ui/viewport/viewport.js +23 -0
- package/commands/attach/copy-ui/viewport/wheel.js +14 -0
- package/commands/attach/history-manager.js +507 -0
- package/commands/attach/history-session.js +48 -0
- package/commands/attach/ink-repl/InkREPL.js +1507 -0
- package/commands/attach/ink-repl/builtinCommands.js +1253 -0
- package/commands/attach/ink-repl/components/ConnectingScreen.js +76 -0
- package/commands/attach/ink-repl/components/Console.js +191 -0
- package/commands/attach/ink-repl/components/DetailView.js +148 -0
- package/commands/attach/ink-repl/components/DropdownMenu.js +86 -0
- package/commands/attach/ink-repl/components/InputArea.js +125 -0
- package/commands/attach/ink-repl/components/InputLine.js +18 -0
- package/commands/attach/ink-repl/components/OutputArea.js +22 -0
- package/commands/attach/ink-repl/components/OutputItem.js +96 -0
- package/commands/attach/ink-repl/components/ShellLayout.js +61 -0
- package/commands/attach/ink-repl/components/Spinner.js +79 -0
- package/commands/attach/ink-repl/components/StatusBar.js +106 -0
- package/commands/attach/ink-repl/components/WelcomeBanner.js +48 -0
- package/commands/attach/ink-repl/contexts/LayoutContext.js +12 -0
- package/commands/attach/ink-repl/contexts/ThemeContext.js +43 -0
- package/commands/attach/ink-repl/hooks/useFunctionKeys.js +70 -0
- package/commands/attach/ink-repl/hooks/useMouse.js +162 -0
- package/commands/attach/ink-repl/hooks/useResources.js +132 -0
- package/commands/attach/ink-repl/hooks/useSpinner.js +49 -0
- package/commands/attach/ink-repl/index.js +112 -0
- package/commands/attach/ink-repl/package.json +3 -0
- package/commands/attach/ink-repl/replState.js +947 -0
- package/commands/attach/ink-repl/shortcuts/defaultKeybindings.js +138 -0
- package/commands/attach/ink-repl/shortcuts/index.js +332 -0
- package/commands/attach/ink-repl/themes/defaultDark.js +18 -0
- package/commands/attach/ink-repl/themes/defaultLight.js +18 -0
- package/commands/attach/ink-repl/themes/index.js +4 -0
- package/commands/attach/ink-repl/themes/themeManager.js +45 -0
- package/commands/attach/ink-repl/themes/themeTokens.js +15 -0
- package/commands/attach/ink-repl/utils/atCompletion.js +346 -0
- package/commands/attach/ink-repl/utils/clipboard.js +50 -0
- package/commands/attach/ink-repl/utils/consoleLogger.js +81 -0
- package/commands/attach/ink-repl/utils/exitCodeHandler.js +49 -0
- package/commands/attach/ink-repl/utils/exitCodeTips.js +56 -0
- package/commands/attach/ink-repl/utils/formatTime.js +12 -0
- package/commands/attach/ink-repl/utils/outputSelection.js +120 -0
- package/commands/attach/ink-repl/utils/outputViewport.js +77 -0
- package/commands/attach/ink-repl/utils/paginatedFileLoading.js +76 -0
- package/commands/attach/ink-repl/utils/paramHint.js +60 -0
- package/commands/attach/ink-repl/utils/parseError.js +174 -0
- package/commands/attach/ink-repl/utils/pathCompletion.js +167 -0
- package/commands/attach/ink-repl/utils/remotePathSafety.js +56 -0
- package/commands/attach/ink-repl/utils/replSelection.js +205 -0
- package/commands/attach/ink-repl/utils/responseFormatter.js +127 -0
- package/commands/attach/ink-repl/utils/textWrap.js +117 -0
- package/commands/attach/ink-repl/utils/truncate.js +115 -0
- package/commands/attach/opentui-repl/App.tsx +891 -0
- package/commands/attach/opentui-repl/builtinCommands.ts +80 -0
- package/commands/attach/opentui-repl/components/ConfirmDialog.tsx +116 -0
- package/commands/attach/opentui-repl/components/ConnectingScreen.tsx +131 -0
- package/commands/attach/opentui-repl/components/Console.tsx +73 -0
- package/commands/attach/opentui-repl/components/DetailView.tsx +45 -0
- package/commands/attach/opentui-repl/components/DropdownMenu.tsx +130 -0
- package/commands/attach/opentui-repl/components/ExecutionStatus.tsx +66 -0
- package/commands/attach/opentui-repl/components/Header.tsx +24 -0
- package/commands/attach/opentui-repl/components/OutputArea.tsx +25 -0
- package/commands/attach/opentui-repl/components/OutputBlock.tsx +108 -0
- package/commands/attach/opentui-repl/components/PromptInput.tsx +109 -0
- package/commands/attach/opentui-repl/components/StatusBar.tsx +63 -0
- package/commands/attach/opentui-repl/components/Toast.tsx +65 -0
- package/commands/attach/opentui-repl/components/WelcomeBanner.tsx +41 -0
- package/commands/attach/opentui-repl/contexts/ReplContext.tsx +137 -0
- package/commands/attach/opentui-repl/contexts/SessionContext.tsx +32 -0
- package/commands/attach/opentui-repl/contexts/ThemeContext.tsx +70 -0
- package/commands/attach/opentui-repl/contexts/ToastContext.tsx +69 -0
- package/commands/attach/opentui-repl/contexts/toast-logic.js +71 -0
- package/commands/attach/opentui-repl/hooks/useResources.ts +102 -0
- package/commands/attach/opentui-repl/hooks/useSpinner.ts +46 -0
- package/commands/attach/opentui-repl/index.js +99 -0
- package/commands/attach/opentui-repl/keybindings.ts +39 -0
- package/commands/attach/opentui-repl/package.json +3 -0
- package/commands/attach/opentui-repl/render.tsx +72 -0
- package/commands/attach/opentui-repl/tsconfig.json +12 -0
- package/commands/attach/repl.js +791 -0
- package/commands/attach/sandbox-id-resolver.js +56 -0
- package/commands/attach/session-manager.js +307 -0
- package/commands/attach/ui-mode.js +146 -0
- package/package.json +1 -1
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { Box, Text, useStdout } from 'ink';
|
|
3
|
+
import { truncateOutputToLines, formatTruncationHint } from '../utils/truncate.js';
|
|
4
|
+
import { formatTimestamp } from '../utils/formatTime.js';
|
|
5
|
+
import { useTheme } from '../contexts/ThemeContext.js';
|
|
6
|
+
import { useLayout } from '../contexts/LayoutContext.js';
|
|
7
|
+
import { sliceByWidth } from '../utils/textWrap.js';
|
|
8
|
+
|
|
9
|
+
const h = React.createElement;
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Single output item (command + output)
|
|
13
|
+
*/
|
|
14
|
+
export function OutputItem({ item, prompt, maxLines = 20, onViewFull }) {
|
|
15
|
+
const { command, output, exitCode, isWelcome, timestamp, metaInfo, tips } = item;
|
|
16
|
+
const { stdout } = useStdout();
|
|
17
|
+
const terminalWidth = stdout?.columns || 80;
|
|
18
|
+
const { width: layoutWidth } = useLayout();
|
|
19
|
+
const contentWidth = Math.max(20, layoutWidth || terminalWidth);
|
|
20
|
+
const { theme } = useTheme();
|
|
21
|
+
// Outer width: border(2) + padding(2) = 4 chars overhead
|
|
22
|
+
// Use extra safety margin to prevent any overflow
|
|
23
|
+
const boxInnerWidth = Math.max(10, contentWidth - 4);
|
|
24
|
+
// Width for text content with extra safety buffer
|
|
25
|
+
const textSafeWidth = Math.max(5, boxInnerWidth - 2);
|
|
26
|
+
|
|
27
|
+
const hasOutput = typeof output === 'string' ? output.length > 0 : !!output;
|
|
28
|
+
const { lines: outputLines, truncated, hiddenLines } = hasOutput
|
|
29
|
+
? truncateOutputToLines(String(output), boxInnerWidth, maxLines)
|
|
30
|
+
: { lines: [], truncated: false, hiddenLines: 0 };
|
|
31
|
+
|
|
32
|
+
// Welcome message - no command line, just output
|
|
33
|
+
if (isWelcome) {
|
|
34
|
+
return h(Box, { flexDirection: 'column', paddingLeft: 1 },
|
|
35
|
+
h(Text, { color: theme.colors.accent, wrap: 'wrap' }, output)
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const outputColor = exitCode === 0 ? theme.colors.textPrimary : theme.colors.danger;
|
|
40
|
+
|
|
41
|
+
return h(Box, {
|
|
42
|
+
flexDirection: 'column',
|
|
43
|
+
width: contentWidth,
|
|
44
|
+
flexShrink: 0, // Never shrink - prevents content overlap
|
|
45
|
+
borderStyle: 'round',
|
|
46
|
+
borderColor: exitCode === 0 ? theme.colors.border : theme.colors.danger,
|
|
47
|
+
},
|
|
48
|
+
// Inner content box: adds padding so mouse selection naturally stays inside
|
|
49
|
+
h(Box, {
|
|
50
|
+
flexDirection: 'column',
|
|
51
|
+
paddingLeft: 1,
|
|
52
|
+
paddingRight: 1,
|
|
53
|
+
paddingTop: 0,
|
|
54
|
+
paddingBottom: hasOutput ? 0 : 0,
|
|
55
|
+
},
|
|
56
|
+
// Command line with timestamp
|
|
57
|
+
h(Box, { width: boxInnerWidth, flexDirection: 'row' },
|
|
58
|
+
h(Text, { wrap: 'truncate' },
|
|
59
|
+
timestamp ? h(Text, { color: theme.colors.textSecondary, dimColor: true }, `[${formatTimestamp(timestamp)}] `) : null,
|
|
60
|
+
h(Text, { color: theme.colors.prompt }, String(prompt)),
|
|
61
|
+
h(Text, { color: theme.colors.textPrimary }, String(command))
|
|
62
|
+
)
|
|
63
|
+
),
|
|
64
|
+
// Output - render wrapped lines with proper width constraint
|
|
65
|
+
hasOutput ? h(Box, { flexDirection: 'column', width: boxInnerWidth, paddingTop: 0 },
|
|
66
|
+
...outputLines.map((line, i) => {
|
|
67
|
+
// Truncate line to safe width
|
|
68
|
+
const safeLine = sliceByWidth(line, textSafeWidth).head || ' ';
|
|
69
|
+
return h(Box, { key: `out-${i}`, width: textSafeWidth },
|
|
70
|
+
h(Text, { color: outputColor }, safeLine)
|
|
71
|
+
);
|
|
72
|
+
}),
|
|
73
|
+
// Truncation hint - wrap in Box to ensure it's on its own line
|
|
74
|
+
truncated
|
|
75
|
+
? h(Box, { key: 'hint' },
|
|
76
|
+
h(Text, { color: theme.colors.warning, dimColor: true },
|
|
77
|
+
formatTruncationHint(hiddenLines)
|
|
78
|
+
)
|
|
79
|
+
)
|
|
80
|
+
: null
|
|
81
|
+
) : null,
|
|
82
|
+
// Meta info (exit code) - displayed in gray when command fails
|
|
83
|
+
metaInfo && exitCode !== 0
|
|
84
|
+
? h(Box, { key: 'meta', marginTop: 1 },
|
|
85
|
+
h(Text, { dimColor: true }, metaInfo)
|
|
86
|
+
)
|
|
87
|
+
: null,
|
|
88
|
+
// Tips - helpful hints when command fails
|
|
89
|
+
tips && exitCode !== 0
|
|
90
|
+
? h(Box, { key: 'tips' },
|
|
91
|
+
h(Text, { color: theme.colors.warning }, `💡 ${tips}`)
|
|
92
|
+
)
|
|
93
|
+
: null
|
|
94
|
+
)
|
|
95
|
+
);
|
|
96
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import React, { useMemo } from 'react';
|
|
2
|
+
import { Box, useStdout } from 'ink';
|
|
3
|
+
import { LayoutProvider } from '../contexts/LayoutContext.js';
|
|
4
|
+
|
|
5
|
+
const h = React.createElement;
|
|
6
|
+
|
|
7
|
+
const DEFAULT_WIDTH_FRACTION = 1;
|
|
8
|
+
const MAX_WIDTH = Infinity;
|
|
9
|
+
const VERTICAL_PADDING = 2;
|
|
10
|
+
const SMALL_TERMINAL_ROWS = 16;
|
|
11
|
+
|
|
12
|
+
export function ShellLayout({ children, widthFraction = DEFAULT_WIDTH_FRACTION }) {
|
|
13
|
+
const { stdout } = useStdout();
|
|
14
|
+
const columns = stdout?.columns || 100;
|
|
15
|
+
const rows = stdout?.rows || 30;
|
|
16
|
+
const verticalPadding = rows <= SMALL_TERMINAL_ROWS ? 0 : VERTICAL_PADDING;
|
|
17
|
+
|
|
18
|
+
const contentWidth = useMemo(() => {
|
|
19
|
+
const maxByFraction = Math.floor(columns * widthFraction);
|
|
20
|
+
const clamped = Math.min(maxByFraction, MAX_WIDTH);
|
|
21
|
+
// fallback to full width when terminal too narrow
|
|
22
|
+
if (columns < 80) {
|
|
23
|
+
return columns;
|
|
24
|
+
}
|
|
25
|
+
return clamped;
|
|
26
|
+
}, [columns, widthFraction]);
|
|
27
|
+
|
|
28
|
+
const horizontalPadding = Math.max(0, Math.floor((columns - contentWidth) / 2));
|
|
29
|
+
|
|
30
|
+
// Separate children into content and statusBar
|
|
31
|
+
const childArray = React.Children.toArray(children);
|
|
32
|
+
const statusBar = childArray[childArray.length - 1]; // Last child is StatusBar
|
|
33
|
+
const content = childArray.slice(0, -1); // Everything else
|
|
34
|
+
|
|
35
|
+
return h(Box, {
|
|
36
|
+
flexDirection: 'column',
|
|
37
|
+
height: rows,
|
|
38
|
+
width: columns,
|
|
39
|
+
paddingLeft: horizontalPadding,
|
|
40
|
+
paddingRight: horizontalPadding,
|
|
41
|
+
},
|
|
42
|
+
h(LayoutProvider, { width: contentWidth },
|
|
43
|
+
h(Box, { flexDirection: 'column', height: rows },
|
|
44
|
+
// Scrollable content area
|
|
45
|
+
h(Box, {
|
|
46
|
+
flexDirection: 'column',
|
|
47
|
+
width: contentWidth,
|
|
48
|
+
flexGrow: 1,
|
|
49
|
+
overflow: 'hidden',
|
|
50
|
+
paddingTop: verticalPadding,
|
|
51
|
+
}, content),
|
|
52
|
+
// Fixed status bar at bottom
|
|
53
|
+
h(Box, {
|
|
54
|
+
flexDirection: 'column',
|
|
55
|
+
width: contentWidth,
|
|
56
|
+
flexShrink: 0,
|
|
57
|
+
}, statusBar)
|
|
58
|
+
)
|
|
59
|
+
)
|
|
60
|
+
);
|
|
61
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import React, { useState, useEffect } from 'react';
|
|
2
|
+
import { Box, Text } from 'ink';
|
|
3
|
+
import { useSpinner, formatSpinnerText } from '../hooks/useSpinner.js';
|
|
4
|
+
|
|
5
|
+
const h = React.createElement;
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Format duration in human readable format
|
|
9
|
+
*/
|
|
10
|
+
export function formatDuration(ms) {
|
|
11
|
+
if (ms === null || ms === undefined) return '';
|
|
12
|
+
if (ms < 1000) return `${ms}ms`;
|
|
13
|
+
if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`;
|
|
14
|
+
const minutes = Math.floor(ms / 60000);
|
|
15
|
+
const seconds = ((ms % 60000) / 1000).toFixed(0);
|
|
16
|
+
return `${minutes}m${seconds}s`;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Executing spinner - shows in output area (not input area)
|
|
21
|
+
* Layout:
|
|
22
|
+
* ⏵ executing command...
|
|
23
|
+
*/
|
|
24
|
+
export function ExecutingSpinner({ command, maxWidth = 60 }) {
|
|
25
|
+
const { frame } = useSpinner(true);
|
|
26
|
+
const text = formatSpinnerText(frame, command, maxWidth);
|
|
27
|
+
|
|
28
|
+
return h(Box, { flexDirection: 'column' },
|
|
29
|
+
h(Text, null, ''), // Empty line before
|
|
30
|
+
h(Box, null,
|
|
31
|
+
h(Text, { color: 'yellow' }, text)
|
|
32
|
+
),
|
|
33
|
+
h(Text, null, '') // Empty line after
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Execution status area - shows spinner when executing, duration when done
|
|
39
|
+
* Shows elapsed time when executing for more than 1 minute
|
|
40
|
+
*/
|
|
41
|
+
export function ExecutionStatus({ isExecuting, command, lastDuration, executionStartTime, maxWidth = 60 }) {
|
|
42
|
+
const { frame } = useSpinner(isExecuting);
|
|
43
|
+
const [elapsedTime, setElapsedTime] = useState(0);
|
|
44
|
+
|
|
45
|
+
// Update elapsed time every second when executing
|
|
46
|
+
useEffect(() => {
|
|
47
|
+
if (!isExecuting || !executionStartTime) {
|
|
48
|
+
setElapsedTime(0);
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const updateElapsed = () => {
|
|
53
|
+
setElapsedTime(Date.now() - executionStartTime);
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
updateElapsed();
|
|
57
|
+
const interval = setInterval(updateElapsed, 1000);
|
|
58
|
+
return () => clearInterval(interval);
|
|
59
|
+
}, [isExecuting, executionStartTime]);
|
|
60
|
+
|
|
61
|
+
let content;
|
|
62
|
+
if (isExecuting) {
|
|
63
|
+
const text = formatSpinnerText(frame, command, maxWidth);
|
|
64
|
+
// Show elapsed time if executing for more than 1 minute (60000ms)
|
|
65
|
+
if (elapsedTime > 60000) {
|
|
66
|
+
content = h(Text, { color: 'yellow' }, `${text} (${formatDuration(elapsedTime)})`);
|
|
67
|
+
} else {
|
|
68
|
+
content = h(Text, { color: 'yellow' }, text);
|
|
69
|
+
}
|
|
70
|
+
} else if (lastDuration !== null) {
|
|
71
|
+
content = h(Text, { dimColor: true }, `⏵ completed in ${formatDuration(lastDuration)}`);
|
|
72
|
+
} else {
|
|
73
|
+
content = h(Text, { dimColor: true }, '⏵ ready');
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return h(Box, { flexDirection: 'column', flexShrink: 0 },
|
|
77
|
+
h(Box, { paddingLeft: 1 }, content)
|
|
78
|
+
);
|
|
79
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { Box, Text } from 'ink';
|
|
3
|
+
import { keybindingManager } from '../shortcuts/index.js';
|
|
4
|
+
import { useTheme } from '../contexts/ThemeContext.js';
|
|
5
|
+
|
|
6
|
+
const h = React.createElement;
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Format resource usage for display
|
|
10
|
+
*/
|
|
11
|
+
export function formatResources(resources) {
|
|
12
|
+
if (!resources) return '';
|
|
13
|
+
|
|
14
|
+
const parts = [];
|
|
15
|
+
|
|
16
|
+
if (resources.cpu !== undefined) {
|
|
17
|
+
parts.push(`CPU ${resources.cpu}`);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
if (resources.load !== undefined) {
|
|
21
|
+
parts.push(`LOAD ${resources.load}`);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
if (resources.memory) {
|
|
25
|
+
parts.push(`MEM ${resources.memory.used}/${resources.memory.total}`);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
if (resources.disk) {
|
|
29
|
+
parts.push(`DISK ${resources.disk.used}/${resources.disk.total}`);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
return parts.join(' ');
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* StatusBar component
|
|
37
|
+
* Layout: {sandboxId} {systemInfo} {keyHints}
|
|
38
|
+
* Uses flexbox for proper spacing without wrapping
|
|
39
|
+
*/
|
|
40
|
+
export function StatusBar({
|
|
41
|
+
sandboxId = '',
|
|
42
|
+
hostIp = null,
|
|
43
|
+
resources = null,
|
|
44
|
+
mode = 'repl',
|
|
45
|
+
menuVisible = false,
|
|
46
|
+
isExecuting = false,
|
|
47
|
+
consoleVisible = false,
|
|
48
|
+
exitPending = false,
|
|
49
|
+
hasSelection = false,
|
|
50
|
+
mouseCaptureEnabled = true,
|
|
51
|
+
}) {
|
|
52
|
+
const { theme } = useTheme();
|
|
53
|
+
// Build sections: left=sandboxId + hostIp, middle=resources, right=keyHints
|
|
54
|
+
const idPart = sandboxId ? `◉ ${sandboxId}` : '';
|
|
55
|
+
const leftSection = hostIp ? `${idPart} ${hostIp}` : idPart;
|
|
56
|
+
const middleSection = formatResources(resources);
|
|
57
|
+
|
|
58
|
+
// Build simplified key hints based on mode and state
|
|
59
|
+
let rightSection = '';
|
|
60
|
+
|
|
61
|
+
if (hasSelection) {
|
|
62
|
+
// Text selected: copy mode
|
|
63
|
+
rightSection = '⏎ copy selection esc cancel';
|
|
64
|
+
} else if (mode === 'detail') {
|
|
65
|
+
// Detail view: minimal hints
|
|
66
|
+
rightSection = 'esc back';
|
|
67
|
+
} else if (isExecuting) {
|
|
68
|
+
// Command executing
|
|
69
|
+
rightSection = 'ctrl+c cancel F12 console';
|
|
70
|
+
} else if (menuVisible) {
|
|
71
|
+
// Menu visible
|
|
72
|
+
rightSection = '⏎ confirm';
|
|
73
|
+
} else {
|
|
74
|
+
// Default REPL mode: minimal hints
|
|
75
|
+
rightSection = '/ commands F12 console ctrl+c exit';
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Exit pending warning message
|
|
79
|
+
const exitWarning = exitPending ? 'Press ctrl+c again to exit' : null;
|
|
80
|
+
|
|
81
|
+
// Use flexbox layout: left | center | right (三栏等宽布局)
|
|
82
|
+
return h(Box, { flexDirection: 'column', marginTop: 1 },
|
|
83
|
+
// Exit pending warning - shown above the status bar
|
|
84
|
+
exitWarning && h(Box, { paddingLeft: 2 },
|
|
85
|
+
h(Text, { color: theme.colors.warning, bold: true }, `⚠ ${exitWarning}`)
|
|
86
|
+
),
|
|
87
|
+
h(Box, {
|
|
88
|
+
paddingLeft: 2,
|
|
89
|
+
paddingRight: 2,
|
|
90
|
+
paddingTop: exitWarning ? 0 : 1,
|
|
91
|
+
},
|
|
92
|
+
// Left section: sandbox ID (cyan)
|
|
93
|
+
h(Box, { flexGrow: 1, flexBasis: 0 },
|
|
94
|
+
h(Text, { color: theme.colors.accent }, leftSection)
|
|
95
|
+
),
|
|
96
|
+
// Center section: resources (yellow) - centered
|
|
97
|
+
h(Box, { flexGrow: 1, flexBasis: 0, justifyContent: 'center' },
|
|
98
|
+
middleSection ? h(Text, { color: theme.colors.warning }, middleSection) : null
|
|
99
|
+
),
|
|
100
|
+
// Right section: key hints (dim) - right aligned
|
|
101
|
+
h(Box, { flexGrow: 1, flexBasis: 0, justifyContent: 'flex-end' },
|
|
102
|
+
h(Text, { color: theme.colors.textSecondary }, rightSection)
|
|
103
|
+
)
|
|
104
|
+
)
|
|
105
|
+
);
|
|
106
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { Box, Text } from 'ink';
|
|
3
|
+
import { useTheme } from '../contexts/ThemeContext.js';
|
|
4
|
+
import i18n from '../../../../utils/i18n.js';
|
|
5
|
+
const { t } = i18n;
|
|
6
|
+
|
|
7
|
+
const h = React.createElement;
|
|
8
|
+
|
|
9
|
+
const ASCII_LINES = [
|
|
10
|
+
'██████╗ ██████ ██████╗ ██╗ ██╗ ██████╗ ██╗ ██╗',
|
|
11
|
+
'██╔══██╗ ██╔═══██╗ ██╔════╝ ██║ ██╔╝ ██╔════╝ ██║ ██║',
|
|
12
|
+
'██████╔╝ ██║ ██║ ██║ █████╔╝ ██║ ██║ ██║',
|
|
13
|
+
'██╔══██╗ ██║ ██║ ██║ ██╔═██╗ ██║ ██║ ██║',
|
|
14
|
+
'██║ ██║ ╚██████╔╝ ╚██████╗ ██║ ██╗ ╚██████╗ ███████╗ ██║',
|
|
15
|
+
'╚═╝ ╚═╝ ╚═════╝ ╚═════╝ ╚═╝ ╚═╝ ╚═════╝ ╚══════╝ ╚═╝',
|
|
16
|
+
];
|
|
17
|
+
|
|
18
|
+
export function WelcomeBanner({ title = 'ROCK' }) {
|
|
19
|
+
const { theme } = useTheme();
|
|
20
|
+
|
|
21
|
+
let gradient = theme.colors.gradient || ['#89b4fa', '#cba6f7', '#f38ba8'];
|
|
22
|
+
if (theme.semantic && theme.semantic.banner) {
|
|
23
|
+
gradient = theme.semantic.banner;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const TIPS = [
|
|
27
|
+
t('welcome.tip.commands'),
|
|
28
|
+
t('welcome.tip.execute'),
|
|
29
|
+
t('welcome.tip.console'),
|
|
30
|
+
t('welcome.tip.exit'),
|
|
31
|
+
];
|
|
32
|
+
|
|
33
|
+
return h(Box, { flexDirection: 'column', alignItems: 'center', marginBottom: 1 },
|
|
34
|
+
ASCII_LINES.map((line, index) =>
|
|
35
|
+
h(Text, {
|
|
36
|
+
key: `banner-line-${index}`,
|
|
37
|
+
color: gradient[index % gradient.length],
|
|
38
|
+
bold: true,
|
|
39
|
+
}, line)
|
|
40
|
+
),
|
|
41
|
+
h(Box, { flexDirection: 'column', alignItems: 'center', marginTop: 1 },
|
|
42
|
+
TIPS.map((tip, idx) => h(Text, {
|
|
43
|
+
key: `tip-${idx}`,
|
|
44
|
+
color: theme.colors.textSecondary,
|
|
45
|
+
}, tip))
|
|
46
|
+
)
|
|
47
|
+
);
|
|
48
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import React, { createContext, useContext, useMemo } from 'react';
|
|
2
|
+
|
|
3
|
+
const LayoutContext = createContext({ width: undefined });
|
|
4
|
+
|
|
5
|
+
export function LayoutProvider({ width, children }) {
|
|
6
|
+
const value = useMemo(() => ({ width }), [width]);
|
|
7
|
+
return React.createElement(LayoutContext.Provider, { value }, children);
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function useLayout() {
|
|
11
|
+
return useContext(LayoutContext);
|
|
12
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import React, { createContext, useContext, useMemo, useState } from 'react';
|
|
2
|
+
import { themeManager } from '../themes/index.js';
|
|
3
|
+
|
|
4
|
+
const ThemeContext = createContext({
|
|
5
|
+
theme: themeManager.getActiveTheme(),
|
|
6
|
+
themeName: themeManager.getActiveTheme().name,
|
|
7
|
+
setTheme: () => false,
|
|
8
|
+
listThemes: () => [],
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
export function ThemeProvider({ initialTheme, onThemeChange, children }) {
|
|
12
|
+
const [themeName, setThemeName] = useState(() => {
|
|
13
|
+
if (initialTheme && themeManager.setActiveTheme(initialTheme)) {
|
|
14
|
+
return initialTheme;
|
|
15
|
+
}
|
|
16
|
+
return themeManager.getActiveTheme().name;
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
const value = useMemo(() => {
|
|
20
|
+
const activeTheme = themeManager.getActiveTheme();
|
|
21
|
+
return {
|
|
22
|
+
theme: activeTheme,
|
|
23
|
+
themeName: activeTheme.name,
|
|
24
|
+
setTheme: (name) => {
|
|
25
|
+
if (themeManager.setActiveTheme(name)) {
|
|
26
|
+
setThemeName(name);
|
|
27
|
+
if (onThemeChange) {
|
|
28
|
+
onThemeChange(name);
|
|
29
|
+
}
|
|
30
|
+
return true;
|
|
31
|
+
}
|
|
32
|
+
return false;
|
|
33
|
+
},
|
|
34
|
+
listThemes: () => themeManager.listThemes(),
|
|
35
|
+
};
|
|
36
|
+
}, [themeName, onThemeChange]);
|
|
37
|
+
|
|
38
|
+
return React.createElement(ThemeContext.Provider, { value }, children);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function useTheme() {
|
|
42
|
+
return useContext(ThemeContext);
|
|
43
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { useEffect } from 'react';
|
|
2
|
+
import { useStdin } from 'ink';
|
|
3
|
+
import { keybindingManager, FN_KEY_SEQUENCES } from '../shortcuts/index.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Hook to detect function keys (F1-F12) and map them to actions
|
|
7
|
+
* Ink's useInput doesn't expose function keys, so we need to listen to raw input
|
|
8
|
+
*
|
|
9
|
+
* @param {Object} actionHandlers - Object mapping action names to handler functions
|
|
10
|
+
* e.g., { toggleConsole: () => setState(s => toggleConsole(s)) }
|
|
11
|
+
* @param {Object} options - Options
|
|
12
|
+
* @param {boolean} options.isActive - Whether the hook is active
|
|
13
|
+
*/
|
|
14
|
+
export function useFunctionKeys(actionHandlers, options = {}) {
|
|
15
|
+
// Access Ink's internal event emitter for input events
|
|
16
|
+
const stdinContext = useStdin();
|
|
17
|
+
const isActive = options.isActive !== false;
|
|
18
|
+
|
|
19
|
+
useEffect(() => {
|
|
20
|
+
if (!isActive) return;
|
|
21
|
+
|
|
22
|
+
// Use Ink's internal_eventEmitter if available (Ink v6+)
|
|
23
|
+
const eventEmitter = stdinContext.internal_eventEmitter;
|
|
24
|
+
|
|
25
|
+
if (eventEmitter) {
|
|
26
|
+
// Listen to raw input events from Ink's internal emitter
|
|
27
|
+
const handleInput = (data) => {
|
|
28
|
+
const input = String(data);
|
|
29
|
+
|
|
30
|
+
// Try to match against each action
|
|
31
|
+
for (const [action, handler] of Object.entries(actionHandlers)) {
|
|
32
|
+
if (keybindingManager.matchesFunctionKeyAction(action, input)) {
|
|
33
|
+
handler();
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
eventEmitter.on('input', handleInput);
|
|
40
|
+
return () => {
|
|
41
|
+
eventEmitter.off('input', handleInput);
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Fallback: listen directly to stdin (older Ink versions)
|
|
46
|
+
const { stdin, setRawMode } = stdinContext;
|
|
47
|
+
if (!stdin) return;
|
|
48
|
+
|
|
49
|
+
setRawMode(true);
|
|
50
|
+
|
|
51
|
+
const handleData = (data) => {
|
|
52
|
+
const input = String(data);
|
|
53
|
+
|
|
54
|
+
// Try to match against each action
|
|
55
|
+
for (const [action, handler] of Object.entries(actionHandlers)) {
|
|
56
|
+
if (keybindingManager.matchesFunctionKeyAction(action, input)) {
|
|
57
|
+
handler();
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
stdin.on('data', handleData);
|
|
64
|
+
return () => {
|
|
65
|
+
stdin.off('data', handleData);
|
|
66
|
+
};
|
|
67
|
+
}, [stdinContext, isActive, actionHandlers]);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export default useFunctionKeys;
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import React, { useEffect } from 'react';
|
|
2
|
+
import { useStdin, useStdout } from 'ink';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Parse SGR mouse event (ESC[<button;x;yM/m)
|
|
6
|
+
* @param {string} input - Raw input string
|
|
7
|
+
* @returns {Object|null} Parsed mouse event or null
|
|
8
|
+
*/
|
|
9
|
+
function parseMouseEvent(input) {
|
|
10
|
+
// SGR format: ESC[<button;x;yM (press) or ESC[<button;x;ym (release)
|
|
11
|
+
const match = input.match(/\x1b\[<(\d+);(\d+);(\d+)([Mm])/);
|
|
12
|
+
if (!match) return null;
|
|
13
|
+
|
|
14
|
+
const button = parseInt(match[1], 10);
|
|
15
|
+
const x = parseInt(match[2], 10);
|
|
16
|
+
const y = parseInt(match[3], 10);
|
|
17
|
+
const isRelease = match[4] === 'm';
|
|
18
|
+
|
|
19
|
+
// Decode button:
|
|
20
|
+
// 0 = left click, 1 = middle, 2 = right
|
|
21
|
+
// 64 = scroll up, 65 = scroll down
|
|
22
|
+
// 32+ = motion with button held
|
|
23
|
+
let type = 'click';
|
|
24
|
+
let scrollDirection = null;
|
|
25
|
+
|
|
26
|
+
if (button === 64) {
|
|
27
|
+
type = 'scroll';
|
|
28
|
+
scrollDirection = 'up';
|
|
29
|
+
} else if (button === 65) {
|
|
30
|
+
type = 'scroll';
|
|
31
|
+
scrollDirection = 'down';
|
|
32
|
+
} else if (button >= 32 && button < 64) {
|
|
33
|
+
type = 'motion';
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
return {
|
|
37
|
+
type,
|
|
38
|
+
button: button & 3, // Extract actual button (0, 1, 2)
|
|
39
|
+
x,
|
|
40
|
+
y,
|
|
41
|
+
isRelease,
|
|
42
|
+
scrollDirection,
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Hook to handle mouse events including scroll wheel and drag selection
|
|
48
|
+
*
|
|
49
|
+
* @param {Object} handlers - Event handlers
|
|
50
|
+
* @param {Function} handlers.onScroll - Called with (direction: 'up'|'down', x, y)
|
|
51
|
+
* @param {Function} handlers.onClick - Called with (button, x, y, isRelease)
|
|
52
|
+
* @param {Function} handlers.onMouseDown - Called with (button, x, y) when mouse button is pressed
|
|
53
|
+
* @param {Function} handlers.onMouseMove - Called with (x, y, isDragging) when mouse moves
|
|
54
|
+
* @param {Function} handlers.onMouseUp - Called with (button, x, y) when mouse button is released
|
|
55
|
+
* @param {Object} options - Options
|
|
56
|
+
* @param {boolean} options.isActive - Whether mouse mode is active (default: true)
|
|
57
|
+
*/
|
|
58
|
+
export function useMouse(handlers, options = {}) {
|
|
59
|
+
const { stdout } = useStdout();
|
|
60
|
+
const stdinContext = useStdin();
|
|
61
|
+
const isActive = options.isActive !== false;
|
|
62
|
+
|
|
63
|
+
// Track dragging state using refs to avoid re-renders
|
|
64
|
+
const dragStateRef = React.useRef({
|
|
65
|
+
isDragging: false,
|
|
66
|
+
button: null,
|
|
67
|
+
startX: 0,
|
|
68
|
+
startY: 0,
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
// Enable/disable mouse mode
|
|
72
|
+
useEffect(() => {
|
|
73
|
+
if (!stdout || !isActive) return;
|
|
74
|
+
|
|
75
|
+
// Skip in test environment (ink-testing-library doesn't have isTTY)
|
|
76
|
+
if (!stdout.isTTY) return;
|
|
77
|
+
|
|
78
|
+
// Enable SGR mouse mode (1006) for better coordinate handling
|
|
79
|
+
// Enable button event mode (1002) for scroll wheel + click + drag
|
|
80
|
+
// Note: 1002 is a superset of 1000, no need to enable both
|
|
81
|
+
// (gemini-cli also uses only 1002h + 1006h)
|
|
82
|
+
const enableMouse = '\x1b[?1002h\x1b[?1006h';
|
|
83
|
+
const disableMouse = '\x1b[?1006l\x1b[?1002l';
|
|
84
|
+
|
|
85
|
+
stdout.write(enableMouse);
|
|
86
|
+
|
|
87
|
+
return () => {
|
|
88
|
+
stdout.write(disableMouse);
|
|
89
|
+
};
|
|
90
|
+
}, [stdout, isActive]);
|
|
91
|
+
|
|
92
|
+
// Handle mouse events
|
|
93
|
+
useEffect(() => {
|
|
94
|
+
if (!isActive) return;
|
|
95
|
+
|
|
96
|
+
const eventEmitter = stdinContext.internal_eventEmitter;
|
|
97
|
+
|
|
98
|
+
const handleInput = (data) => {
|
|
99
|
+
const input = String(data);
|
|
100
|
+
|
|
101
|
+
// Check for mouse event
|
|
102
|
+
if (!input.includes('\x1b[<')) return;
|
|
103
|
+
|
|
104
|
+
const event = parseMouseEvent(input);
|
|
105
|
+
if (!event) return;
|
|
106
|
+
|
|
107
|
+
if (event.type === 'scroll' && handlers.onScroll) {
|
|
108
|
+
handlers.onScroll(event.scrollDirection, event.x, event.y);
|
|
109
|
+
} else if (event.type === 'click') {
|
|
110
|
+
// Handle click press/release for drag detection
|
|
111
|
+
if (!event.isRelease) {
|
|
112
|
+
// Mouse down
|
|
113
|
+
dragStateRef.current = {
|
|
114
|
+
isDragging: true,
|
|
115
|
+
button: event.button,
|
|
116
|
+
startX: event.x,
|
|
117
|
+
startY: event.y,
|
|
118
|
+
};
|
|
119
|
+
if (handlers.onMouseDown) {
|
|
120
|
+
handlers.onMouseDown(event.button, event.x, event.y);
|
|
121
|
+
}
|
|
122
|
+
} else {
|
|
123
|
+
// Mouse up
|
|
124
|
+
if (handlers.onMouseUp) {
|
|
125
|
+
handlers.onMouseUp(event.button, event.x, event.y);
|
|
126
|
+
}
|
|
127
|
+
// Reset drag state
|
|
128
|
+
dragStateRef.current.isDragging = false;
|
|
129
|
+
dragStateRef.current.button = null;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Also call onClick for backward compatibility
|
|
133
|
+
if (handlers.onClick) {
|
|
134
|
+
handlers.onClick(event.button, event.x, event.y, event.isRelease);
|
|
135
|
+
}
|
|
136
|
+
} else if (event.type === 'motion') {
|
|
137
|
+
// Mouse move with button held
|
|
138
|
+
if (handlers.onMouseMove) {
|
|
139
|
+
handlers.onMouseMove(event.x, event.y, dragStateRef.current.isDragging);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
if (eventEmitter) {
|
|
145
|
+
eventEmitter.on('input', handleInput);
|
|
146
|
+
return () => {
|
|
147
|
+
eventEmitter.off('input', handleInput);
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Fallback for older Ink versions
|
|
152
|
+
const { stdin } = stdinContext;
|
|
153
|
+
if (!stdin) return;
|
|
154
|
+
|
|
155
|
+
stdin.on('data', handleInput);
|
|
156
|
+
return () => {
|
|
157
|
+
stdin.off('data', handleInput);
|
|
158
|
+
};
|
|
159
|
+
}, [stdinContext, isActive, handlers]);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export default useMouse;
|