rl-rockcli 0.0.9 → 0.0.11
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/commands/attach.js +186 -0
- package/package.json +1 -1
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { Box, Text } from 'ink';
|
|
3
|
+
import { useTheme } from '../contexts/ThemeContext.js';
|
|
4
|
+
import { useSpinner } from '../hooks/useSpinner.js';
|
|
5
|
+
import i18n from '../../../../utils/i18n.js';
|
|
6
|
+
const { t } = i18n;
|
|
7
|
+
|
|
8
|
+
const h = React.createElement;
|
|
9
|
+
|
|
10
|
+
const ASCII_LINES = [
|
|
11
|
+
'██████╗ ██████ ██████╗ ██╗ ██╗ ██████╗ ██╗ ██╗',
|
|
12
|
+
'██╔══██╗ ██╔═══██╗ ██╔════╝ ██║ ██╔╝ ██╔════╝ ██║ ██║',
|
|
13
|
+
'██████╔╝ ██║ ██║ ██║ █████╔╝ ██║ ██║ ██║',
|
|
14
|
+
'██╔══██╗ ██║ ██║ ██║ ██╔═██╗ ██║ ██║ ██║',
|
|
15
|
+
'██║ ██║ ╚██████╔╝ ╚██████╗ ██║ ██╗ ╚██████╗ ███████╗ ██║',
|
|
16
|
+
'╚═╝ ╚═╝ ╚═════╝ ╚═════╝ ╚═╝ ╚═╝ ╚═════╝ ╚══════╝ ╚═╝',
|
|
17
|
+
];
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Connecting screen with ROCK CLI branding
|
|
21
|
+
* @param {Object} props
|
|
22
|
+
* @param {string} props.sandboxId - Sandbox ID being connected to
|
|
23
|
+
* @param {number} props.attempt - Current attempt number
|
|
24
|
+
* @param {number} props.maxAttempts - Maximum number of attempts
|
|
25
|
+
*/
|
|
26
|
+
export function ConnectingScreen({ sandboxId, attempt = 1, maxAttempts = 30 }) {
|
|
27
|
+
const { theme } = useTheme();
|
|
28
|
+
const { frame } = useSpinner(true);
|
|
29
|
+
|
|
30
|
+
let gradient = theme.colors.gradient || ['#89b4fa', '#cba6f7', '#f38ba8'];
|
|
31
|
+
if (theme.semantic && theme.semantic.banner) {
|
|
32
|
+
gradient = theme.semantic.banner;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const connectingText = t('connecting.message') || '🔄 正在连接沙箱...';
|
|
36
|
+
const attemptText = attempt > 1 ? t('connecting.attempt') || `尝试 ${attempt}/${maxAttempts} 次...` : '';
|
|
37
|
+
|
|
38
|
+
return h(Box, {
|
|
39
|
+
flexDirection: 'column',
|
|
40
|
+
alignItems: 'center',
|
|
41
|
+
justifyContent: 'center',
|
|
42
|
+
paddingTop: 2,
|
|
43
|
+
paddingBottom: 2,
|
|
44
|
+
},
|
|
45
|
+
// ASCII Art Banner
|
|
46
|
+
h(Box, { flexDirection: 'column', alignItems: 'center', marginBottom: 2 },
|
|
47
|
+
ASCII_LINES.map((line, index) =>
|
|
48
|
+
h(Text, {
|
|
49
|
+
key: `banner-line-${index}`,
|
|
50
|
+
color: gradient[index % gradient.length],
|
|
51
|
+
bold: true,
|
|
52
|
+
}, line)
|
|
53
|
+
)
|
|
54
|
+
),
|
|
55
|
+
|
|
56
|
+
// Connecting message
|
|
57
|
+
h(Box, { marginBottom: 1 },
|
|
58
|
+
h(Text, { color: theme.colors.primary || '#89b4fa' }, connectingText)
|
|
59
|
+
),
|
|
60
|
+
|
|
61
|
+
// Sandbox ID
|
|
62
|
+
h(Box, { marginBottom: 1 },
|
|
63
|
+
h(Text, { color: theme.colors.textSecondary || 'gray' }, sandboxId)
|
|
64
|
+
),
|
|
65
|
+
|
|
66
|
+
// Spinner
|
|
67
|
+
h(Box, { marginBottom: 1 },
|
|
68
|
+
h(Text, { color: 'yellow' }, `${frame} Loading...`)
|
|
69
|
+
),
|
|
70
|
+
|
|
71
|
+
// Attempt counter (only show if attempt > 1)
|
|
72
|
+
attempt > 1 ? h(Box, {},
|
|
73
|
+
h(Text, { color: theme.colors.textSecondary || 'gray' }, attemptText.replace('${attempt}', attempt).replace('${maxAttempts}', maxAttempts))
|
|
74
|
+
) : null
|
|
75
|
+
);
|
|
76
|
+
}
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { Box, Text, useStdout } from 'ink';
|
|
3
|
+
import { getLineSelectionRanges } from '../replState.js';
|
|
4
|
+
import { useTheme } from '../contexts/ThemeContext.js';
|
|
5
|
+
|
|
6
|
+
const h = React.createElement;
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Truncate text to fit terminal width
|
|
10
|
+
* @param {string} text Text to truncate
|
|
11
|
+
* @param {number} maxWidth Maximum width
|
|
12
|
+
* @returns {string} Truncated text
|
|
13
|
+
*/
|
|
14
|
+
function truncateText(text, maxWidth) {
|
|
15
|
+
if (!text || text.length <= maxWidth) return text;
|
|
16
|
+
return text.slice(0, maxWidth - 3) + '...';
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Render a log line with selection highlighting
|
|
21
|
+
* @param {string} text - The log message
|
|
22
|
+
* @param {number} lineIndex - 0-based line index in visible area
|
|
23
|
+
* @param {number} scrollOffset - Current scroll offset
|
|
24
|
+
* @param {Object} selection - Selection state
|
|
25
|
+
* @param {string} color - Log level color
|
|
26
|
+
* @param {boolean} isBold - Whether to bold text
|
|
27
|
+
* @returns {React.Element}
|
|
28
|
+
*/
|
|
29
|
+
function renderLogWithSelection(text, lineIndex, scrollOffset, selection, color, isBold) {
|
|
30
|
+
const actualLineIndex = scrollOffset + lineIndex;
|
|
31
|
+
const maxWidth = text.length;
|
|
32
|
+
|
|
33
|
+
if (!selection || selection.source !== 'console') {
|
|
34
|
+
return h(Text, {
|
|
35
|
+
color,
|
|
36
|
+
bold: isBold,
|
|
37
|
+
wrap: 'truncate',
|
|
38
|
+
}, text);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const ranges = getLineSelectionRanges(selection, actualLineIndex, maxWidth);
|
|
42
|
+
|
|
43
|
+
if (ranges.length === 0) {
|
|
44
|
+
return h(Text, {
|
|
45
|
+
color,
|
|
46
|
+
bold: isBold,
|
|
47
|
+
wrap: 'truncate',
|
|
48
|
+
}, text);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Split text into segments based on selection ranges
|
|
52
|
+
const segments = [];
|
|
53
|
+
let lastEnd = 0;
|
|
54
|
+
|
|
55
|
+
for (const range of ranges) {
|
|
56
|
+
// Unselected part before this range
|
|
57
|
+
if (range.start > lastEnd) {
|
|
58
|
+
segments.push(
|
|
59
|
+
h(Text, {
|
|
60
|
+
key: `seg-${lastEnd}-unselected`,
|
|
61
|
+
color,
|
|
62
|
+
bold: isBold,
|
|
63
|
+
}, text.substring(lastEnd, range.start))
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Selected part
|
|
68
|
+
segments.push(
|
|
69
|
+
h(Text,
|
|
70
|
+
{
|
|
71
|
+
key: `seg-${range.start}-selected`,
|
|
72
|
+
inverse: true,
|
|
73
|
+
color: 'cyan',
|
|
74
|
+
bold: isBold,
|
|
75
|
+
},
|
|
76
|
+
text.substring(range.start, range.end)
|
|
77
|
+
)
|
|
78
|
+
);
|
|
79
|
+
|
|
80
|
+
lastEnd = range.end;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Unselected part after last range
|
|
84
|
+
if (lastEnd < text.length) {
|
|
85
|
+
segments.push(
|
|
86
|
+
h(Text, {
|
|
87
|
+
key: `seg-${lastEnd}-tail`,
|
|
88
|
+
color,
|
|
89
|
+
bold: isBold,
|
|
90
|
+
}, text.substring(lastEnd))
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return h(Text, { wrap: 'truncate' }, ...segments);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Console component - Developer console for debug logs
|
|
99
|
+
* Toggle visibility with F12
|
|
100
|
+
* Use ↑↓ to select, Enter to copy
|
|
101
|
+
* Supports mouse drag selection
|
|
102
|
+
*/
|
|
103
|
+
export function Console({
|
|
104
|
+
visible = false,
|
|
105
|
+
logs = [],
|
|
106
|
+
height = 8,
|
|
107
|
+
scrollOffset = 0,
|
|
108
|
+
selectedIndex = -1,
|
|
109
|
+
selection = null,
|
|
110
|
+
}) {
|
|
111
|
+
const { stdout } = useStdout();
|
|
112
|
+
const { theme } = useTheme();
|
|
113
|
+
|
|
114
|
+
if (!visible) return null;
|
|
115
|
+
|
|
116
|
+
// Get terminal width, subtract border (2) and padding (2) and selection indicator (2)
|
|
117
|
+
const terminalWidth = stdout?.columns || 120;
|
|
118
|
+
const contentWidth = terminalWidth - 6;
|
|
119
|
+
|
|
120
|
+
// Calculate visible lines
|
|
121
|
+
const visibleLogs = logs.slice(scrollOffset, scrollOffset + height);
|
|
122
|
+
|
|
123
|
+
// Build hint text based on selection state
|
|
124
|
+
const hasDragSelection = selection && selection.source === 'console' && selection.content;
|
|
125
|
+
const hintText = hasDragSelection
|
|
126
|
+
? `${logs.length} logs selection active ⏎ copy esc clear`
|
|
127
|
+
: selectedIndex >= 0
|
|
128
|
+
? `${logs.length} logs ↑↓ select ⏎ copy esc cancel`
|
|
129
|
+
: `${logs.length} logs ↑↓ select F12 close`;
|
|
130
|
+
|
|
131
|
+
return h(Box, {
|
|
132
|
+
flexDirection: 'column',
|
|
133
|
+
borderStyle: 'round',
|
|
134
|
+
borderColor: hasDragSelection ? theme.colors.accent : theme.colors.border,
|
|
135
|
+
paddingLeft: 1,
|
|
136
|
+
paddingRight: 1,
|
|
137
|
+
height: height + 2,
|
|
138
|
+
},
|
|
139
|
+
// Header
|
|
140
|
+
h(Box, { justifyContent: 'flex-start' },
|
|
141
|
+
h(Text, { color: theme.colors.accent, bold: true }, 'CONSOLE'),
|
|
142
|
+
h(Text, { color: theme.colors.textSecondary }, ` ${hintText}`)
|
|
143
|
+
),
|
|
144
|
+
// Log entries with selection support
|
|
145
|
+
...visibleLogs.map((log, index) => {
|
|
146
|
+
const logIndex = scrollOffset + index;
|
|
147
|
+
const isSelected = logIndex === selectedIndex;
|
|
148
|
+
const displayText = truncateText(log.message, contentWidth);
|
|
149
|
+
|
|
150
|
+
return h(Box, { key: `log-${logIndex}` },
|
|
151
|
+
// Selection indicator (keyboard selection)
|
|
152
|
+
h(Text, { color: theme.colors.accent }, isSelected ? '› ' : ' '),
|
|
153
|
+
// Log text with drag selection support
|
|
154
|
+
renderLogWithSelection(
|
|
155
|
+
displayText,
|
|
156
|
+
index,
|
|
157
|
+
scrollOffset,
|
|
158
|
+
selection,
|
|
159
|
+
getLogColor(log.level, theme),
|
|
160
|
+
log.level === 'error'
|
|
161
|
+
)
|
|
162
|
+
);
|
|
163
|
+
}),
|
|
164
|
+
// Empty lines if not enough logs
|
|
165
|
+
...Array(Math.max(0, height - visibleLogs.length - 1)).fill(null).map((_, i) =>
|
|
166
|
+
h(Box, { key: `empty-${i}` }, h(Text, null, ' '))
|
|
167
|
+
)
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Get color based on log level
|
|
173
|
+
*/
|
|
174
|
+
function getLogColor(level, theme) {
|
|
175
|
+
switch (level) {
|
|
176
|
+
case 'error': return theme.colors.danger;
|
|
177
|
+
case 'warn': return theme.colors.warning;
|
|
178
|
+
case 'info': return theme.colors.accent;
|
|
179
|
+
case 'debug': return theme.colors.textSecondary;
|
|
180
|
+
default: return theme.colors.textPrimary;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Format timestamp for display
|
|
186
|
+
*/
|
|
187
|
+
function formatTime(date) {
|
|
188
|
+
if (!date) return '--:--:--';
|
|
189
|
+
const d = date instanceof Date ? date : new Date(date);
|
|
190
|
+
return d.toTimeString().slice(0, 8);
|
|
191
|
+
}
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import React, { useMemo } from 'react';
|
|
2
|
+
import { Box, Text, useStdout } from 'ink';
|
|
3
|
+
import { getLineSelectionRanges } from '../replState.js';
|
|
4
|
+
|
|
5
|
+
const h = React.createElement;
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Render a line with selection highlighting
|
|
9
|
+
* @param {string} line - The text line
|
|
10
|
+
* @param {number} lineIndex - 0-based line index in visible area
|
|
11
|
+
* @param {number} scrollOffset - Current scroll offset
|
|
12
|
+
* @param {Object} selection - Selection state
|
|
13
|
+
* @returns {React.Element}
|
|
14
|
+
*/
|
|
15
|
+
function renderLineWithSelection(line, lineIndex, scrollOffset, selection) {
|
|
16
|
+
const actualLineIndex = scrollOffset + lineIndex;
|
|
17
|
+
const lineText = line || ' ';
|
|
18
|
+
|
|
19
|
+
if (!selection) {
|
|
20
|
+
return h(Text, { key: `line-${lineIndex}` }, lineText);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const ranges = getLineSelectionRanges(selection, actualLineIndex, lineText.length);
|
|
24
|
+
|
|
25
|
+
if (ranges.length === 0) {
|
|
26
|
+
return h(Text, { key: `line-${lineIndex}` }, lineText);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Split line into segments based on selection ranges
|
|
30
|
+
const segments = [];
|
|
31
|
+
let lastEnd = 0;
|
|
32
|
+
|
|
33
|
+
for (const range of ranges) {
|
|
34
|
+
// Unselected part before this range
|
|
35
|
+
if (range.start > lastEnd) {
|
|
36
|
+
segments.push(
|
|
37
|
+
h(Text, { key: `seg-${lastEnd}-unselected` },
|
|
38
|
+
lineText.substring(lastEnd, range.start)
|
|
39
|
+
)
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Selected part
|
|
44
|
+
segments.push(
|
|
45
|
+
h(Text,
|
|
46
|
+
{
|
|
47
|
+
key: `seg-${range.start}-selected`,
|
|
48
|
+
inverse: true,
|
|
49
|
+
color: 'cyan',
|
|
50
|
+
},
|
|
51
|
+
lineText.substring(range.start, range.end)
|
|
52
|
+
)
|
|
53
|
+
);
|
|
54
|
+
|
|
55
|
+
lastEnd = range.end;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Unselected part after last range
|
|
59
|
+
if (lastEnd < lineText.length) {
|
|
60
|
+
segments.push(
|
|
61
|
+
h(Text, { key: `seg-${lastEnd}-tail` },
|
|
62
|
+
lineText.substring(lastEnd)
|
|
63
|
+
)
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return h(Text, { key: `line-${lineIndex}` }, ...segments);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Detail view for full output
|
|
72
|
+
* @param {string} title - Command title
|
|
73
|
+
* @param {string} content - Full output content
|
|
74
|
+
* @param {string[]} lines - Pre-wrapped content lines (optional)
|
|
75
|
+
* @param {number} scrollOffset - Current scroll position
|
|
76
|
+
* @param {number} availableHeight - Available height passed from parent (excludes status bar)
|
|
77
|
+
* @param {Object} selection - Selection state for highlighting
|
|
78
|
+
* @param {Object} paginatedFile - Paginated file state (if applicable)
|
|
79
|
+
*/
|
|
80
|
+
export function DetailView({ title, content, lines = null, scrollOffset = 0, availableHeight, selection = null, paginatedFile = null }) {
|
|
81
|
+
const { stdout } = useStdout();
|
|
82
|
+
const width = stdout ? stdout.columns || 80 : 80;
|
|
83
|
+
const terminalHeight = stdout ? stdout.rows || 24 : 24;
|
|
84
|
+
|
|
85
|
+
// Use passed height or calculate from terminal (minus status bar)
|
|
86
|
+
const totalHeight = availableHeight || (terminalHeight - 1);
|
|
87
|
+
|
|
88
|
+
// Fixed layout: header (2) + content area (rest)
|
|
89
|
+
const headerHeight = 2;
|
|
90
|
+
|
|
91
|
+
// Use wrapped lines if provided; otherwise fallback to logical lines.
|
|
92
|
+
const allLines = useMemo(() => {
|
|
93
|
+
if (Array.isArray(lines) && lines.length >= 0) return lines;
|
|
94
|
+
return String(content ?? '').split('\n');
|
|
95
|
+
}, [content, lines]);
|
|
96
|
+
|
|
97
|
+
// Fixed content height (total - header)
|
|
98
|
+
const contentHeight = totalHeight - headerHeight;
|
|
99
|
+
|
|
100
|
+
// Check if scrolling is needed
|
|
101
|
+
const needsScrolling = allLines.length > contentHeight;
|
|
102
|
+
|
|
103
|
+
// Get visible lines - exactly contentHeight lines
|
|
104
|
+
const visibleLines = allLines.slice(scrollOffset, scrollOffset + contentHeight);
|
|
105
|
+
|
|
106
|
+
// Build scroll info string (shown in header area)
|
|
107
|
+
let scrollInfo = '';
|
|
108
|
+
if (needsScrolling) {
|
|
109
|
+
const endLine = Math.min(scrollOffset + contentHeight, allLines.length);
|
|
110
|
+
scrollInfo = ` [${scrollOffset + 1}-${endLine}/${allLines.length}]`;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Add loading indicator if paginated file is loading
|
|
114
|
+
let loadingIndicator = '';
|
|
115
|
+
if (paginatedFile && paginatedFile.isLoading) {
|
|
116
|
+
loadingIndicator = ' ⟳ Loading...';
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Build all output lines (exactly contentHeight lines)
|
|
120
|
+
// This ensures stable rendering without relying on Ink's flexbox
|
|
121
|
+
const outputLines = [];
|
|
122
|
+
for (let i = 0; i < contentHeight; i++) {
|
|
123
|
+
if (i < visibleLines.length) {
|
|
124
|
+
outputLines.push(visibleLines[i]);
|
|
125
|
+
} else {
|
|
126
|
+
outputLines.push(''); // Empty padding line
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
return h(Box, { flexDirection: 'column' },
|
|
131
|
+
// Fixed Header with scroll info
|
|
132
|
+
h(Text, null,
|
|
133
|
+
h(Text, { color: 'cyan' }, '← back (esc)'),
|
|
134
|
+
h(Text, { dimColor: true }, ' │ '),
|
|
135
|
+
h(Text, { color: 'green' }, 'Ctrl+Y copy'),
|
|
136
|
+
h(Text, { dimColor: true }, scrollInfo),
|
|
137
|
+
h(Text, { color: 'yellow' }, loadingIndicator),
|
|
138
|
+
h(Text, null, ' '.repeat(Math.max(1, width - 12 - 11 - scrollInfo.length - loadingIndicator.length - title.length - 10))),
|
|
139
|
+
h(Text, { dimColor: true }, 'Output: ', title)
|
|
140
|
+
),
|
|
141
|
+
h(Text, { dimColor: true }, '─'.repeat(width)),
|
|
142
|
+
|
|
143
|
+
// Content area - render exactly contentHeight lines with selection support
|
|
144
|
+
...outputLines.map((line, i) =>
|
|
145
|
+
renderLineWithSelection(line, i, scrollOffset, selection)
|
|
146
|
+
)
|
|
147
|
+
);
|
|
148
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import React, { useMemo } from 'react';
|
|
2
|
+
import { Box, Text, useStdout } from 'ink';
|
|
3
|
+
|
|
4
|
+
const h = React.createElement;
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Truncate text with ellipsis
|
|
8
|
+
*/
|
|
9
|
+
export function truncateText(text, maxLength) {
|
|
10
|
+
if (!text || text.length <= maxLength) return text || '';
|
|
11
|
+
return text.slice(0, maxLength - 1) + '…';
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Single menu item - Claude Code style
|
|
16
|
+
* Layout: "› /command description text..."
|
|
17
|
+
*/
|
|
18
|
+
export function MenuItem({ item, isSelected, labelWidth, descWidth }) {
|
|
19
|
+
const label = item.label.padEnd(labelWidth);
|
|
20
|
+
const desc = truncateText(item.description, descWidth);
|
|
21
|
+
|
|
22
|
+
// Use › indicator for selected item
|
|
23
|
+
const prefix = isSelected ? '› ' : ' ';
|
|
24
|
+
const labelStyle = isSelected
|
|
25
|
+
? { color: 'cyan', bold: true }
|
|
26
|
+
: { color: 'white' };
|
|
27
|
+
|
|
28
|
+
return h(Box, null,
|
|
29
|
+
h(Text, { color: isSelected ? 'cyan' : undefined }, prefix),
|
|
30
|
+
h(Text, labelStyle, label),
|
|
31
|
+
h(Text, null, ' '), // 4 spaces gap between command and description
|
|
32
|
+
h(Text, { dimColor: true }, desc)
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Dropdown menu with scrolling - Claude Code style
|
|
38
|
+
*/
|
|
39
|
+
export function DropdownMenu({ items, selectedIndex, maxVisible = 8 }) {
|
|
40
|
+
const { stdout } = useStdout();
|
|
41
|
+
const termWidth = stdout ? stdout.columns || 80 : 80;
|
|
42
|
+
|
|
43
|
+
// Calculate scroll offset
|
|
44
|
+
const scrollOffset = useMemo(() => {
|
|
45
|
+
if (items.length <= maxVisible) return 0;
|
|
46
|
+
const halfVisible = Math.floor(maxVisible / 2);
|
|
47
|
+
let offset = selectedIndex - halfVisible;
|
|
48
|
+
offset = Math.max(0, offset);
|
|
49
|
+
offset = Math.min(items.length - maxVisible, offset);
|
|
50
|
+
return offset;
|
|
51
|
+
}, [items.length, selectedIndex, maxVisible]);
|
|
52
|
+
|
|
53
|
+
// Calculate column widths based on longest label
|
|
54
|
+
const labelWidth = useMemo(() => {
|
|
55
|
+
const maxLabel = Math.max(...items.map(i => i.label.length));
|
|
56
|
+
return Math.min(maxLabel + 2, 40); // Cap at 40 chars
|
|
57
|
+
}, [items]);
|
|
58
|
+
|
|
59
|
+
// Description takes remaining space (minus prefix and gaps: 2 + 4 + some buffer)
|
|
60
|
+
const descWidth = Math.max(20, termWidth - labelWidth - 10);
|
|
61
|
+
|
|
62
|
+
const visibleItems = items.slice(scrollOffset, scrollOffset + maxVisible);
|
|
63
|
+
const hasMoreAbove = scrollOffset > 0;
|
|
64
|
+
const hasMoreBelow = scrollOffset + maxVisible < items.length;
|
|
65
|
+
|
|
66
|
+
return h(Box, { flexDirection: 'column' },
|
|
67
|
+
// Scroll up indicator
|
|
68
|
+
hasMoreAbove
|
|
69
|
+
? h(Text, { dimColor: true }, ' ↑ ', scrollOffset, ' more')
|
|
70
|
+
: null,
|
|
71
|
+
// Menu items
|
|
72
|
+
...visibleItems.map((item, index) =>
|
|
73
|
+
h(MenuItem, {
|
|
74
|
+
key: item.value || `item-${index}`,
|
|
75
|
+
item: item,
|
|
76
|
+
isSelected: (index + scrollOffset) === selectedIndex,
|
|
77
|
+
labelWidth: labelWidth,
|
|
78
|
+
descWidth: descWidth,
|
|
79
|
+
})
|
|
80
|
+
),
|
|
81
|
+
// Scroll down indicator
|
|
82
|
+
hasMoreBelow
|
|
83
|
+
? h(Text, { dimColor: true }, ' ↓ ', items.length - scrollOffset - maxVisible, ' more')
|
|
84
|
+
: null
|
|
85
|
+
);
|
|
86
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import React, { useMemo } from 'react';
|
|
2
|
+
import { Box, Text, useStdout } from 'ink';
|
|
3
|
+
import { useTheme } from '../contexts/ThemeContext.js';
|
|
4
|
+
import { useLayout } from '../contexts/LayoutContext.js';
|
|
5
|
+
import { getPlaceholderText } from '../utils/paramHint.js';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Create ANSI background color escape sequence from hex color
|
|
9
|
+
* This avoids issues with the bgHex method being obfuscated incorrectly
|
|
10
|
+
*/
|
|
11
|
+
function hexToAnsiBg(hexColor) {
|
|
12
|
+
// Remove # if present
|
|
13
|
+
const hex = hexColor.replace('#', '');
|
|
14
|
+
// Parse RGB values
|
|
15
|
+
const r = parseInt(hex.substring(0, 2), 16);
|
|
16
|
+
const g = parseInt(hex.substring(2, 4), 16);
|
|
17
|
+
const b = parseInt(hex.substring(4, 6), 16);
|
|
18
|
+
// Return ANSI escape sequence for background color
|
|
19
|
+
return `\x1b[48;2;${r};${g};${b}m`;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const h = React.createElement;
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Calculate the display width of a string (handling wide characters like CJK)
|
|
26
|
+
*/
|
|
27
|
+
function stringWidth(str) {
|
|
28
|
+
let width = 0;
|
|
29
|
+
for (const char of str) {
|
|
30
|
+
const code = char.codePointAt(0);
|
|
31
|
+
// CJK characters and other wide characters
|
|
32
|
+
if (
|
|
33
|
+
(code >= 0x1100 && code <= 0x115f) || // Hangul Jamo
|
|
34
|
+
(code >= 0x2e80 && code <= 0xa4cf) || // CJK
|
|
35
|
+
(code >= 0xac00 && code <= 0xd7a3) || // Hangul Syllables
|
|
36
|
+
(code >= 0xf900 && code <= 0xfaff) || // CJK Compatibility Ideographs
|
|
37
|
+
(code >= 0xfe10 && code <= 0xfe1f) || // Vertical forms
|
|
38
|
+
(code >= 0xfe30 && code <= 0xfe6f) || // CJK Compatibility Forms
|
|
39
|
+
(code >= 0xff00 && code <= 0xff60) || // Fullwidth Forms
|
|
40
|
+
(code >= 0xffe0 && code <= 0xffe6) || // Fullwidth Forms
|
|
41
|
+
(code >= 0x20000 && code <= 0x2fffd) || // CJK Extension B
|
|
42
|
+
(code >= 0x30000 && code <= 0x3fffd) // CJK Extension C
|
|
43
|
+
) {
|
|
44
|
+
width += 2;
|
|
45
|
+
} else {
|
|
46
|
+
width += 1;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return width;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Input area with separator lines (like Claude Code style)
|
|
54
|
+
*
|
|
55
|
+
* Layout:
|
|
56
|
+
* ────────────────────────────
|
|
57
|
+
* ❯ user input here█ @<param>
|
|
58
|
+
* ────────────────────────────
|
|
59
|
+
*
|
|
60
|
+
* Shows parameter hints (placeholder text) for /upload and /download commands.
|
|
61
|
+
*/
|
|
62
|
+
export function InputArea({
|
|
63
|
+
prompt = '❯ ',
|
|
64
|
+
buffer = '',
|
|
65
|
+
cursorPosition = null,
|
|
66
|
+
showCursor = true,
|
|
67
|
+
menuVisible = false,
|
|
68
|
+
menuType = null
|
|
69
|
+
}) {
|
|
70
|
+
const { stdout } = useStdout();
|
|
71
|
+
const { theme } = useTheme();
|
|
72
|
+
const { width: layoutWidth } = useLayout();
|
|
73
|
+
const fallbackWidth = stdout ? stdout.columns || 80 : 80;
|
|
74
|
+
const width = Math.max(1, layoutWidth || fallbackWidth);
|
|
75
|
+
|
|
76
|
+
// Memoize separator to avoid recalculating on every render
|
|
77
|
+
// Use bold line character '━' for thicker appearance
|
|
78
|
+
const separator = useMemo(() => '━'.repeat(width), [width]);
|
|
79
|
+
|
|
80
|
+
// Blue color for input area separator (like Claude Code style)
|
|
81
|
+
const separatorColor = '#4a90d9';
|
|
82
|
+
|
|
83
|
+
// Cursor position defaults to end of buffer if not specified
|
|
84
|
+
const actualCursorPos = cursorPosition !== null && cursorPosition !== undefined ? cursorPosition : buffer.length;
|
|
85
|
+
|
|
86
|
+
// Split buffer at cursor position
|
|
87
|
+
const beforeCursor = buffer.slice(0, actualCursorPos);
|
|
88
|
+
const atCursor = buffer.slice(actualCursorPos, actualCursorPos + 1); // Character at cursor
|
|
89
|
+
const afterCursor = buffer.slice(actualCursorPos + 1);
|
|
90
|
+
|
|
91
|
+
// Calculate placeholder text for parameter hints
|
|
92
|
+
const placeholderText = useMemo(() => {
|
|
93
|
+
// Don't show if menu is visible and it's a slash menu
|
|
94
|
+
if (menuVisible && menuType === 'slash') {
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
97
|
+
return getPlaceholderText(buffer, actualCursorPos);
|
|
98
|
+
}, [buffer, actualCursorPos, menuVisible, menuType]);
|
|
99
|
+
|
|
100
|
+
return h(Box, { flexDirection: 'column', width, marginTop: 0, flexShrink: 0 },
|
|
101
|
+
// Top separator (bold and dark)
|
|
102
|
+
h(Text, { color: separatorColor, wrap: 'truncate' }, separator),
|
|
103
|
+
// Input line with padding
|
|
104
|
+
h(Box, { paddingLeft: 1, paddingTop: 1, paddingBottom: 1, width: Math.max(1, width - 1) },
|
|
105
|
+
h(Text, { color: theme.colors.prompt }, prompt),
|
|
106
|
+
// Render buffer before cursor
|
|
107
|
+
h(Text, null, beforeCursor),
|
|
108
|
+
// Render cursor block: either covering a character or showing as space at end
|
|
109
|
+
showCursor ? h(Text, null,
|
|
110
|
+
hexToAnsiBg(theme.colors.cursor) + (atCursor || ' ') + '\x1b[0m'
|
|
111
|
+
) : (
|
|
112
|
+
// When cursor is hidden, still show the character at cursor position
|
|
113
|
+
atCursor ? h(Text, null, atCursor) : null
|
|
114
|
+
),
|
|
115
|
+
// Render buffer after cursor
|
|
116
|
+
h(Text, null, afterCursor),
|
|
117
|
+
// Render placeholder (FR-005: secondary text color)
|
|
118
|
+
placeholderText ? h(Text, { color: theme.colors.textSecondary },
|
|
119
|
+
' ' + placeholderText
|
|
120
|
+
) : null
|
|
121
|
+
),
|
|
122
|
+
// Bottom separator (bold and dark)
|
|
123
|
+
h(Text, { color: separatorColor, wrap: 'truncate' }, separator)
|
|
124
|
+
);
|
|
125
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const React = require('react');
|
|
4
|
+
const { Box, Text } = require('ink');
|
|
5
|
+
const h = React.createElement;
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Input line with prompt, buffer, and cursor
|
|
9
|
+
*/
|
|
10
|
+
function InputLine({ prompt = '', buffer = '', showCursor = true }) {
|
|
11
|
+
return h(Box, null,
|
|
12
|
+
h(Text, { color: 'green' }, prompt),
|
|
13
|
+
h(Text, null, buffer),
|
|
14
|
+
showCursor ? h(Text, { inverse: true }, ' ') : null
|
|
15
|
+
);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
module.exports = { InputLine };
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { Box, useStdout } from 'ink';
|
|
3
|
+
import { useLayout } from '../contexts/LayoutContext.js';
|
|
4
|
+
|
|
5
|
+
const h = React.createElement;
|
|
6
|
+
|
|
7
|
+
export function OutputArea({ children }) {
|
|
8
|
+
const { stdout } = useStdout();
|
|
9
|
+
const { width: layoutWidth } = useLayout();
|
|
10
|
+
const fallbackWidth = stdout?.columns || 80;
|
|
11
|
+
const width = Math.max(1, layoutWidth || fallbackWidth);
|
|
12
|
+
|
|
13
|
+
return h(Box, {
|
|
14
|
+
flexDirection: 'column',
|
|
15
|
+
width,
|
|
16
|
+
flexGrow: 1,
|
|
17
|
+
flexShrink: 1,
|
|
18
|
+
minHeight: 1,
|
|
19
|
+
rowGap: 0,
|
|
20
|
+
paddingBottom: 0, // Remove padding, handled by RESERVED_FIXED_ROWS
|
|
21
|
+
}, children);
|
|
22
|
+
}
|