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,1507 @@
|
|
|
1
|
+
import React, { useState, useEffect, useCallback, useRef, useMemo } from 'react';
|
|
2
|
+
import { Box, Text, useInput, useApp, useStdout } from 'ink';
|
|
3
|
+
|
|
4
|
+
const h = React.createElement;
|
|
5
|
+
|
|
6
|
+
// Components
|
|
7
|
+
import { OutputItem } from './components/OutputItem.js';
|
|
8
|
+
import { InputArea } from './components/InputArea.js';
|
|
9
|
+
import { DropdownMenu } from './components/DropdownMenu.js';
|
|
10
|
+
import { ExecutionStatus } from './components/Spinner.js';
|
|
11
|
+
import { StatusBar } from './components/StatusBar.js';
|
|
12
|
+
import { DetailView } from './components/DetailView.js';
|
|
13
|
+
import { Console } from './components/Console.js';
|
|
14
|
+
import { ShellLayout } from './components/ShellLayout.js';
|
|
15
|
+
import { OutputArea } from './components/OutputArea.js';
|
|
16
|
+
import { WelcomeBanner } from './components/WelcomeBanner.js';
|
|
17
|
+
import { useTheme } from './contexts/ThemeContext.js';
|
|
18
|
+
|
|
19
|
+
// State management
|
|
20
|
+
import {
|
|
21
|
+
createInitialState,
|
|
22
|
+
addOutput,
|
|
23
|
+
showMenu,
|
|
24
|
+
hideMenu,
|
|
25
|
+
selectNext,
|
|
26
|
+
selectPrev,
|
|
27
|
+
startExecuting,
|
|
28
|
+
finishExecuting,
|
|
29
|
+
enterDetailView,
|
|
30
|
+
exitDetailView,
|
|
31
|
+
navigateHistoryUp,
|
|
32
|
+
navigateHistoryDown,
|
|
33
|
+
setBuffer,
|
|
34
|
+
setBufferWithSlashMenu,
|
|
35
|
+
moveCursorLeft,
|
|
36
|
+
moveCursorRight,
|
|
37
|
+
moveCursorToStart,
|
|
38
|
+
moveCursorToEnd,
|
|
39
|
+
addToHistory,
|
|
40
|
+
updateResources,
|
|
41
|
+
updatePrompt,
|
|
42
|
+
scrollDetailView,
|
|
43
|
+
toggleConsole,
|
|
44
|
+
addConsoleLog,
|
|
45
|
+
scrollConsole,
|
|
46
|
+
scrollOutputs,
|
|
47
|
+
scrollOutputsByLines,
|
|
48
|
+
setPaginatedFile,
|
|
49
|
+
clearPaginatedFile,
|
|
50
|
+
setPaginatedFileLoading,
|
|
51
|
+
extendPaginatedFileRange,
|
|
52
|
+
setExitPending,
|
|
53
|
+
selectConsoleLog,
|
|
54
|
+
getSelectedConsoleLog,
|
|
55
|
+
clearConsoleSelection,
|
|
56
|
+
startSelection,
|
|
57
|
+
updateSelection,
|
|
58
|
+
endSelection,
|
|
59
|
+
clearSelection,
|
|
60
|
+
getSelectedText,
|
|
61
|
+
shouldEnableMouseMode,
|
|
62
|
+
toggleMouseCapture,
|
|
63
|
+
} from './replState.js';
|
|
64
|
+
|
|
65
|
+
// Hooks
|
|
66
|
+
import { useResources } from './hooks/useResources.js';
|
|
67
|
+
import { useFunctionKeys } from './hooks/useFunctionKeys.js';
|
|
68
|
+
import { useMouse } from './hooks/useMouse.js';
|
|
69
|
+
|
|
70
|
+
// Shortcuts
|
|
71
|
+
import { keybindingManager } from './shortcuts/index.js';
|
|
72
|
+
|
|
73
|
+
// Utils
|
|
74
|
+
import { getPathCompletions } from './utils/pathCompletion.js';
|
|
75
|
+
import {
|
|
76
|
+
parseAtContext,
|
|
77
|
+
getLocalCompletions,
|
|
78
|
+
getRemoteCompletions,
|
|
79
|
+
replaceAtPath,
|
|
80
|
+
} from './utils/atCompletion.js';
|
|
81
|
+
import { parseExecutionError, friendlyErrorMessage } from './utils/parseError.js';
|
|
82
|
+
import { formatCommandOutput } from './utils/responseFormatter.js';
|
|
83
|
+
import {
|
|
84
|
+
calculateOutputPositions,
|
|
85
|
+
findOutputAtPosition,
|
|
86
|
+
isInsideContentArea,
|
|
87
|
+
extractSelectionFromOutputs,
|
|
88
|
+
} from './utils/replSelection.js';
|
|
89
|
+
import { pickVisibleOutputsByHeight } from './utils/outputViewport.js';
|
|
90
|
+
import {
|
|
91
|
+
extractCatFilePath,
|
|
92
|
+
shouldEnablePagination,
|
|
93
|
+
calculatePreviousPageRange,
|
|
94
|
+
calculateNextPageRange,
|
|
95
|
+
shouldLoadPreviousPage,
|
|
96
|
+
shouldLoadNextPage,
|
|
97
|
+
} from './utils/paginatedFileLoading.js';
|
|
98
|
+
import {
|
|
99
|
+
isBuiltinCommand,
|
|
100
|
+
executeBuiltinCommand,
|
|
101
|
+
checkInteractiveCommand,
|
|
102
|
+
} from './builtinCommands.js';
|
|
103
|
+
import { initConsoleLogger, clearConsoleLogger, consoleLogger } from './utils/consoleLogger.js';
|
|
104
|
+
import { copyToClipboard } from './utils/clipboard.js';
|
|
105
|
+
import { httpLogger } from '../../../utils/http-logger.js';
|
|
106
|
+
/**
|
|
107
|
+
* Main InkREPL component
|
|
108
|
+
*/
|
|
109
|
+
export function InkREPL({
|
|
110
|
+
sandboxId,
|
|
111
|
+
hostname,
|
|
112
|
+
hostIp,
|
|
113
|
+
user = 'root',
|
|
114
|
+
client,
|
|
115
|
+
sessionManager,
|
|
116
|
+
historyManager,
|
|
117
|
+
initialPrompt = '$ ',
|
|
118
|
+
version,
|
|
119
|
+
triggers = [],
|
|
120
|
+
onExit,
|
|
121
|
+
}) {
|
|
122
|
+
const { exit } = useApp();
|
|
123
|
+
const { stdout } = useStdout();
|
|
124
|
+
|
|
125
|
+
// Load history outputs on mount
|
|
126
|
+
const [initialOutputs, setInitialOutputs] = useState(null);
|
|
127
|
+
const [initialCommandHistory, setInitialCommandHistory] = useState(null);
|
|
128
|
+
const hasLoadedHistory = useRef(false);
|
|
129
|
+
|
|
130
|
+
useEffect(() => {
|
|
131
|
+
if (
|
|
132
|
+
!hasLoadedHistory.current &&
|
|
133
|
+
historyManager &&
|
|
134
|
+
typeof historyManager.loadOutputs === 'function'
|
|
135
|
+
) {
|
|
136
|
+
hasLoadedHistory.current = true;
|
|
137
|
+
const loadOutputs = Promise.resolve(historyManager.loadOutputs()).catch(() => []);
|
|
138
|
+
const loadHistory = typeof historyManager.getHistory === 'function'
|
|
139
|
+
? Promise.resolve(historyManager.getHistory()).catch(() => [])
|
|
140
|
+
: Promise.resolve([]);
|
|
141
|
+
|
|
142
|
+
Promise.all([loadOutputs, loadHistory])
|
|
143
|
+
.then(([outputs, history]) => {
|
|
144
|
+
setInitialOutputs(outputs);
|
|
145
|
+
setInitialCommandHistory(history);
|
|
146
|
+
})
|
|
147
|
+
.catch(() => {
|
|
148
|
+
// Ignore history loading errors
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
}, [historyManager]);
|
|
152
|
+
|
|
153
|
+
const [state, setState] = useState(() =>
|
|
154
|
+
createInitialState({ shellPrompt: initialPrompt })
|
|
155
|
+
);
|
|
156
|
+
|
|
157
|
+
// Apply loaded outputs once available
|
|
158
|
+
const hasAppliedHistory = useRef(false);
|
|
159
|
+
useEffect(() => {
|
|
160
|
+
if (hasAppliedHistory.current) return;
|
|
161
|
+
if ((initialOutputs && initialOutputs.length > 0) || (initialCommandHistory && initialCommandHistory.length > 0)) {
|
|
162
|
+
hasAppliedHistory.current = true;
|
|
163
|
+
setState(s => {
|
|
164
|
+
// Update welcome message to indicate history loaded
|
|
165
|
+
const welcomeMsg = s.outputs[0];
|
|
166
|
+
const restoredOutputs = Array.isArray(initialOutputs) ? initialOutputs : [];
|
|
167
|
+
const restoredCommands = Array.isArray(initialCommandHistory) ? initialCommandHistory : [];
|
|
168
|
+
const updatedWelcome = {
|
|
169
|
+
...welcomeMsg,
|
|
170
|
+
output: `Connected! Type / for commands, double Ctrl+C to exit.\n✓ Restored ${restoredOutputs.length} output(s), ${restoredCommands.length} command(s) from history.`,
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
return {
|
|
174
|
+
...s,
|
|
175
|
+
outputs: [
|
|
176
|
+
updatedWelcome,
|
|
177
|
+
...restoredOutputs,
|
|
178
|
+
],
|
|
179
|
+
commandHistory: restoredCommands,
|
|
180
|
+
historyIndex: -1,
|
|
181
|
+
savedBuffer: '',
|
|
182
|
+
};
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
}, [initialOutputs, initialCommandHistory]);
|
|
186
|
+
|
|
187
|
+
// Get terminal dimensions for full-screen layout
|
|
188
|
+
const terminalHeight = stdout?.rows || 24;
|
|
189
|
+
const terminalWidth = stdout?.columns || 80;
|
|
190
|
+
|
|
191
|
+
// Calculate visible outputs by *height*, not by item count.
|
|
192
|
+
// This prevents layout breakage when outputs contain very long single-line logs
|
|
193
|
+
// that wrap to many terminal rows.
|
|
194
|
+
// Fixed bottom area needs:
|
|
195
|
+
// Empty lines before separator: 2
|
|
196
|
+
// Separator line: 1
|
|
197
|
+
// ExecutionStatus: 1
|
|
198
|
+
// InputArea: separator(1) + paddingTop(1) + input(1) + paddingBottom(1) + separator(1) = 5
|
|
199
|
+
// StatusBar: 1
|
|
200
|
+
// Total: 10 rows
|
|
201
|
+
const RESERVED_FIXED_ROWS = 10;
|
|
202
|
+
const verticalPadding = terminalHeight <= 16 ? 0 : 2; // matches ShellLayout
|
|
203
|
+
const availableOutputRows = Math.max(1, terminalHeight - RESERVED_FIXED_ROWS - verticalPadding);
|
|
204
|
+
|
|
205
|
+
// DEBUG: 输出布局信息到 stderr(不影响 UI)
|
|
206
|
+
if (process.env.DEBUG_LAYOUT) {
|
|
207
|
+
console.error(`[LAYOUT] terminalHeight=${terminalHeight}, reserved=${RESERVED_FIXED_ROWS}, padding=${verticalPadding}, available=${availableOutputRows}`);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const visibleOutputs = useMemo(() => {
|
|
211
|
+
// Item-based scrolling (simple and reliable)
|
|
212
|
+
const offset = typeof state.outputScrollOffset === 'number' ? state.outputScrollOffset : 0;
|
|
213
|
+
const end = Math.max(0, state.outputs.length - offset);
|
|
214
|
+
const slice = state.outputs.slice(0, end);
|
|
215
|
+
|
|
216
|
+
const picked = pickVisibleOutputsByHeight(slice, {
|
|
217
|
+
contentWidth: Math.max(20, terminalWidth),
|
|
218
|
+
maxLines: 20,
|
|
219
|
+
maxHeight: availableOutputRows,
|
|
220
|
+
rowGap: 0,
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
return picked;
|
|
224
|
+
}, [state.outputs, state.outputScrollOffset, terminalWidth, availableOutputRows]);
|
|
225
|
+
|
|
226
|
+
// Keep existing item-based scrolling logic by approximating how many items fit.
|
|
227
|
+
// This is used only to cap the scroll offset; rendering itself is height-based.
|
|
228
|
+
const maxVisibleOutputs = useMemo(() => {
|
|
229
|
+
return Math.max(1, visibleOutputs.length || 1);
|
|
230
|
+
}, [visibleOutputs.length]);
|
|
231
|
+
|
|
232
|
+
// Ref to track drag selection state
|
|
233
|
+
const dragRef = useRef({
|
|
234
|
+
isDragging: false,
|
|
235
|
+
source: null, // 'detail' | 'console' | 'repl' | null
|
|
236
|
+
outputPositions: null, // For REPL output selection
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
// Calculate output positions for REPL selection (based on visible outputs)
|
|
240
|
+
const outputPositions = useMemo(() => {
|
|
241
|
+
if (state.viewMode === 'repl') {
|
|
242
|
+
// Top padding depends on terminal height: 0 for small terminals (<= 16 rows), 2 otherwise
|
|
243
|
+
const topPadding = terminalHeight <= 16 ? 0 : 2;
|
|
244
|
+
return calculateOutputPositions(visibleOutputs, topPadding, {
|
|
245
|
+
contentWidth: Math.max(20, terminalWidth),
|
|
246
|
+
maxLines: 20,
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
return [];
|
|
250
|
+
}, [state.viewMode, visibleOutputs, terminalHeight, terminalWidth]);
|
|
251
|
+
|
|
252
|
+
// Calculate text lines for current view (for selection content extraction)
|
|
253
|
+
const getViewLines = useCallback(() => {
|
|
254
|
+
if (state.viewMode === 'detail' && state.detailContent) {
|
|
255
|
+
return state.detailContent.content.split('\n');
|
|
256
|
+
} else if (state.consoleVisible) {
|
|
257
|
+
return state.consoleLogs.map(log => log.message);
|
|
258
|
+
}
|
|
259
|
+
return [];
|
|
260
|
+
}, [state.viewMode, state.detailContent, state.consoleVisible, state.consoleLogs]);
|
|
261
|
+
|
|
262
|
+
// Mouse event handlers - scroll and drag selection
|
|
263
|
+
const mouseHandlers = useCallback(() => ({
|
|
264
|
+
onScroll: (direction, x, y) => {
|
|
265
|
+
// Don't scroll if actively dragging for selection
|
|
266
|
+
if (dragRef.current.isDragging) return;
|
|
267
|
+
|
|
268
|
+
setState(s => {
|
|
269
|
+
if (s.viewMode === 'detail') {
|
|
270
|
+
const delta = direction === 'up' ? -3 : 3;
|
|
271
|
+
const newState = scrollDetailView(s, delta, terminalHeight - 1);
|
|
272
|
+
|
|
273
|
+
// Check if we need to load more content (pagination)
|
|
274
|
+
if (s.paginatedFile && !s.paginatedFile.isLoading) {
|
|
275
|
+
const detailScrollOffset = newState.detailContent?.scrollOffset || 0;
|
|
276
|
+
const contentLines = (newState.detailContent?.content || '').split('\n').length;
|
|
277
|
+
const viewportHeight = terminalHeight - 1;
|
|
278
|
+
const threshold = 5;
|
|
279
|
+
const { loadedStartLine, loadedEndLine, totalLines } = s.paginatedFile;
|
|
280
|
+
|
|
281
|
+
// Check if should load previous page (scrolling up near top)
|
|
282
|
+
if (shouldLoadPreviousPage(detailScrollOffset, threshold, loadedStartLine)) {
|
|
283
|
+
loadPreviousPageAsync(s.paginatedFile);
|
|
284
|
+
}
|
|
285
|
+
// Check if should load next page (scrolling down near bottom)
|
|
286
|
+
else if (shouldLoadNextPage(detailScrollOffset, contentLines, viewportHeight, threshold, loadedEndLine, totalLines)) {
|
|
287
|
+
loadNextPageAsync(s.paginatedFile);
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
return newState;
|
|
292
|
+
} else if (s.consoleVisible) {
|
|
293
|
+
const delta = direction === 'up' ? -3 : 3;
|
|
294
|
+
return scrollConsole(s, delta);
|
|
295
|
+
}
|
|
296
|
+
// REPL output history scrolling
|
|
297
|
+
if (s.viewMode === 'repl') {
|
|
298
|
+
const delta = direction === 'up' ? 1 : -1;
|
|
299
|
+
return scrollOutputs(s, delta, maxVisibleOutputs);
|
|
300
|
+
}
|
|
301
|
+
return s;
|
|
302
|
+
});
|
|
303
|
+
},
|
|
304
|
+
|
|
305
|
+
onMouseDown: (button, x, y) => {
|
|
306
|
+
// Only handle left click (button 0)
|
|
307
|
+
if (button !== 0) return;
|
|
308
|
+
|
|
309
|
+
// Determine selection source based on y position and view mode
|
|
310
|
+
let source = null;
|
|
311
|
+
let adjustedY = y;
|
|
312
|
+
|
|
313
|
+
if (state.viewMode === 'detail') {
|
|
314
|
+
// DetailView: header takes 2 rows (0 and 1), content starts at row 3
|
|
315
|
+
if (y >= 3) {
|
|
316
|
+
source = 'detail';
|
|
317
|
+
// Calculate absolute line number (considering scroll offset)
|
|
318
|
+
const contentRow = y - 2; // Row within visible area (1-based)
|
|
319
|
+
const scrollOffset = state.detailContent?.scrollOffset || 0;
|
|
320
|
+
adjustedY = scrollOffset + contentRow; // Absolute line number in content
|
|
321
|
+
}
|
|
322
|
+
} else if (state.consoleVisible) {
|
|
323
|
+
// Console: positioned above status bar, height 8 (+2 for borders)
|
|
324
|
+
// Status bar is at bottom, so console ends at terminalHeight - 2
|
|
325
|
+
const consoleTop = terminalHeight - 10; // 8 height + 2 borders
|
|
326
|
+
if (y >= consoleTop && y < terminalHeight - 1) {
|
|
327
|
+
source = 'console';
|
|
328
|
+
// Calculate absolute line number (considering scroll offset)
|
|
329
|
+
const contentRow = y - consoleTop; // Row within visible area (0-based)
|
|
330
|
+
adjustedY = state.consoleScrollOffset + contentRow + 1; // Absolute line number (1-based)
|
|
331
|
+
}
|
|
332
|
+
} else if (state.viewMode === 'repl') {
|
|
333
|
+
// REPL output selection - check if inside content area
|
|
334
|
+
const contentWidth = terminalWidth;
|
|
335
|
+
if (isInsideContentArea(x, y, contentWidth, outputPositions)) {
|
|
336
|
+
source = 'repl';
|
|
337
|
+
adjustedY = y; // Use actual screen Y
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
if (source) {
|
|
342
|
+
dragRef.current = { isDragging: true, source, outputPositions };
|
|
343
|
+
setState(s => startSelection(s, x, adjustedY, source));
|
|
344
|
+
}
|
|
345
|
+
},
|
|
346
|
+
|
|
347
|
+
onMouseMove: (x, y, isDragging) => {
|
|
348
|
+
if (!isDragging || !dragRef.current.isDragging) return;
|
|
349
|
+
|
|
350
|
+
const source = dragRef.current.source;
|
|
351
|
+
let adjustedY = y;
|
|
352
|
+
|
|
353
|
+
// Adjust y coordinate based on source (with scroll offset)
|
|
354
|
+
if (source === 'detail') {
|
|
355
|
+
const contentRow = y - 2; // Row within visible area (1-based)
|
|
356
|
+
const scrollOffset = state.detailContent?.scrollOffset || 0;
|
|
357
|
+
adjustedY = scrollOffset + contentRow; // Absolute line number
|
|
358
|
+
} else if (source === 'console') {
|
|
359
|
+
const consoleTop = terminalHeight - 10;
|
|
360
|
+
const contentRow = y - consoleTop; // Row within visible area (0-based)
|
|
361
|
+
adjustedY = state.consoleScrollOffset + contentRow + 1; // Absolute line number (1-based)
|
|
362
|
+
} else if (source === 'repl') {
|
|
363
|
+
adjustedY = y; // Use actual screen Y for REPL
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
// Ensure y is at least 1 (1-based)
|
|
367
|
+
adjustedY = Math.max(1, adjustedY);
|
|
368
|
+
|
|
369
|
+
const lines = source === 'repl' ? [] : getViewLines();
|
|
370
|
+
setState(s => updateSelection(s, x, adjustedY, lines));
|
|
371
|
+
},
|
|
372
|
+
|
|
373
|
+
onMouseUp: (button, x, y) => {
|
|
374
|
+
if (!dragRef.current.isDragging) return;
|
|
375
|
+
|
|
376
|
+
const source = dragRef.current.source;
|
|
377
|
+
let adjustedY = y;
|
|
378
|
+
|
|
379
|
+
// Adjust y coordinate based on source (with scroll offset)
|
|
380
|
+
if (source === 'detail') {
|
|
381
|
+
const contentRow = y - 2; // Row within visible area (1-based)
|
|
382
|
+
const scrollOffset = state.detailContent?.scrollOffset || 0;
|
|
383
|
+
adjustedY = scrollOffset + contentRow; // Absolute line number
|
|
384
|
+
} else if (source === 'console') {
|
|
385
|
+
const consoleTop = terminalHeight - 10;
|
|
386
|
+
const contentRow = y - consoleTop; // Row within visible area (0-based)
|
|
387
|
+
adjustedY = state.consoleScrollOffset + contentRow + 1; // Absolute line number (1-based)
|
|
388
|
+
} else if (source === 'repl') {
|
|
389
|
+
adjustedY = y; // Use actual screen Y for REPL
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
adjustedY = Math.max(1, adjustedY);
|
|
393
|
+
|
|
394
|
+
const lines = source === 'repl' ? [] : getViewLines();
|
|
395
|
+
setState(s => {
|
|
396
|
+
const newState = endSelection(s, x, adjustedY, lines);
|
|
397
|
+
|
|
398
|
+
// For REPL selection, extract text using outputPositions
|
|
399
|
+
if (source === 'repl' && newState.selection) {
|
|
400
|
+
const positions = dragRef.current.outputPositions;
|
|
401
|
+
// Ensure positions is a valid array before extracting
|
|
402
|
+
if (positions && Array.isArray(positions)) {
|
|
403
|
+
const selectedText = extractSelectionFromOutputs(
|
|
404
|
+
newState.selection.startX,
|
|
405
|
+
newState.selection.startY,
|
|
406
|
+
newState.selection.endX,
|
|
407
|
+
newState.selection.endY,
|
|
408
|
+
positions,
|
|
409
|
+
state.shellPrompt
|
|
410
|
+
);
|
|
411
|
+
return {
|
|
412
|
+
...newState,
|
|
413
|
+
selection: {
|
|
414
|
+
...newState.selection,
|
|
415
|
+
content: selectedText,
|
|
416
|
+
},
|
|
417
|
+
};
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
return newState;
|
|
422
|
+
});
|
|
423
|
+
|
|
424
|
+
dragRef.current = { isDragging: false, source: null, outputPositions: null };
|
|
425
|
+
},
|
|
426
|
+
}), [terminalHeight, terminalWidth, state.viewMode, state.consoleVisible, state.detailContent, state.consoleScrollOffset, state.shellPrompt, getViewLines, outputPositions]);
|
|
427
|
+
|
|
428
|
+
const mouseModeEnabled = shouldEnableMouseMode(state);
|
|
429
|
+
useMouse(mouseHandlers(), { isActive: mouseModeEnabled });
|
|
430
|
+
|
|
431
|
+
const lastMouseCaptureEnabled = useRef(state.mouseCaptureEnabled);
|
|
432
|
+
useEffect(() => {
|
|
433
|
+
if (lastMouseCaptureEnabled.current === state.mouseCaptureEnabled) return;
|
|
434
|
+
lastMouseCaptureEnabled.current = state.mouseCaptureEnabled;
|
|
435
|
+
// Mouse mode is now always enabled
|
|
436
|
+
}, [state.mouseCaptureEnabled]);
|
|
437
|
+
|
|
438
|
+
// Session stats - persisted across renders
|
|
439
|
+
const statsRef = useRef({
|
|
440
|
+
startTime: Date.now(),
|
|
441
|
+
shellCommands: 0,
|
|
442
|
+
builtinCommands: 0,
|
|
443
|
+
});
|
|
444
|
+
|
|
445
|
+
// Exit confirmation state - requires two Ctrl+C presses within 2 seconds
|
|
446
|
+
const exitConfirmRef = useRef({
|
|
447
|
+
pending: false,
|
|
448
|
+
timer: null,
|
|
449
|
+
});
|
|
450
|
+
|
|
451
|
+
// Abort controller for cancelling execution
|
|
452
|
+
const abortControllerRef = useRef(null);
|
|
453
|
+
|
|
454
|
+
// Save outputs to history before exit
|
|
455
|
+
const saveOutputsBeforeExit = useCallback(() => {
|
|
456
|
+
if (historyManager && typeof historyManager.saveOutputs === 'function') {
|
|
457
|
+
try {
|
|
458
|
+
historyManager.saveOutputs(state.outputs);
|
|
459
|
+
} catch {
|
|
460
|
+
// Ignore history saving errors
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
}, [historyManager, state.outputs]);
|
|
464
|
+
|
|
465
|
+
// Initialize console logger with setState
|
|
466
|
+
useEffect(() => {
|
|
467
|
+
initConsoleLogger(setState);
|
|
468
|
+
|
|
469
|
+
// Enable HTTP logging
|
|
470
|
+
httpLogger.enable();
|
|
471
|
+
|
|
472
|
+
// Default UX note: Mouse mode is always enabled for wheel scrolling.
|
|
473
|
+
// Many terminals support Shift+drag to bypass mouse reporting and use native selection.
|
|
474
|
+
const scrollUpKey = keybindingManager.getActionKeyLabel('consoleScrollUp');
|
|
475
|
+
const scrollDownKey = keybindingManager.getActionKeyLabel('consoleScrollDown');
|
|
476
|
+
consoleLogger.info(`Wheel scroll enabled. Use ${scrollUpKey}${scrollDownKey} for precise control, or use Shift+drag for native text selection (if supported).`);
|
|
477
|
+
|
|
478
|
+
// Log session start using new format
|
|
479
|
+
httpLogger.log(
|
|
480
|
+
httpLogger.constructor.LEVEL.INFO,
|
|
481
|
+
httpLogger.constructor.ACTION.SESSION,
|
|
482
|
+
`started sandbox=${sandboxId}`
|
|
483
|
+
);
|
|
484
|
+
|
|
485
|
+
// Map log levels to consoleLogger methods
|
|
486
|
+
const levelToMethod = {
|
|
487
|
+
DEBUG: 'debug',
|
|
488
|
+
INFO: 'info',
|
|
489
|
+
WARN: 'warn',
|
|
490
|
+
ERROR: 'error',
|
|
491
|
+
};
|
|
492
|
+
|
|
493
|
+
const handleLogEvent = (event) => {
|
|
494
|
+
const method = levelToMethod[event.level] || 'info';
|
|
495
|
+
// Pass fullMessage for body events (used when copying)
|
|
496
|
+
consoleLogger[method](event.message, event.fullMessage);
|
|
497
|
+
};
|
|
498
|
+
|
|
499
|
+
httpLogger.on('request', handleLogEvent);
|
|
500
|
+
httpLogger.on('response', handleLogEvent);
|
|
501
|
+
httpLogger.on('error', handleLogEvent);
|
|
502
|
+
httpLogger.on('log', handleLogEvent);
|
|
503
|
+
|
|
504
|
+
return () => {
|
|
505
|
+
httpLogger.off('request', handleLogEvent);
|
|
506
|
+
httpLogger.off('response', handleLogEvent);
|
|
507
|
+
httpLogger.off('error', handleLogEvent);
|
|
508
|
+
httpLogger.off('log', handleLogEvent);
|
|
509
|
+
httpLogger.disable();
|
|
510
|
+
clearConsoleLogger();
|
|
511
|
+
// Save outputs on unmount
|
|
512
|
+
saveOutputsBeforeExit();
|
|
513
|
+
};
|
|
514
|
+
}, [sandboxId, saveOutputsBeforeExit]);
|
|
515
|
+
|
|
516
|
+
// Handle function keys (F1-F12) using action-based API
|
|
517
|
+
const functionKeyHandlers = useCallback(() => ({
|
|
518
|
+
toggleConsole: () => setState(s => toggleConsole(s)),
|
|
519
|
+
// Page Up/Down: scroll console if visible, otherwise scroll output history
|
|
520
|
+
consoleScrollUp: () => setState(s => (
|
|
521
|
+
s.consoleVisible ? scrollConsole(s, -5) : scrollOutputs(s, 3, maxVisibleOutputs)
|
|
522
|
+
)),
|
|
523
|
+
consoleScrollDown: () => setState(s => (
|
|
524
|
+
s.consoleVisible ? scrollConsole(s, 5) : scrollOutputs(s, -3, maxVisibleOutputs)
|
|
525
|
+
)),
|
|
526
|
+
}), [maxVisibleOutputs]);
|
|
527
|
+
useFunctionKeys(functionKeyHandlers());
|
|
528
|
+
|
|
529
|
+
// Resource monitoring (via client.exec, not session, to avoid output interference)
|
|
530
|
+
const { resources } = useResources(client, state.viewMode === 'repl' && !state.isExecuting);
|
|
531
|
+
|
|
532
|
+
// Update resources in state
|
|
533
|
+
useEffect(() => {
|
|
534
|
+
if (resources) {
|
|
535
|
+
setState(s => updateResources(s, resources));
|
|
536
|
+
}
|
|
537
|
+
}, [resources]);
|
|
538
|
+
|
|
539
|
+
// Check triggers and update menu
|
|
540
|
+
const checkTriggers = useCallback((buffer) => {
|
|
541
|
+
for (const trigger of triggers) {
|
|
542
|
+
if (buffer.startsWith(trigger.symbol)) {
|
|
543
|
+
const items = trigger.getItems(buffer);
|
|
544
|
+
// Filter items by prefix
|
|
545
|
+
const filtered = items.filter(item =>
|
|
546
|
+
item.label.toLowerCase().startsWith(buffer.toLowerCase())
|
|
547
|
+
);
|
|
548
|
+
return filtered;
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
return [];
|
|
552
|
+
}, [triggers]);
|
|
553
|
+
|
|
554
|
+
// Note: Slash menu is now handled synchronously in useInput via setBufferWithSlashMenu
|
|
555
|
+
// This eliminates the flicker caused by useEffect's asynchronous updates
|
|
556
|
+
// Path and @ completion menus are still handled via their respective useEffect hooks
|
|
557
|
+
|
|
558
|
+
// Ref for @ completion debounce
|
|
559
|
+
const atCompletionTimeoutRef = useRef(null);
|
|
560
|
+
const atCompletionAbortRef = useRef(null);
|
|
561
|
+
|
|
562
|
+
// Trigger @ completion for /upload command
|
|
563
|
+
const triggerAtCompletion = useCallback(async (atContext) => {
|
|
564
|
+
if (!atContext) return;
|
|
565
|
+
|
|
566
|
+
try {
|
|
567
|
+
let items;
|
|
568
|
+
if (atContext.isLocal) {
|
|
569
|
+
// First @: local file completion
|
|
570
|
+
items = getLocalCompletions(atContext.partialPath, process.cwd());
|
|
571
|
+
} else {
|
|
572
|
+
// Second @: remote file completion
|
|
573
|
+
if (atCompletionAbortRef.current) {
|
|
574
|
+
atCompletionAbortRef.current.abort();
|
|
575
|
+
}
|
|
576
|
+
const controller = new AbortController();
|
|
577
|
+
atCompletionAbortRef.current = controller;
|
|
578
|
+
items = await getRemoteCompletions(sessionManager, atContext.partialPath, { signal: controller.signal });
|
|
579
|
+
if (controller.signal.aborted) {
|
|
580
|
+
return;
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
if (items.length > 0) {
|
|
585
|
+
setState(s => showMenu(s, items, 'at', atContext));
|
|
586
|
+
} else if (state.menuType === 'at') {
|
|
587
|
+
setState(s => hideMenu(s));
|
|
588
|
+
}
|
|
589
|
+
} catch (e) {
|
|
590
|
+
// Silently ignore completion errors
|
|
591
|
+
}
|
|
592
|
+
}, [sessionManager, state.menuType]);
|
|
593
|
+
|
|
594
|
+
// Detect @ in buffer for /upload and /download commands and trigger completion
|
|
595
|
+
useEffect(() => {
|
|
596
|
+
if (state.isExecuting) return;
|
|
597
|
+
if (!state.buffer.startsWith('/upload') && !state.buffer.startsWith('/download')) return;
|
|
598
|
+
|
|
599
|
+
// Clear previous timeout
|
|
600
|
+
if (atCompletionTimeoutRef.current) {
|
|
601
|
+
clearTimeout(atCompletionTimeoutRef.current);
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
const atContext = parseAtContext(state.buffer);
|
|
605
|
+
if (atContext) {
|
|
606
|
+
// Debounce to avoid too many completions while typing
|
|
607
|
+
atCompletionTimeoutRef.current = setTimeout(() => {
|
|
608
|
+
triggerAtCompletion(atContext);
|
|
609
|
+
}, 100);
|
|
610
|
+
} else if (state.menuVisible && state.menuType === 'at') {
|
|
611
|
+
// No @ context, hide at menu
|
|
612
|
+
setState(s => hideMenu(s));
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
return () => {
|
|
616
|
+
if (atCompletionTimeoutRef.current) {
|
|
617
|
+
clearTimeout(atCompletionTimeoutRef.current);
|
|
618
|
+
}
|
|
619
|
+
if (atCompletionAbortRef.current) {
|
|
620
|
+
atCompletionAbortRef.current.abort();
|
|
621
|
+
}
|
|
622
|
+
};
|
|
623
|
+
}, [state.buffer, state.isExecuting, triggerAtCompletion, state.menuType, state.menuVisible]);
|
|
624
|
+
|
|
625
|
+
// Dynamic page loading functions
|
|
626
|
+
const loadPreviousPageRef = useRef(null);
|
|
627
|
+
const loadNextPageRef = useRef(null);
|
|
628
|
+
|
|
629
|
+
const loadPreviousPageAsync = useCallback((paginatedFile) => {
|
|
630
|
+
if (loadPreviousPageRef.current) return; // Already loading
|
|
631
|
+
|
|
632
|
+
loadPreviousPageRef.current = (async () => {
|
|
633
|
+
const { filePath, loadedStartLine, loadedEndLine } = paginatedFile;
|
|
634
|
+
const pageSize = 50;
|
|
635
|
+
const { newStartLine, newEndLine } = calculatePreviousPageRange(loadedStartLine, pageSize);
|
|
636
|
+
|
|
637
|
+
if (newEndLine < newStartLine) return; // No more content
|
|
638
|
+
|
|
639
|
+
setState(s => setPaginatedFileLoading(s, true));
|
|
640
|
+
|
|
641
|
+
try {
|
|
642
|
+
const result = await sessionManager.execute(`sed -n '${newStartLine},${newEndLine}p' "${filePath}"`);
|
|
643
|
+
const newContent = result.output || '';
|
|
644
|
+
|
|
645
|
+
setState(s => {
|
|
646
|
+
// Prepend new content
|
|
647
|
+
const currentContent = s.detailContent?.content || '';
|
|
648
|
+
const updatedContent = newContent + '\n' + currentContent;
|
|
649
|
+
const linesAdded = newContent.split('\n').length;
|
|
650
|
+
|
|
651
|
+
let newState = {
|
|
652
|
+
...s,
|
|
653
|
+
detailContent: {
|
|
654
|
+
...s.detailContent,
|
|
655
|
+
content: updatedContent,
|
|
656
|
+
scrollOffset: (s.detailContent?.scrollOffset || 0) + linesAdded,
|
|
657
|
+
title: `${filePath} (${paginatedFile.totalLines} lines, showing ${newStartLine}-${loadedEndLine})`,
|
|
658
|
+
}
|
|
659
|
+
};
|
|
660
|
+
|
|
661
|
+
// Update paginated file range
|
|
662
|
+
newState = extendPaginatedFileRange(newState, newStartLine, loadedEndLine);
|
|
663
|
+
newState = setPaginatedFileLoading(newState, false);
|
|
664
|
+
|
|
665
|
+
return newState;
|
|
666
|
+
});
|
|
667
|
+
} catch (e) {
|
|
668
|
+
setState(s => setPaginatedFileLoading(s, false));
|
|
669
|
+
} finally {
|
|
670
|
+
loadPreviousPageRef.current = null;
|
|
671
|
+
}
|
|
672
|
+
})();
|
|
673
|
+
}, [sessionManager]);
|
|
674
|
+
|
|
675
|
+
const loadNextPageAsync = useCallback((paginatedFile) => {
|
|
676
|
+
if (loadNextPageRef.current) return; // Already loading
|
|
677
|
+
|
|
678
|
+
loadNextPageRef.current = (async () => {
|
|
679
|
+
const { filePath, loadedStartLine, loadedEndLine, totalLines } = paginatedFile;
|
|
680
|
+
const pageSize = 50;
|
|
681
|
+
const { newStartLine, newEndLine } = calculateNextPageRange(loadedEndLine, totalLines, pageSize);
|
|
682
|
+
|
|
683
|
+
if (newStartLine > totalLines) return; // No more content
|
|
684
|
+
|
|
685
|
+
setState(s => setPaginatedFileLoading(s, true));
|
|
686
|
+
|
|
687
|
+
try {
|
|
688
|
+
const result = await sessionManager.execute(`sed -n '${newStartLine},${newEndLine}p' "${filePath}"`);
|
|
689
|
+
const newContent = result.output || '';
|
|
690
|
+
|
|
691
|
+
setState(s => {
|
|
692
|
+
// Append new content
|
|
693
|
+
const currentContent = s.detailContent?.content || '';
|
|
694
|
+
const updatedContent = currentContent + '\n' + newContent;
|
|
695
|
+
|
|
696
|
+
let newState = {
|
|
697
|
+
...s,
|
|
698
|
+
detailContent: {
|
|
699
|
+
...s.detailContent,
|
|
700
|
+
content: updatedContent,
|
|
701
|
+
title: `${filePath} (${paginatedFile.totalLines} lines, showing ${loadedStartLine}-${newEndLine})`,
|
|
702
|
+
}
|
|
703
|
+
};
|
|
704
|
+
|
|
705
|
+
// Update paginated file range
|
|
706
|
+
newState = extendPaginatedFileRange(newState, loadedStartLine, newEndLine);
|
|
707
|
+
newState = setPaginatedFileLoading(newState, false);
|
|
708
|
+
|
|
709
|
+
return newState;
|
|
710
|
+
});
|
|
711
|
+
} catch (e) {
|
|
712
|
+
setState(s => setPaginatedFileLoading(s, false));
|
|
713
|
+
} finally {
|
|
714
|
+
loadNextPageRef.current = null;
|
|
715
|
+
}
|
|
716
|
+
})();
|
|
717
|
+
}, [sessionManager]);
|
|
718
|
+
|
|
719
|
+
// Execute command
|
|
720
|
+
const executeCommand = useCallback(async (command) => {
|
|
721
|
+
if (!command.trim()) return;
|
|
722
|
+
|
|
723
|
+
// Increment shell command count
|
|
724
|
+
statsRef.current.shellCommands++;
|
|
725
|
+
|
|
726
|
+
// Check if this is a cat command that should enable pagination
|
|
727
|
+
let enablePagination = false;
|
|
728
|
+
let filePath = null;
|
|
729
|
+
const PAGINATION_THRESHOLD = 100; // Enable pagination if file > 100 lines
|
|
730
|
+
const INITIAL_PAGE_SIZE = 70; // Initial load: 70 lines
|
|
731
|
+
|
|
732
|
+
if (shouldEnablePagination(command)) {
|
|
733
|
+
filePath = extractCatFilePath(command);
|
|
734
|
+
if (filePath) {
|
|
735
|
+
try {
|
|
736
|
+
// Get total line count
|
|
737
|
+
const lineCountResult = await sessionManager.execute(`wc -l < "${filePath}" 2>/dev/null`);
|
|
738
|
+
const totalLines = parseInt(lineCountResult.output?.trim() || '0', 10);
|
|
739
|
+
|
|
740
|
+
if (totalLines > PAGINATION_THRESHOLD) {
|
|
741
|
+
enablePagination = true;
|
|
742
|
+
|
|
743
|
+
// Load initial page
|
|
744
|
+
setState(s => startExecuting(s, command));
|
|
745
|
+
setState(s => addToHistory(s, command));
|
|
746
|
+
|
|
747
|
+
const initialContent = await sessionManager.execute(`sed -n '1,${INITIAL_PAGE_SIZE}p' "${filePath}"`);
|
|
748
|
+
|
|
749
|
+
setState(s => {
|
|
750
|
+
let newState = finishExecuting(s);
|
|
751
|
+
let output = initialContent.output || '';
|
|
752
|
+
|
|
753
|
+
// Add hint for large files
|
|
754
|
+
const remainingLines = totalLines - INITIAL_PAGE_SIZE;
|
|
755
|
+
if (remainingLines > 0) {
|
|
756
|
+
output += `\n\n[Showing first ${INITIAL_PAGE_SIZE} of ${totalLines} lines. Press Ctrl+V to view full content with pagination.]`;
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
newState = addOutput(newState, command, output, 0);
|
|
760
|
+
|
|
761
|
+
// Set paginated file state (but don't auto-enter detail view)
|
|
762
|
+
newState = setPaginatedFile(newState, {
|
|
763
|
+
filePath,
|
|
764
|
+
totalLines,
|
|
765
|
+
loadedStartLine: 1,
|
|
766
|
+
loadedEndLine: Math.min(INITIAL_PAGE_SIZE, totalLines),
|
|
767
|
+
isLoading: false,
|
|
768
|
+
});
|
|
769
|
+
|
|
770
|
+
return newState;
|
|
771
|
+
});
|
|
772
|
+
|
|
773
|
+
// Update prompt (same as below)
|
|
774
|
+
try {
|
|
775
|
+
const pwdResult = await sessionManager.execute('pwd');
|
|
776
|
+
if (pwdResult.output) {
|
|
777
|
+
const cwd = pwdResult.output.trim();
|
|
778
|
+
const newPrompt = `${user}@${hostname}:${cwd}# `;
|
|
779
|
+
setState(s => updatePrompt(s, newPrompt));
|
|
780
|
+
if (historyManager && historyManager.updateWorkDir) {
|
|
781
|
+
await historyManager.updateWorkDir(cwd);
|
|
782
|
+
}
|
|
783
|
+
}
|
|
784
|
+
} catch (e) {
|
|
785
|
+
// Ignore
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
return; // Early return, pagination handled
|
|
789
|
+
}
|
|
790
|
+
} catch (e) {
|
|
791
|
+
// Fall through to normal execution
|
|
792
|
+
}
|
|
793
|
+
}
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
// Normal execution (non-paginated or pagination failed)
|
|
797
|
+
// Auto-limit output for potentially large file viewing commands
|
|
798
|
+
let actualCommand = command;
|
|
799
|
+
let wasLimited = false;
|
|
800
|
+
const MAX_OUTPUT_LINES = 70; // maxLines (20) + buffer (50)
|
|
801
|
+
|
|
802
|
+
if (!enablePagination) {
|
|
803
|
+
// Check if command is a file viewer without explicit line limit
|
|
804
|
+
const fileViewerPattern = /^\s*(cat|less|more|zcat|bzcat|xzcat)\s+/;
|
|
805
|
+
const hasHeadTail = /\|\s*(head|tail)\s+/;
|
|
806
|
+
const hasLineLimit = /-n\s+\d+/;
|
|
807
|
+
|
|
808
|
+
if (fileViewerPattern.test(command) && !hasHeadTail.test(command) && !hasLineLimit.test(command)) {
|
|
809
|
+
// Use 2>/dev/null to suppress "Broken pipe" error from cat/less when head closes early
|
|
810
|
+
actualCommand = `${command} 2>/dev/null | head -n ${MAX_OUTPUT_LINES}`;
|
|
811
|
+
wasLimited = true;
|
|
812
|
+
}
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
setState(s => startExecuting(s, command));
|
|
816
|
+
setState(s => addToHistory(s, command));
|
|
817
|
+
|
|
818
|
+
try {
|
|
819
|
+
const result = await sessionManager.execute(actualCommand);
|
|
820
|
+
|
|
821
|
+
setState(s => {
|
|
822
|
+
let newState = finishExecuting(s);
|
|
823
|
+
|
|
824
|
+
// Format command output using responseFormatter
|
|
825
|
+
const formatted = formatCommandOutput(result, { locale: 'zh' });
|
|
826
|
+
let output = formatted.output || '';
|
|
827
|
+
const exitCode = formatted.exitCode ?? result.exit_code ?? 0;
|
|
828
|
+
|
|
829
|
+
// Add hint if output was auto-limited and reached the limit
|
|
830
|
+
if (wasLimited && output) {
|
|
831
|
+
const outputLines = output.split('\n').length;
|
|
832
|
+
// Only show hint if output reached or is close to the limit
|
|
833
|
+
if (outputLines >= MAX_OUTPUT_LINES - 5) {
|
|
834
|
+
output += `\n\n[Output limited to ${MAX_OUTPUT_LINES} lines. Use 'head -n <lines>' or 'tail -n <lines>' to view more.]`;
|
|
835
|
+
}
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
// Handle abnormal exit codes with helpful messages
|
|
839
|
+
if (exitCode === -1) {
|
|
840
|
+
// -1 typically indicates process was killed or internal error
|
|
841
|
+
const hint = output ? '' : 'Command terminated abnormally (exit code -1). This may indicate a timeout, signal termination, or internal error.';
|
|
842
|
+
output = output || hint;
|
|
843
|
+
} else if (exitCode === 137) {
|
|
844
|
+
// 128 + 9 (SIGKILL) - often OOM killed
|
|
845
|
+
output += output ? '\n' : '';
|
|
846
|
+
output += '[Process killed (exit code 137). Possibly OOM or manually terminated.]';
|
|
847
|
+
} else if (exitCode === 143) {
|
|
848
|
+
// 128 + 15 (SIGTERM) - graceful termination
|
|
849
|
+
output += output ? '\n' : '';
|
|
850
|
+
output += '[Process terminated (exit code 143). Received SIGTERM.]';
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
newState = addOutput(newState, command, output, exitCode, null, formatted.metaInfo, formatted.tips);
|
|
854
|
+
return newState;
|
|
855
|
+
});
|
|
856
|
+
|
|
857
|
+
// Update prompt after command (for cd, etc.)
|
|
858
|
+
try {
|
|
859
|
+
const pwdResult = await sessionManager.execute('pwd');
|
|
860
|
+
if (pwdResult.output) {
|
|
861
|
+
const cwd = pwdResult.output.trim();
|
|
862
|
+
const newPrompt = `${user}@${hostname}:${cwd}# `;
|
|
863
|
+
setState(s => updatePrompt(s, newPrompt));
|
|
864
|
+
|
|
865
|
+
// Update work_dir in history manager
|
|
866
|
+
if (historyManager && historyManager.updateWorkDir) {
|
|
867
|
+
await historyManager.updateWorkDir(cwd);
|
|
868
|
+
}
|
|
869
|
+
}
|
|
870
|
+
} catch (e) {
|
|
871
|
+
// Ignore prompt update errors
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
// Save to history manager
|
|
875
|
+
if (historyManager) {
|
|
876
|
+
await historyManager.addCommand(command);
|
|
877
|
+
}
|
|
878
|
+
} catch (error) {
|
|
879
|
+
// Parse the error to extract meaningful output
|
|
880
|
+
const { output, exitCode } = parseExecutionError(error);
|
|
881
|
+
|
|
882
|
+
// Format error output with metaInfo and tips
|
|
883
|
+
const errorResult = { output };
|
|
884
|
+
const formatted = formatCommandOutput(errorResult, { locale: 'zh' });
|
|
885
|
+
|
|
886
|
+
setState(s => {
|
|
887
|
+
let newState = finishExecuting(s);
|
|
888
|
+
newState = addOutput(newState, command, formatted.output || output, formatted.exitCode ?? exitCode, null, formatted.metaInfo, formatted.tips);
|
|
889
|
+
return newState;
|
|
890
|
+
});
|
|
891
|
+
}
|
|
892
|
+
}, [sessionManager, historyManager, sandboxId]);
|
|
893
|
+
|
|
894
|
+
// Track last command for /retry
|
|
895
|
+
const lastCommandRef = useRef(null);
|
|
896
|
+
const lastOutputRef = useRef(null);
|
|
897
|
+
|
|
898
|
+
// Handle built-in commands using the builtinCommands module
|
|
899
|
+
const handleBuiltinCommand = useCallback(async (command) => {
|
|
900
|
+
// Increment builtin command count
|
|
901
|
+
statsRef.current.builtinCommands++;
|
|
902
|
+
|
|
903
|
+
// Create execution context for builtin commands
|
|
904
|
+
const context = {
|
|
905
|
+
sandboxId,
|
|
906
|
+
client,
|
|
907
|
+
sessionManager,
|
|
908
|
+
historyManager,
|
|
909
|
+
stats: statsRef.current,
|
|
910
|
+
lastCommand: lastCommandRef.current,
|
|
911
|
+
lastOutput: lastOutputRef.current,
|
|
912
|
+
version,
|
|
913
|
+
terminalWidth, // Add terminal width for table formatting
|
|
914
|
+
themeManager: null,
|
|
915
|
+
setTheme: (name) => themeManager.setActiveTheme(name),
|
|
916
|
+
};
|
|
917
|
+
|
|
918
|
+
setState(s => startExecuting(s, command));
|
|
919
|
+
setState(s => addToHistory(s, command));
|
|
920
|
+
|
|
921
|
+
try {
|
|
922
|
+
const result = await executeBuiltinCommand(command, context);
|
|
923
|
+
|
|
924
|
+
// Handle special actions
|
|
925
|
+
if (result.action === 'exit') {
|
|
926
|
+
setState(s => finishExecuting(s));
|
|
927
|
+
saveOutputsBeforeExit();
|
|
928
|
+
if (onExit) onExit();
|
|
929
|
+
exit();
|
|
930
|
+
return;
|
|
931
|
+
}
|
|
932
|
+
|
|
933
|
+
if (result.action === 'clear') {
|
|
934
|
+
setState(s => {
|
|
935
|
+
let newState = finishExecuting(s);
|
|
936
|
+
return { ...newState, outputs: [] };
|
|
937
|
+
});
|
|
938
|
+
return;
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
if (result.action === 'retry' && result.command) {
|
|
942
|
+
setState(s => finishExecuting(s));
|
|
943
|
+
await executeCommand(result.command);
|
|
944
|
+
return;
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
// Show output
|
|
948
|
+
if (result.output) {
|
|
949
|
+
setState(s => {
|
|
950
|
+
let newState = finishExecuting(s);
|
|
951
|
+
newState = addOutput(newState, command, result.output, result.exitCode || 0);
|
|
952
|
+
return newState;
|
|
953
|
+
});
|
|
954
|
+
} else {
|
|
955
|
+
setState(s => finishExecuting(s));
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
// Save to history manager
|
|
959
|
+
if (historyManager) {
|
|
960
|
+
await historyManager.addCommand(command);
|
|
961
|
+
}
|
|
962
|
+
} catch (error) {
|
|
963
|
+
setState(s => {
|
|
964
|
+
let newState = finishExecuting(s);
|
|
965
|
+
newState = addOutput(newState, command, friendlyErrorMessage(error.message), 1);
|
|
966
|
+
return newState;
|
|
967
|
+
});
|
|
968
|
+
}
|
|
969
|
+
}, [sandboxId, client, sessionManager, historyManager, exit, onExit, executeCommand]);
|
|
970
|
+
|
|
971
|
+
// Submit command
|
|
972
|
+
const submitCommand = useCallback(async (command) => {
|
|
973
|
+
if (!command.trim()) return;
|
|
974
|
+
|
|
975
|
+
setState(s => setBuffer(s, '')); // Clear buffer immediately
|
|
976
|
+
|
|
977
|
+
// Handle builtin commands
|
|
978
|
+
if (isBuiltinCommand(command)) {
|
|
979
|
+
await handleBuiltinCommand(command);
|
|
980
|
+
return;
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
// Check for interactive commands
|
|
984
|
+
const interactiveCheck = checkInteractiveCommand(command);
|
|
985
|
+
if (interactiveCheck.isInteractive) {
|
|
986
|
+
setState(s => addOutput(s, command,
|
|
987
|
+
`Interactive command '${interactiveCheck.cmdName}' not supported in attach mode.\n` +
|
|
988
|
+
`Alternative: ${interactiveCheck.alternative || 'Use non-interactive mode'}`,
|
|
989
|
+
1
|
|
990
|
+
, null, Math.max(20, terminalWidth), 50));
|
|
991
|
+
return;
|
|
992
|
+
}
|
|
993
|
+
|
|
994
|
+
// Track last command for /retry
|
|
995
|
+
lastCommandRef.current = command;
|
|
996
|
+
|
|
997
|
+
// Execute shell command
|
|
998
|
+
await executeCommand(command);
|
|
999
|
+
}, [executeCommand, handleBuiltinCommand]);
|
|
1000
|
+
|
|
1001
|
+
// Trigger path completion (async)
|
|
1002
|
+
const triggerPathCompletion = useCallback(async () => {
|
|
1003
|
+
if (!sessionManager || !state.buffer.trim()) return;
|
|
1004
|
+
|
|
1005
|
+
try {
|
|
1006
|
+
const items = await getPathCompletions(sessionManager, state.buffer);
|
|
1007
|
+
if (items.length === 1) {
|
|
1008
|
+
// Single match - auto-fill directly without showing menu
|
|
1009
|
+
setState(s => setBuffer(s, items[0].value));
|
|
1010
|
+
} else if (items.length > 1) {
|
|
1011
|
+
// Multiple matches - show menu
|
|
1012
|
+
setState(s => showMenu(s, items, 'path'));
|
|
1013
|
+
}
|
|
1014
|
+
// No matches - do nothing
|
|
1015
|
+
} catch (e) {
|
|
1016
|
+
// Silently ignore completion errors
|
|
1017
|
+
}
|
|
1018
|
+
}, [sessionManager, state.buffer]);
|
|
1019
|
+
|
|
1020
|
+
// Use ref to track completion state (avoids re-render)
|
|
1021
|
+
const isCompletingRef = useRef(false);
|
|
1022
|
+
|
|
1023
|
+
// Handle copying log to clipboard
|
|
1024
|
+
const handleCopyLog = useCallback(async (message) => {
|
|
1025
|
+
const success = await copyToClipboard(message);
|
|
1026
|
+
if (success) {
|
|
1027
|
+
consoleLogger.info('Copied to clipboard');
|
|
1028
|
+
} else {
|
|
1029
|
+
consoleLogger.warn('Failed to copy to clipboard');
|
|
1030
|
+
}
|
|
1031
|
+
setState(s => clearConsoleSelection(s));
|
|
1032
|
+
}, []);
|
|
1033
|
+
|
|
1034
|
+
// Auto-save outputs periodically (every 5 seconds when there are changes)
|
|
1035
|
+
const lastSaveRef = useRef(0);
|
|
1036
|
+
useEffect(() => {
|
|
1037
|
+
const outputCount = state.outputs.length;
|
|
1038
|
+
const now = Date.now();
|
|
1039
|
+
|
|
1040
|
+
// Save if outputs changed and at least 5 seconds passed since last save
|
|
1041
|
+
if (outputCount > 1 && now - lastSaveRef.current > 5000) {
|
|
1042
|
+
saveOutputsBeforeExit();
|
|
1043
|
+
lastSaveRef.current = now;
|
|
1044
|
+
}
|
|
1045
|
+
}, [state.outputs.length, saveOutputsBeforeExit]);
|
|
1046
|
+
|
|
1047
|
+
// Wrapper to handle async path completion properly
|
|
1048
|
+
const handleTabCompletion = useCallback(() => {
|
|
1049
|
+
if (state.menuVisible && state.menuItems.length > 0) {
|
|
1050
|
+
// Select from existing menu
|
|
1051
|
+
const selected = state.menuItems[state.selectedIndex];
|
|
1052
|
+
if (selected) {
|
|
1053
|
+
if (state.menuType === 'at' && state.atContext) {
|
|
1054
|
+
// For @ completion, replace only the @path part
|
|
1055
|
+
const newBuffer = replaceAtPath(state.buffer, state.atContext, selected.value);
|
|
1056
|
+
setState(s => setBuffer(hideMenu(s), newBuffer));
|
|
1057
|
+
} else {
|
|
1058
|
+
// For other completions, replace entire buffer
|
|
1059
|
+
setState(s => setBuffer(hideMenu(s), selected.value));
|
|
1060
|
+
}
|
|
1061
|
+
}
|
|
1062
|
+
} else if (!isCompletingRef.current) {
|
|
1063
|
+
// Trigger path completion
|
|
1064
|
+
isCompletingRef.current = true;
|
|
1065
|
+
triggerPathCompletion().finally(() => { isCompletingRef.current = false; });
|
|
1066
|
+
}
|
|
1067
|
+
}, [state.menuVisible, state.menuItems, state.selectedIndex, state.menuType, state.atContext, state.buffer, triggerPathCompletion]);
|
|
1068
|
+
|
|
1069
|
+
// Handle keyboard input using keybindingManager
|
|
1070
|
+
useInput((input, key) => {
|
|
1071
|
+
// If exit is pending and user presses any key other than Ctrl+C, cancel the pending state
|
|
1072
|
+
if (exitConfirmRef.current.pending && !keybindingManager.matchesAction('exit', input, key)) {
|
|
1073
|
+
if (exitConfirmRef.current.timer) {
|
|
1074
|
+
clearTimeout(exitConfirmRef.current.timer);
|
|
1075
|
+
}
|
|
1076
|
+
exitConfirmRef.current.pending = false;
|
|
1077
|
+
setState(s => setExitPending(s, false));
|
|
1078
|
+
// Continue processing the current key input normally (don't return)
|
|
1079
|
+
}
|
|
1080
|
+
|
|
1081
|
+
// Global exit keys - require two presses within 2 seconds
|
|
1082
|
+
if (keybindingManager.matchesAction('exit', input, key)) {
|
|
1083
|
+
if (exitConfirmRef.current.pending) {
|
|
1084
|
+
// Second press - actually exit
|
|
1085
|
+
if (exitConfirmRef.current.timer) {
|
|
1086
|
+
clearTimeout(exitConfirmRef.current.timer);
|
|
1087
|
+
}
|
|
1088
|
+
setState(s => setExitPending(s, false));
|
|
1089
|
+
saveOutputsBeforeExit();
|
|
1090
|
+
if (onExit) onExit();
|
|
1091
|
+
exit();
|
|
1092
|
+
return;
|
|
1093
|
+
} else {
|
|
1094
|
+
// First press - clear buffer and show confirmation in status bar
|
|
1095
|
+
exitConfirmRef.current.pending = true;
|
|
1096
|
+
setState(s => {
|
|
1097
|
+
const newState = setExitPending(s, true);
|
|
1098
|
+
return setBuffer(newState, ''); // Clear input buffer
|
|
1099
|
+
});
|
|
1100
|
+
exitConfirmRef.current.timer = setTimeout(() => {
|
|
1101
|
+
exitConfirmRef.current.pending = false;
|
|
1102
|
+
setState(s => setExitPending(s, false));
|
|
1103
|
+
}, 2000);
|
|
1104
|
+
return;
|
|
1105
|
+
}
|
|
1106
|
+
}
|
|
1107
|
+
|
|
1108
|
+
// Cancel execution with Escape
|
|
1109
|
+
if (state.isExecuting && keybindingManager.matchesAction('cancelExecution', input, key)) {
|
|
1110
|
+
// Debug: log the key that triggered cancellation
|
|
1111
|
+
consoleLogger.warn(`Cancel triggered by key: input=${JSON.stringify(input)}, key=${JSON.stringify(key)}`);
|
|
1112
|
+
if (abortControllerRef.current) {
|
|
1113
|
+
abortControllerRef.current.abort();
|
|
1114
|
+
}
|
|
1115
|
+
setState(s => {
|
|
1116
|
+
let newState = finishExecuting(s);
|
|
1117
|
+
newState = addOutput(newState, s.executingCommand, "Cancelled by user", 130, null, Math.max(20, terminalWidth), 50);
|
|
1118
|
+
return newState;
|
|
1119
|
+
});
|
|
1120
|
+
consoleLogger.warn('Execution cancelled');
|
|
1121
|
+
return;
|
|
1122
|
+
}
|
|
1123
|
+
|
|
1124
|
+
// Toggle mouse capture mode - REMOVED (always enabled)
|
|
1125
|
+
// if (keybindingManager.matchesAction('toggleMouseCapture', input, key)) {
|
|
1126
|
+
// setState(s => toggleMouseCapture(s));
|
|
1127
|
+
// return;
|
|
1128
|
+
// }
|
|
1129
|
+
|
|
1130
|
+
// Detail view mode
|
|
1131
|
+
if (state.viewMode === 'detail') {
|
|
1132
|
+
// Check for selection copy first
|
|
1133
|
+
if (keybindingManager.matchesAction('submit', input, key)) {
|
|
1134
|
+
const selectedText = getSelectedText(state);
|
|
1135
|
+
if (selectedText) {
|
|
1136
|
+
handleCopyLog(selectedText);
|
|
1137
|
+
setState(s => clearSelection(s));
|
|
1138
|
+
return;
|
|
1139
|
+
}
|
|
1140
|
+
}
|
|
1141
|
+
|
|
1142
|
+
// Copy full detail content with Ctrl+Y
|
|
1143
|
+
if (keybindingManager.matchesAction('copyLastOutput', input, key)) {
|
|
1144
|
+
if (state.detailContent && state.detailContent.content) {
|
|
1145
|
+
handleCopyLog(state.detailContent.content);
|
|
1146
|
+
}
|
|
1147
|
+
return;
|
|
1148
|
+
}
|
|
1149
|
+
|
|
1150
|
+
if (keybindingManager.matchesAction('detailExit', input, key)) {
|
|
1151
|
+
setState(s => {
|
|
1152
|
+
let newState = exitDetailView(s);
|
|
1153
|
+
// Clear paginated file state when exiting detail view
|
|
1154
|
+
newState = clearPaginatedFile(newState);
|
|
1155
|
+
return newState;
|
|
1156
|
+
});
|
|
1157
|
+
return;
|
|
1158
|
+
}
|
|
1159
|
+
if (keybindingManager.matchesAction('detailScrollUp', input, key)) {
|
|
1160
|
+
setState(s => scrollDetailView(s, -1, terminalHeight - 1));
|
|
1161
|
+
return;
|
|
1162
|
+
}
|
|
1163
|
+
if (keybindingManager.matchesAction('detailScrollDown', input, key)) {
|
|
1164
|
+
setState(s => scrollDetailView(s, 1, terminalHeight - 1));
|
|
1165
|
+
return;
|
|
1166
|
+
}
|
|
1167
|
+
return;
|
|
1168
|
+
}
|
|
1169
|
+
|
|
1170
|
+
// Executing mode - block other input
|
|
1171
|
+
if (state.isExecuting) {
|
|
1172
|
+
// Debug: log all inputs during execution (use warn to ensure visibility)
|
|
1173
|
+
consoleLogger.warn(`Input during execution: input=${JSON.stringify(input)}, key=${JSON.stringify(key)}`);
|
|
1174
|
+
return;
|
|
1175
|
+
}
|
|
1176
|
+
|
|
1177
|
+
// REPL selection mode: Enter to copy (check before regular Enter handling)
|
|
1178
|
+
if (state.viewMode === 'repl' && state.selection?.source === 'repl') {
|
|
1179
|
+
if (keybindingManager.matchesAction('submit', input, key)) {
|
|
1180
|
+
const selectedText = getSelectedText(state);
|
|
1181
|
+
if (selectedText) {
|
|
1182
|
+
handleCopyLog(selectedText);
|
|
1183
|
+
setState(s => clearSelection(s));
|
|
1184
|
+
return;
|
|
1185
|
+
}
|
|
1186
|
+
}
|
|
1187
|
+
}
|
|
1188
|
+
|
|
1189
|
+
// Console selection mode: Enter to copy (check before regular Enter handling)
|
|
1190
|
+
if (state.consoleVisible) {
|
|
1191
|
+
if (keybindingManager.matchesAction('submit', input, key)) {
|
|
1192
|
+
// First check for drag selection
|
|
1193
|
+
const selectedText = getSelectedText(state);
|
|
1194
|
+
if (selectedText && state.selection?.source === 'console') {
|
|
1195
|
+
handleCopyLog(selectedText);
|
|
1196
|
+
setState(s => clearSelection(s));
|
|
1197
|
+
return;
|
|
1198
|
+
}
|
|
1199
|
+
|
|
1200
|
+
// Then check for keyboard selection
|
|
1201
|
+
if (state.consoleSelectedIndex >= 0) {
|
|
1202
|
+
const log = getSelectedConsoleLog(state);
|
|
1203
|
+
if (log) {
|
|
1204
|
+
// Use fullMessage if available (contains complete body), otherwise use message
|
|
1205
|
+
handleCopyLog(log.fullMessage || log.message);
|
|
1206
|
+
}
|
|
1207
|
+
return;
|
|
1208
|
+
}
|
|
1209
|
+
}
|
|
1210
|
+
}
|
|
1211
|
+
|
|
1212
|
+
// Enter: Submit or select
|
|
1213
|
+
if (keybindingManager.matchesAction('submit', input, key)) {
|
|
1214
|
+
if (state.menuVisible && state.menuItems.length > 0) {
|
|
1215
|
+
const selected = state.menuItems[state.selectedIndex];
|
|
1216
|
+
if (selected) {
|
|
1217
|
+
if (state.menuType === 'at' && state.atContext) {
|
|
1218
|
+
// For @ completion, replace the @path part and continue editing
|
|
1219
|
+
const newBuffer = replaceAtPath(state.buffer, state.atContext, selected.value);
|
|
1220
|
+
setState(s => setBuffer(hideMenu(s), newBuffer));
|
|
1221
|
+
} else {
|
|
1222
|
+
// For other menus (slash commands), execute directly
|
|
1223
|
+
setState(s => hideMenu(s));
|
|
1224
|
+
submitCommand(selected.value);
|
|
1225
|
+
}
|
|
1226
|
+
}
|
|
1227
|
+
} else {
|
|
1228
|
+
submitCommand(state.buffer);
|
|
1229
|
+
}
|
|
1230
|
+
return;
|
|
1231
|
+
}
|
|
1232
|
+
|
|
1233
|
+
// Tab: Auto-complete
|
|
1234
|
+
if (keybindingManager.matchesAction('autoComplete', input, key)) {
|
|
1235
|
+
handleTabCompletion();
|
|
1236
|
+
return;
|
|
1237
|
+
}
|
|
1238
|
+
|
|
1239
|
+
// Escape: Close menu, cancel console selection, or clear drag selection
|
|
1240
|
+
if (keybindingManager.matchesAction('closeMenu', input, key)) {
|
|
1241
|
+
// Clear drag selection first if active
|
|
1242
|
+
if (state.selection) {
|
|
1243
|
+
setState(s => clearSelection(s));
|
|
1244
|
+
return;
|
|
1245
|
+
}
|
|
1246
|
+
if (state.consoleVisible && state.consoleSelectedIndex >= 0) {
|
|
1247
|
+
setState(s => clearConsoleSelection(s));
|
|
1248
|
+
return;
|
|
1249
|
+
}
|
|
1250
|
+
if (state.menuVisible) {
|
|
1251
|
+
setState(s => hideMenu(s));
|
|
1252
|
+
}
|
|
1253
|
+
return;
|
|
1254
|
+
}
|
|
1255
|
+
|
|
1256
|
+
// Up arrow - context-dependent
|
|
1257
|
+
if (keybindingManager.matchesAction('menuUp', input, key) || keybindingManager.matchesAction('historyUp', input, key)) {
|
|
1258
|
+
if (state.consoleVisible) {
|
|
1259
|
+
// Console selection mode
|
|
1260
|
+
setState(s => selectConsoleLog(s, -1));
|
|
1261
|
+
return;
|
|
1262
|
+
}
|
|
1263
|
+
if (state.menuVisible) {
|
|
1264
|
+
setState(s => selectPrev(s));
|
|
1265
|
+
} else {
|
|
1266
|
+
setState(s => navigateHistoryUp(s));
|
|
1267
|
+
}
|
|
1268
|
+
return;
|
|
1269
|
+
}
|
|
1270
|
+
|
|
1271
|
+
// Down arrow - context-dependent
|
|
1272
|
+
if (keybindingManager.matchesAction('menuDown', input, key) || keybindingManager.matchesAction('historyDown', input, key)) {
|
|
1273
|
+
if (state.consoleVisible) {
|
|
1274
|
+
// Console selection mode
|
|
1275
|
+
setState(s => selectConsoleLog(s, 1));
|
|
1276
|
+
return;
|
|
1277
|
+
}
|
|
1278
|
+
if (state.menuVisible) {
|
|
1279
|
+
setState(s => selectNext(s));
|
|
1280
|
+
} else {
|
|
1281
|
+
setState(s => navigateHistoryDown(s));
|
|
1282
|
+
}
|
|
1283
|
+
return;
|
|
1284
|
+
}
|
|
1285
|
+
|
|
1286
|
+
// Left arrow - move cursor left (when not in menu/console mode)
|
|
1287
|
+
if (key.leftArrow && !state.menuVisible && !state.consoleVisible) {
|
|
1288
|
+
setState(s => moveCursorLeft(s));
|
|
1289
|
+
return;
|
|
1290
|
+
}
|
|
1291
|
+
|
|
1292
|
+
// Right arrow - move cursor right (when not in menu/console mode)
|
|
1293
|
+
if (key.rightArrow && !state.menuVisible && !state.consoleVisible) {
|
|
1294
|
+
setState(s => moveCursorRight(s));
|
|
1295
|
+
return;
|
|
1296
|
+
}
|
|
1297
|
+
|
|
1298
|
+
// Ctrl+A - move cursor to start
|
|
1299
|
+
if (key.ctrl && input === 'a') {
|
|
1300
|
+
setState(s => moveCursorToStart(s));
|
|
1301
|
+
return;
|
|
1302
|
+
}
|
|
1303
|
+
|
|
1304
|
+
// Ctrl+E - move cursor to end
|
|
1305
|
+
if (key.ctrl && input === 'e') {
|
|
1306
|
+
setState(s => moveCursorToEnd(s));
|
|
1307
|
+
return;
|
|
1308
|
+
}
|
|
1309
|
+
|
|
1310
|
+
// Delete word (Alt/Option + Backspace)
|
|
1311
|
+
if (keybindingManager.matchesAction('deleteWord', input, key)) {
|
|
1312
|
+
setState(s => {
|
|
1313
|
+
const buffer = s.buffer;
|
|
1314
|
+
if (!buffer) return s;
|
|
1315
|
+
// Find the last word boundary (space, /, -, _, etc.)
|
|
1316
|
+
const trimmed = buffer.trimEnd();
|
|
1317
|
+
const match = trimmed.match(/^(.*?)[\s\/\-_\.]+[^\s\/\-_\.]*$/);
|
|
1318
|
+
if (match) {
|
|
1319
|
+
return setBufferWithSlashMenu(s, match[1], checkTriggers);
|
|
1320
|
+
}
|
|
1321
|
+
// No word boundary found, delete everything
|
|
1322
|
+
return setBufferWithSlashMenu(s, '', checkTriggers);
|
|
1323
|
+
});
|
|
1324
|
+
return;
|
|
1325
|
+
}
|
|
1326
|
+
|
|
1327
|
+
// Backspace - delete character at cursor position
|
|
1328
|
+
if (key.backspace || key.delete) {
|
|
1329
|
+
setState(s => {
|
|
1330
|
+
const cursorPos = s.cursorPosition || 0;
|
|
1331
|
+
if (cursorPos === 0) return s; // Nothing to delete
|
|
1332
|
+
const before = s.buffer.slice(0, cursorPos - 1);
|
|
1333
|
+
const after = s.buffer.slice(cursorPos);
|
|
1334
|
+
const newBuffer = before + after;
|
|
1335
|
+
const newState = setBufferWithSlashMenu(s, newBuffer, checkTriggers);
|
|
1336
|
+
return { ...newState, cursorPosition: cursorPos - 1 };
|
|
1337
|
+
});
|
|
1338
|
+
return;
|
|
1339
|
+
}
|
|
1340
|
+
|
|
1341
|
+
// View full output (when not typing)
|
|
1342
|
+
if (keybindingManager.matchesAction('viewOutput', input, key) && !state.buffer) {
|
|
1343
|
+
const lastOutput = state.outputs[state.outputs.length - 1];
|
|
1344
|
+
if (lastOutput && lastOutput.output) {
|
|
1345
|
+
// Check if this is a paginated file
|
|
1346
|
+
let title = lastOutput.command;
|
|
1347
|
+
if (state.paginatedFile) {
|
|
1348
|
+
const { filePath, totalLines, loadedStartLine, loadedEndLine } = state.paginatedFile;
|
|
1349
|
+
title = `${filePath} (${totalLines} lines, showing ${loadedStartLine}-${loadedEndLine})`;
|
|
1350
|
+
}
|
|
1351
|
+
setState(s => enterDetailView(s, title, lastOutput.output));
|
|
1352
|
+
}
|
|
1353
|
+
return;
|
|
1354
|
+
}
|
|
1355
|
+
|
|
1356
|
+
// Copy last output (plain text, no borders)
|
|
1357
|
+
if (keybindingManager.matchesAction('copyLastOutput', input, key) && !state.buffer) {
|
|
1358
|
+
const lastOutput = state.outputs[state.outputs.length - 1];
|
|
1359
|
+
if (lastOutput && lastOutput.output && !lastOutput.isWelcome) {
|
|
1360
|
+
handleCopyLog(lastOutput.output);
|
|
1361
|
+
}
|
|
1362
|
+
return;
|
|
1363
|
+
}
|
|
1364
|
+
|
|
1365
|
+
// Regular character - filter out only mouse events and control characters
|
|
1366
|
+
// Allow printable characters including CJK (Chinese/Japanese/Korean)
|
|
1367
|
+
if (input && !key.ctrl && !key.meta) {
|
|
1368
|
+
// Skip mouse event sequences (handled by useMouse hook)
|
|
1369
|
+
if (input.includes('\x1b[<') || input.includes('\x1b[M')) {
|
|
1370
|
+
return;
|
|
1371
|
+
}
|
|
1372
|
+
// Skip SGR mouse events where ESC was stripped: [<button;x;yM or [<button;x;ym
|
|
1373
|
+
// This can happen when mouse events leak through from useMouse hook
|
|
1374
|
+
if (/^\[<\d+;\d+;\d+[Mm]/.test(input)) {
|
|
1375
|
+
return;
|
|
1376
|
+
}
|
|
1377
|
+
// Filter out non-printable control characters (but allow CJK characters)
|
|
1378
|
+
// Control characters: 0x00-0x1F (except tab 0x09), 0x7F-0x9F
|
|
1379
|
+
// Keep: regular ASCII (0x20-0x7E), CJK and other Unicode characters
|
|
1380
|
+
const filtered = Array.from(input).filter(char => {
|
|
1381
|
+
const code = char.charCodeAt(0);
|
|
1382
|
+
// Allow printable ASCII (space to ~)
|
|
1383
|
+
if (code >= 0x20 && code <= 0x7E) return true;
|
|
1384
|
+
// Allow Unicode characters (includes CJK)
|
|
1385
|
+
if (code > 0x7F && code < 0x9F) return false; // Filter C1 control codes
|
|
1386
|
+
if (code >= 0x9F) return true; // Allow all high Unicode including CJK
|
|
1387
|
+
// Filter other control characters
|
|
1388
|
+
return false;
|
|
1389
|
+
}).join('');
|
|
1390
|
+
|
|
1391
|
+
if (!filtered) return; // No printable characters, skip
|
|
1392
|
+
|
|
1393
|
+
// Insert filtered characters at cursor position
|
|
1394
|
+
setState(s => {
|
|
1395
|
+
const cursorPos = s.cursorPosition || 0;
|
|
1396
|
+
const before = s.buffer.slice(0, cursorPos);
|
|
1397
|
+
const after = s.buffer.slice(cursorPos);
|
|
1398
|
+
const newBuffer = before + filtered + after;
|
|
1399
|
+
const newState = setBufferWithSlashMenu(s, newBuffer, checkTriggers);
|
|
1400
|
+
return { ...newState, cursorPosition: cursorPos + filtered.length };
|
|
1401
|
+
});
|
|
1402
|
+
}
|
|
1403
|
+
});
|
|
1404
|
+
|
|
1405
|
+
const { theme, themeName, setTheme, listThemes } = useTheme();
|
|
1406
|
+
|
|
1407
|
+
const hasUserOutput = state.outputs.some(item => !item.isWelcome);
|
|
1408
|
+
const showBanner = !hasUserOutput && state.viewMode === 'repl';
|
|
1409
|
+
|
|
1410
|
+
// Scrollable content (outputs, banner, etc.)
|
|
1411
|
+
const scrollableContent = h(React.Fragment, null,
|
|
1412
|
+
showBanner ? h(WelcomeBanner, null) : null,
|
|
1413
|
+
h(OutputArea, null,
|
|
1414
|
+
...visibleOutputs.map(item =>
|
|
1415
|
+
h(OutputItem, {
|
|
1416
|
+
key: item.id,
|
|
1417
|
+
item: item,
|
|
1418
|
+
prompt: item.prompt || state.shellPrompt,
|
|
1419
|
+
maxLines: 20,
|
|
1420
|
+
})
|
|
1421
|
+
)
|
|
1422
|
+
)
|
|
1423
|
+
// ExecutionStatus moved to fixed bottom area to prevent overlap
|
|
1424
|
+
);
|
|
1425
|
+
|
|
1426
|
+
// Fixed bottom UI (input, menu, console)
|
|
1427
|
+
const fixedBottomUI = h(React.Fragment, null,
|
|
1428
|
+
// Add empty line to separate output from ExecutionStatus
|
|
1429
|
+
h(Text, null, ''),
|
|
1430
|
+
h(Text, null, ''),
|
|
1431
|
+
// Separator line
|
|
1432
|
+
h(Box, { flexShrink: 0 },
|
|
1433
|
+
h(Text, { dimColor: true }, '─'.repeat(Math.min(80, terminalWidth)))
|
|
1434
|
+
),
|
|
1435
|
+
// ExecutionStatus moved here from scrollable area to prevent overlap
|
|
1436
|
+
h(ExecutionStatus, {
|
|
1437
|
+
isExecuting: state.isExecuting,
|
|
1438
|
+
command: state.executingCommand,
|
|
1439
|
+
lastDuration: state.lastExecutionDuration,
|
|
1440
|
+
executionStartTime: state.executionStartTime,
|
|
1441
|
+
}),
|
|
1442
|
+
h(InputArea, {
|
|
1443
|
+
prompt: state.shellPrompt,
|
|
1444
|
+
buffer: state.buffer,
|
|
1445
|
+
cursorPosition: state.cursorPosition,
|
|
1446
|
+
showCursor: !state.isExecuting,
|
|
1447
|
+
menuVisible: state.menuVisible,
|
|
1448
|
+
menuType: state.menuType,
|
|
1449
|
+
}),
|
|
1450
|
+
state.menuVisible && !state.isExecuting
|
|
1451
|
+
? h(DropdownMenu, {
|
|
1452
|
+
items: state.menuItems,
|
|
1453
|
+
selectedIndex: state.selectedIndex,
|
|
1454
|
+
})
|
|
1455
|
+
: null,
|
|
1456
|
+
h(Console, {
|
|
1457
|
+
visible: state.consoleVisible,
|
|
1458
|
+
logs: state.consoleLogs,
|
|
1459
|
+
height: 8,
|
|
1460
|
+
scrollOffset: state.consoleScrollOffset,
|
|
1461
|
+
selectedIndex: state.consoleSelectedIndex,
|
|
1462
|
+
selection: state.selection,
|
|
1463
|
+
})
|
|
1464
|
+
);
|
|
1465
|
+
|
|
1466
|
+
const replContent = h(React.Fragment, null,
|
|
1467
|
+
// Scrollable area
|
|
1468
|
+
h(Box, {
|
|
1469
|
+
flexDirection: 'column',
|
|
1470
|
+
flexGrow: 1,
|
|
1471
|
+
minHeight: 4,
|
|
1472
|
+
},
|
|
1473
|
+
scrollableContent
|
|
1474
|
+
),
|
|
1475
|
+
// Fixed bottom area
|
|
1476
|
+
h(Box, { flexDirection: 'column', flexShrink: 0 },
|
|
1477
|
+
fixedBottomUI
|
|
1478
|
+
)
|
|
1479
|
+
);
|
|
1480
|
+
|
|
1481
|
+
const body = state.viewMode === 'detail' && state.detailContent
|
|
1482
|
+
? h(DetailView, {
|
|
1483
|
+
title: state.detailContent.title,
|
|
1484
|
+
content: state.detailContent.content,
|
|
1485
|
+
scrollOffset: state.detailContent.scrollOffset,
|
|
1486
|
+
availableHeight: terminalHeight - 3,
|
|
1487
|
+
selection: state.selection?.source === 'detail' ? state.selection : null,
|
|
1488
|
+
paginatedFile: state.paginatedFile,
|
|
1489
|
+
})
|
|
1490
|
+
: replContent;
|
|
1491
|
+
|
|
1492
|
+
return h(ShellLayout, null,
|
|
1493
|
+
body,
|
|
1494
|
+
h(StatusBar, {
|
|
1495
|
+
sandboxId,
|
|
1496
|
+
hostIp,
|
|
1497
|
+
resources: state.resources,
|
|
1498
|
+
mode: state.viewMode,
|
|
1499
|
+
menuVisible: state.menuVisible,
|
|
1500
|
+
isExecuting: state.isExecuting,
|
|
1501
|
+
consoleVisible: state.consoleVisible,
|
|
1502
|
+
exitPending: state.exitPending,
|
|
1503
|
+
hasSelection: !!state.selection,
|
|
1504
|
+
mouseCaptureEnabled: state.mouseCaptureEnabled !== false,
|
|
1505
|
+
})
|
|
1506
|
+
);
|
|
1507
|
+
}
|