rl-rockcli 0.0.9 → 0.0.10

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