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.
Files changed (89) hide show
  1. package/commands/attach/basic-repl.js +212 -0
  2. package/commands/attach/cleanup-history.js +189 -0
  3. package/commands/attach/cleanup-manager.js +163 -0
  4. package/commands/attach/copy-ui/copyRepl.js +195 -0
  5. package/commands/attach/copy-ui/index.js +7 -0
  6. package/commands/attach/copy-ui/render/outputBlock.js +25 -0
  7. package/commands/attach/copy-ui/viewport/viewport.js +23 -0
  8. package/commands/attach/copy-ui/viewport/wheel.js +14 -0
  9. package/commands/attach/history-manager.js +507 -0
  10. package/commands/attach/history-session.js +48 -0
  11. package/commands/attach/ink-repl/InkREPL.js +1507 -0
  12. package/commands/attach/ink-repl/builtinCommands.js +1253 -0
  13. package/commands/attach/ink-repl/components/ConnectingScreen.js +76 -0
  14. package/commands/attach/ink-repl/components/Console.js +191 -0
  15. package/commands/attach/ink-repl/components/DetailView.js +148 -0
  16. package/commands/attach/ink-repl/components/DropdownMenu.js +86 -0
  17. package/commands/attach/ink-repl/components/InputArea.js +125 -0
  18. package/commands/attach/ink-repl/components/InputLine.js +18 -0
  19. package/commands/attach/ink-repl/components/OutputArea.js +22 -0
  20. package/commands/attach/ink-repl/components/OutputItem.js +96 -0
  21. package/commands/attach/ink-repl/components/ShellLayout.js +61 -0
  22. package/commands/attach/ink-repl/components/Spinner.js +79 -0
  23. package/commands/attach/ink-repl/components/StatusBar.js +106 -0
  24. package/commands/attach/ink-repl/components/WelcomeBanner.js +48 -0
  25. package/commands/attach/ink-repl/contexts/LayoutContext.js +12 -0
  26. package/commands/attach/ink-repl/contexts/ThemeContext.js +43 -0
  27. package/commands/attach/ink-repl/hooks/useFunctionKeys.js +70 -0
  28. package/commands/attach/ink-repl/hooks/useMouse.js +162 -0
  29. package/commands/attach/ink-repl/hooks/useResources.js +132 -0
  30. package/commands/attach/ink-repl/hooks/useSpinner.js +49 -0
  31. package/commands/attach/ink-repl/index.js +112 -0
  32. package/commands/attach/ink-repl/package.json +3 -0
  33. package/commands/attach/ink-repl/replState.js +947 -0
  34. package/commands/attach/ink-repl/shortcuts/defaultKeybindings.js +138 -0
  35. package/commands/attach/ink-repl/shortcuts/index.js +332 -0
  36. package/commands/attach/ink-repl/themes/defaultDark.js +18 -0
  37. package/commands/attach/ink-repl/themes/defaultLight.js +18 -0
  38. package/commands/attach/ink-repl/themes/index.js +4 -0
  39. package/commands/attach/ink-repl/themes/themeManager.js +45 -0
  40. package/commands/attach/ink-repl/themes/themeTokens.js +15 -0
  41. package/commands/attach/ink-repl/utils/atCompletion.js +346 -0
  42. package/commands/attach/ink-repl/utils/clipboard.js +50 -0
  43. package/commands/attach/ink-repl/utils/consoleLogger.js +81 -0
  44. package/commands/attach/ink-repl/utils/exitCodeHandler.js +49 -0
  45. package/commands/attach/ink-repl/utils/exitCodeTips.js +56 -0
  46. package/commands/attach/ink-repl/utils/formatTime.js +12 -0
  47. package/commands/attach/ink-repl/utils/outputSelection.js +120 -0
  48. package/commands/attach/ink-repl/utils/outputViewport.js +77 -0
  49. package/commands/attach/ink-repl/utils/paginatedFileLoading.js +76 -0
  50. package/commands/attach/ink-repl/utils/paramHint.js +60 -0
  51. package/commands/attach/ink-repl/utils/parseError.js +174 -0
  52. package/commands/attach/ink-repl/utils/pathCompletion.js +167 -0
  53. package/commands/attach/ink-repl/utils/remotePathSafety.js +56 -0
  54. package/commands/attach/ink-repl/utils/replSelection.js +205 -0
  55. package/commands/attach/ink-repl/utils/responseFormatter.js +127 -0
  56. package/commands/attach/ink-repl/utils/textWrap.js +117 -0
  57. package/commands/attach/ink-repl/utils/truncate.js +115 -0
  58. package/commands/attach/opentui-repl/App.tsx +891 -0
  59. package/commands/attach/opentui-repl/builtinCommands.ts +80 -0
  60. package/commands/attach/opentui-repl/components/ConfirmDialog.tsx +116 -0
  61. package/commands/attach/opentui-repl/components/ConnectingScreen.tsx +131 -0
  62. package/commands/attach/opentui-repl/components/Console.tsx +73 -0
  63. package/commands/attach/opentui-repl/components/DetailView.tsx +45 -0
  64. package/commands/attach/opentui-repl/components/DropdownMenu.tsx +130 -0
  65. package/commands/attach/opentui-repl/components/ExecutionStatus.tsx +66 -0
  66. package/commands/attach/opentui-repl/components/Header.tsx +24 -0
  67. package/commands/attach/opentui-repl/components/OutputArea.tsx +25 -0
  68. package/commands/attach/opentui-repl/components/OutputBlock.tsx +108 -0
  69. package/commands/attach/opentui-repl/components/PromptInput.tsx +109 -0
  70. package/commands/attach/opentui-repl/components/StatusBar.tsx +63 -0
  71. package/commands/attach/opentui-repl/components/Toast.tsx +65 -0
  72. package/commands/attach/opentui-repl/components/WelcomeBanner.tsx +41 -0
  73. package/commands/attach/opentui-repl/contexts/ReplContext.tsx +137 -0
  74. package/commands/attach/opentui-repl/contexts/SessionContext.tsx +32 -0
  75. package/commands/attach/opentui-repl/contexts/ThemeContext.tsx +70 -0
  76. package/commands/attach/opentui-repl/contexts/ToastContext.tsx +69 -0
  77. package/commands/attach/opentui-repl/contexts/toast-logic.js +71 -0
  78. package/commands/attach/opentui-repl/hooks/useResources.ts +102 -0
  79. package/commands/attach/opentui-repl/hooks/useSpinner.ts +46 -0
  80. package/commands/attach/opentui-repl/index.js +99 -0
  81. package/commands/attach/opentui-repl/keybindings.ts +39 -0
  82. package/commands/attach/opentui-repl/package.json +3 -0
  83. package/commands/attach/opentui-repl/render.tsx +72 -0
  84. package/commands/attach/opentui-repl/tsconfig.json +12 -0
  85. package/commands/attach/repl.js +791 -0
  86. package/commands/attach/sandbox-id-resolver.js +56 -0
  87. package/commands/attach/session-manager.js +307 -0
  88. package/commands/attach/ui-mode.js +146 -0
  89. 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;