rl-rockcli 0.0.8 → 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 (162) 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/commands/log/core/constants.js +237 -0
  90. package/commands/log/core/display.js +370 -0
  91. package/commands/log/core/search.js +330 -0
  92. package/commands/log/core/tail.js +216 -0
  93. package/commands/log/core/utils.js +424 -0
  94. package/commands/log.js +298 -0
  95. package/commands/sandbox/core/log-bridge.js +119 -0
  96. package/commands/sandbox/core/replay/analyzer.js +311 -0
  97. package/commands/sandbox/core/replay/batch-orchestrator.js +536 -0
  98. package/commands/sandbox/core/replay/batch-task.js +369 -0
  99. package/commands/sandbox/core/replay/concurrent-display.js +70 -0
  100. package/commands/sandbox/core/replay/concurrent-orchestrator.js +170 -0
  101. package/commands/sandbox/core/replay/data-source.js +86 -0
  102. package/commands/sandbox/core/replay/display.js +231 -0
  103. package/commands/sandbox/core/replay/executor.js +634 -0
  104. package/commands/sandbox/core/replay/history-fetcher.js +124 -0
  105. package/commands/sandbox/core/replay/index.js +338 -0
  106. package/commands/sandbox/core/replay/loghouse-data-source.js +177 -0
  107. package/commands/sandbox/core/replay/pid-mapping.js +26 -0
  108. package/commands/sandbox/core/replay/request.js +109 -0
  109. package/commands/sandbox/core/replay/worker.js +166 -0
  110. package/commands/sandbox/core/session.js +346 -0
  111. package/commands/sandbox/log-bridge.js +2 -0
  112. package/commands/sandbox/ray.js +2 -0
  113. package/commands/sandbox/replay/analyzer.js +311 -0
  114. package/commands/sandbox/replay/batch-orchestrator.js +536 -0
  115. package/commands/sandbox/replay/batch-task.js +369 -0
  116. package/commands/sandbox/replay/concurrent-display.js +70 -0
  117. package/commands/sandbox/replay/concurrent-orchestrator.js +170 -0
  118. package/commands/sandbox/replay/display.js +231 -0
  119. package/commands/sandbox/replay/executor.js +634 -0
  120. package/commands/sandbox/replay/history-fetcher.js +118 -0
  121. package/commands/sandbox/replay/index.js +338 -0
  122. package/commands/sandbox/replay/pid-mapping.js +26 -0
  123. package/commands/sandbox/replay/request.js +109 -0
  124. package/commands/sandbox/replay/worker.js +166 -0
  125. package/commands/sandbox/replay.js +2 -0
  126. package/commands/sandbox/session.js +2 -0
  127. package/commands/sandbox-original.js +1393 -0
  128. package/commands/sandbox.js +499 -0
  129. package/help/help.json +1071 -0
  130. package/help/middleware.js +71 -0
  131. package/help/renderer.js +800 -0
  132. package/index.js +5 -15
  133. package/lib/plugin-context.js +40 -0
  134. package/package.json +2 -2
  135. package/sdks/sandbox/core/client.js +845 -0
  136. package/sdks/sandbox/core/config.js +70 -0
  137. package/sdks/sandbox/core/types.js +74 -0
  138. package/sdks/sandbox/httpLogger.js +251 -0
  139. package/sdks/sandbox/index.js +9 -0
  140. package/utils/asciiArt.js +138 -0
  141. package/utils/bun-compat.js +59 -0
  142. package/utils/ciPipelines.js +138 -0
  143. package/utils/cli.js +17 -0
  144. package/utils/command-router.js +79 -0
  145. package/utils/configManager.js +503 -0
  146. package/utils/dependency-resolver.js +135 -0
  147. package/utils/eagleeye_traceid.js +151 -0
  148. package/utils/envDetector.js +78 -0
  149. package/utils/execution_logger.js +415 -0
  150. package/utils/featureManager.js +68 -0
  151. package/utils/firstTimeTip.js +44 -0
  152. package/utils/hook-manager.js +125 -0
  153. package/utils/http-logger.js +264 -0
  154. package/utils/i18n.js +139 -0
  155. package/utils/image-progress.js +159 -0
  156. package/utils/logger.js +154 -0
  157. package/utils/plugin-loader.js +124 -0
  158. package/utils/plugin-manager.js +348 -0
  159. package/utils/ray_cli_wrapper.js +746 -0
  160. package/utils/sandbox-client.js +419 -0
  161. package/utils/terminal.js +32 -0
  162. package/utils/tips.js +106 -0
@@ -0,0 +1,891 @@
1
+ import { Show, createSignal, createEffect, on, onCleanup, onMount } from 'solid-js';
2
+ import { useKeyboard, useRenderer } from '@opentui/solid';
3
+ import { ThemeProvider, useTheme } from './contexts/ThemeContext.tsx';
4
+ import { SessionProvider, type SessionInfo } from './contexts/SessionContext.tsx';
5
+ import { ReplProvider, useRepl } from './contexts/ReplContext.tsx';
6
+ import { ToastProvider, useToast } from './contexts/ToastContext.tsx';
7
+ import { Header } from './components/Header.tsx';
8
+ import { OutputArea } from './components/OutputArea.tsx';
9
+ import { ExecutionStatus } from './components/ExecutionStatus.tsx';
10
+ import { PromptInput } from './components/PromptInput.tsx';
11
+ import { StatusBar } from './components/StatusBar.tsx';
12
+ import { DropdownMenu } from './components/DropdownMenu.tsx';
13
+ import { DetailView } from './components/DetailView.tsx';
14
+ import { Console } from './components/Console.tsx';
15
+ import { Toast } from './components/Toast.tsx';
16
+ import { ConnectingScreen } from './components/ConnectingScreen.tsx';
17
+ import { useResources } from './hooks/useResources.ts';
18
+ import { executeBuiltinCommand, isBuiltinCommand, checkInteractiveCommand } from './builtinCommands.ts';
19
+ import {
20
+ parseAtContext,
21
+ getLocalCompletions,
22
+ getRemoteCompletions,
23
+ replaceAtPath,
24
+ } from '../ink-repl/utils/atCompletion.js';
25
+ import { parseExecutionError, friendlyErrorMessage } from '../ink-repl/utils/parseError.js';
26
+ import { formatCommandOutput } from '../ink-repl/utils/responseFormatter.js';
27
+
28
+ // Flag to prevent handleInputChange from resetting historyIndex during navigation
29
+ let _navigatingHistory = false;
30
+
31
+ function ReplShell(props: { onExit?: () => void }) {
32
+ const theme = useTheme();
33
+ const [state, setState, { nextId, addLog }] = useRepl();
34
+ const session = useSession();
35
+ const renderer = useRenderer();
36
+ const toast = useToast();
37
+
38
+ // Connection is already established by repl.js before starting OpenTUI
39
+ // Skip loading state and show UI immediately
40
+ const [ready] = createSignal(true);
41
+
42
+ // Wire up console copy-to-clipboard via opentui's onCopySelection callback
43
+ // This handles copy-selection action triggered by Ctrl+Y key binding within the Console
44
+ renderer.console.onCopySelection = (text: string) => {
45
+ if (!text || text.length === 0) return;
46
+ copyToClipboard(text);
47
+ toast.show({ variant: 'success', message: 'Console log copied to clipboard' });
48
+ renderer.clearSelection();
49
+ };
50
+
51
+ // Store ref to input for focus management
52
+ let inputRef: any = null;
53
+
54
+ // Auto-copy on mouse selection release
55
+ // When user selects text with mouse and releases button, auto-copy to clipboard.
56
+ // This is needed because consoleOptions.onCopySelection only works for the
57
+ // built-in Console component, not the main app content area.
58
+ function handleMouseUp() {
59
+ const selection = renderer.getSelection();
60
+ if (selection) {
61
+ const text = selection.getSelectedText();
62
+ if (text && text.length > 0) {
63
+ copyToClipboard(text);
64
+ toast.show({ variant: 'success', message: 'Selection copied to clipboard' });
65
+ renderer.clearSelection();
66
+ }
67
+ }
68
+ // Restore focus to input after any mouse interaction
69
+ setTimeout(() => {
70
+ if (inputRef && state.viewMode === 'repl' && !state.consoleVisible && inputRef.focus) {
71
+ inputRef.focus();
72
+ }
73
+ }, 50);
74
+ }
75
+
76
+ // Stats tracking
77
+ const stats = { startTime: Date.now(), shellCommands: 0, builtinCommands: 0 };
78
+
79
+ // AbortController for cancelling execution
80
+ let abortController: AbortController | null = null;
81
+
82
+ // Track last save time for auto-save
83
+ let lastSaveTime = 0;
84
+
85
+ // Track @ completion timeout
86
+ let atCompletionTimeout: ReturnType<typeof setTimeout> | null = null;
87
+
88
+ // Trigger @ completion for /upload command
89
+ async function triggerAtCompletion(atContext: any) {
90
+ if (!atContext) return;
91
+
92
+ try {
93
+ let items;
94
+ if (atContext.isLocal) {
95
+ // First @: local file completion
96
+ items = getLocalCompletions(atContext.partialPath, process.cwd());
97
+ } else {
98
+ // Second @: remote file completion
99
+ items = await getRemoteCompletions(session.sessionManager, atContext.partialPath);
100
+ }
101
+
102
+ if (items.length > 0) {
103
+ setState('menuItems', items);
104
+ setState('menuVisible', true);
105
+ setState('selectedIndex', 0);
106
+ setState('menuType', 'at');
107
+ setState('atContext', atContext);
108
+ } else if (state.menuType === 'at') {
109
+ setState('menuVisible', false);
110
+ setState('menuItems', []);
111
+ setState('menuType', null);
112
+ setState('atContext', null);
113
+ }
114
+ } catch {
115
+ // Silently ignore completion errors
116
+ }
117
+ }
118
+
119
+ // Watch buffer changes for @ completion
120
+ createEffect(on(() => state.buffer, (buffer) => {
121
+ if (atCompletionTimeout) {
122
+ clearTimeout(atCompletionTimeout);
123
+ atCompletionTimeout = null;
124
+ }
125
+
126
+ if (state.isExecuting) return;
127
+
128
+ // Handle /upload @ completion
129
+ if (buffer.startsWith('/upload')) {
130
+ const atContext = parseAtContext(buffer);
131
+ if (atContext) {
132
+ atCompletionTimeout = setTimeout(() => {
133
+ triggerAtCompletion(atContext);
134
+ }, 100);
135
+ }
136
+ return;
137
+ }
138
+
139
+ // Handle /download @ completion
140
+ if (buffer.startsWith('/download')) {
141
+ const atContext = parseAtContext(buffer);
142
+ if (atContext) {
143
+ atCompletionTimeout = setTimeout(() => {
144
+ triggerAtCompletion(atContext);
145
+ }, 100);
146
+ }
147
+ return;
148
+ }
149
+
150
+ // Clear menu if not in upload/download command
151
+ if (state.menuType === 'at') {
152
+ setState('menuVisible', false);
153
+ setState('menuItems', []);
154
+ setState('menuType', null);
155
+ setState('atContext', null);
156
+ }
157
+ }));
158
+
159
+ // Start resource monitoring
160
+ useResources();
161
+
162
+ // Load history on startup - use onMount to ensure it runs after component is initialized
163
+ onMount(() => {
164
+ loadHistory();
165
+ });
166
+
167
+ // Auto-save outputs periodically (every 5 seconds)
168
+ const autoSaveTimer = setInterval(() => {
169
+ if (state.outputs.length > 1 && Date.now() - lastSaveTime > 5000) {
170
+ saveOutputs();
171
+ }
172
+ }, 5000);
173
+
174
+ onCleanup(() => {
175
+ clearInterval(autoSaveTimer);
176
+ saveOutputs();
177
+ });
178
+
179
+ async function loadHistory() {
180
+ const { historyManager } = session;
181
+ if (!historyManager) return;
182
+
183
+ try {
184
+ const [outputs, history] = await Promise.all([
185
+ typeof historyManager.loadOutputs === 'function'
186
+ ? Promise.resolve(historyManager.loadOutputs()).catch(() => [])
187
+ : Promise.resolve([]),
188
+ typeof historyManager.getHistory === 'function'
189
+ ? Promise.resolve(historyManager.getHistory()).catch(() => [])
190
+ : Promise.resolve([]),
191
+ ]);
192
+
193
+ const restoredOutputs = Array.isArray(outputs) ? outputs : [];
194
+ const restoredCommands = Array.isArray(history) ? history : [];
195
+
196
+ if (restoredOutputs.length > 0 || restoredCommands.length > 0) {
197
+ // Update welcome message to show restored count
198
+ setState('outputs', 0, 'output',
199
+ `Connected! Type / for commands, double Ctrl+C to exit.\n✓ Restored ${restoredOutputs.length} output(s), ${restoredCommands.length} command(s) from history.`
200
+ );
201
+
202
+ if (restoredOutputs.length > 0) {
203
+ setState('outputs', (prev) => [...prev, ...restoredOutputs]);
204
+ }
205
+ if (restoredCommands.length > 0) {
206
+ setState('commandHistory', restoredCommands);
207
+ }
208
+ }
209
+ } catch {
210
+ // Ignore history loading errors
211
+ }
212
+ }
213
+
214
+ function saveOutputs() {
215
+ const { historyManager } = session;
216
+ if (historyManager && typeof historyManager.saveOutputs === 'function') {
217
+ try {
218
+ historyManager.saveOutputs(state.outputs);
219
+ lastSaveTime = Date.now();
220
+ } catch {
221
+ // Ignore history saving errors
222
+ }
223
+ }
224
+ }
225
+
226
+ /**
227
+ * Cancel the currently executing command and recreate the session.
228
+ * This is needed because aborting the HTTP request does NOT kill the
229
+ * server-side process — the old session remains blocked.
230
+ * Creating a new session unblocks command execution immediately.
231
+ */
232
+ async function cancelExecution() {
233
+ // 1. Abort the client-side HTTP request
234
+ if (abortController) {
235
+ abortController.abort();
236
+ }
237
+
238
+ // 2. Update the output to show "Cancelled"
239
+ const outputId = state.outputs[state.outputs.length - 1]?.id;
240
+ if (outputId) {
241
+ setState('outputs', (items) =>
242
+ items.map((item) =>
243
+ item.id === outputId
244
+ ? { ...item, output: 'Cancelled by user', exitCode: 130 }
245
+ : item
246
+ )
247
+ );
248
+ }
249
+ setState('isExecuting', false);
250
+ setState('executingCommand', '');
251
+
252
+ // 3. Recreate the session to unblock it from the cancelled command
253
+ const cwdMatch = state.shellPrompt.match(/:([^#]+)#/);
254
+ const cwd = cwdMatch ? cwdMatch[1] : '/';
255
+ try {
256
+ await session.sessionManager.interruptSession(cwd);
257
+ addLog('info', 'Session recreated after cancel');
258
+ } catch (err: any) {
259
+ addLog('warn', `Session recreate failed: ${err.message}`);
260
+ }
261
+ }
262
+
263
+ // Global keyboard handling
264
+ useKeyboard(async (e) => {
265
+ // Detail view: Escape goes back
266
+ if (state.viewMode === 'detail') {
267
+ if (e.name === 'escape') {
268
+ setState('viewMode', 'repl');
269
+ setState('detailContent', null);
270
+ e.preventDefault();
271
+ }
272
+ return;
273
+ }
274
+
275
+ // Menu navigation
276
+ if (state.menuVisible) {
277
+ if (e.name === 'up') {
278
+ setState('selectedIndex', Math.max(0, state.selectedIndex - 1));
279
+ e.preventDefault();
280
+ return;
281
+ }
282
+ if (e.name === 'down') {
283
+ setState('selectedIndex', Math.min(state.menuItems.length - 1, state.selectedIndex + 1));
284
+ e.preventDefault();
285
+ return;
286
+ }
287
+ if (e.name === 'return') {
288
+ // #8: Enter on slash menu directly executes the command
289
+ // For @ / download completion, Enter should fill the selection like Tab
290
+ const selected = state.menuItems[state.selectedIndex];
291
+ if (selected) {
292
+ if (state.menuType === 'at' && state.atContext) {
293
+ // For @ completion, replace the @path part
294
+ const newBuffer = replaceAtPath(state.buffer, state.atContext, selected.value);
295
+ setState('buffer', newBuffer);
296
+ setState('menuVisible', false);
297
+ setState('menuItems', []);
298
+ setState('menuType', null);
299
+ setState('atContext', null);
300
+ } else {
301
+ // For slash menu, execute the command
302
+ setState('menuVisible', false);
303
+ setState('menuItems', []);
304
+ setState('menuType', null);
305
+ setState('buffer', '');
306
+ handleCommand(selected.value);
307
+ }
308
+ }
309
+ e.preventDefault();
310
+ return;
311
+ }
312
+ if (e.name === 'tab') {
313
+ const selected = state.menuItems[state.selectedIndex];
314
+ if (selected) {
315
+ if (state.menuType === 'at' && state.atContext) {
316
+ // For @ completion, replace only the @path part
317
+ const newBuffer = replaceAtPath(state.buffer, state.atContext, selected.value);
318
+ setState('buffer', newBuffer);
319
+ } else {
320
+ // For slash/path completions, replace entire buffer
321
+ setState('buffer', selected.value + ' ');
322
+ }
323
+ setState('menuVisible', false);
324
+ setState('menuItems', []);
325
+ setState('menuType', null);
326
+ setState('atContext', null);
327
+ }
328
+ e.preventDefault();
329
+ return;
330
+ }
331
+ if (e.name === 'escape') {
332
+ setState('menuVisible', false);
333
+ setState('menuItems', []);
334
+ setState('menuType', null);
335
+ setState('atContext', null);
336
+ e.preventDefault();
337
+ return;
338
+ }
339
+ }
340
+
341
+ // Escape during execution: cancel (#3)
342
+ if (state.isExecuting && e.name === 'escape') {
343
+ await cancelExecution();
344
+ e.preventDefault();
345
+ return;
346
+ }
347
+
348
+ // Ctrl+C: exit handling
349
+ if (e.name === 'c' && e.ctrl) {
350
+ if (state.isExecuting) {
351
+ await cancelExecution();
352
+ return;
353
+ }
354
+ if (state.exitPending) {
355
+ // Second Ctrl+C -> exit
356
+ saveOutputs();
357
+ // Destroy renderer to exit alternate screen
358
+ renderer.destroy();
359
+ // Wait a bit for renderer to fully destroy before calling onExit
360
+ setTimeout(() => {
361
+ // Notify render.tsx that we're done (triggers Promise resolve)
362
+ if (props.onExit) {
363
+ props.onExit();
364
+ }
365
+ }, 100);
366
+ return;
367
+ }
368
+ // #9: First Ctrl+C clears buffer
369
+ setState('buffer', '');
370
+ setState('exitPending', true);
371
+ setTimeout(() => setState('exitPending', false), 2000);
372
+ return;
373
+ }
374
+
375
+ // Ctrl+V: show detail view of last output
376
+ if (e.name === 'v' && e.ctrl) {
377
+ const lastOutput = state.outputs.filter((o) => !o.isWelcome);
378
+ if (lastOutput.length > 0) {
379
+ const last = lastOutput[lastOutput.length - 1];
380
+ setState('detailContent', `$ ${last.command}\n\n${last.output}`);
381
+ setState('viewMode', 'detail');
382
+ }
383
+ e.preventDefault();
384
+ return;
385
+ }
386
+
387
+ // Ctrl+Y: copy selected text or last output to clipboard
388
+ if (e.name === 'y' && e.ctrl) {
389
+ // First check if there's a mouse selection
390
+ const selection = renderer.getSelection();
391
+ if (selection) {
392
+ const selectedText = selection.getSelectedText();
393
+ if (selectedText && selectedText.length > 0) {
394
+ copyToClipboard(selectedText);
395
+ toast.show({ variant: 'success', message: 'Selection copied to clipboard' });
396
+ renderer.clearSelection();
397
+ e.preventDefault();
398
+ return;
399
+ }
400
+ }
401
+ // Fallback: copy last output
402
+ const lastOutput = state.outputs.filter((o) => !o.isWelcome);
403
+ if (lastOutput.length > 0) {
404
+ const last = lastOutput[lastOutput.length - 1];
405
+ copyToClipboard(last.output);
406
+ toast.show({ variant: 'success', message: 'Last output copied to clipboard' });
407
+ }
408
+ e.preventDefault();
409
+ return;
410
+ }
411
+
412
+ // F12: toggle console
413
+ if (e.name === 'f12') {
414
+ const isVisible = !state.consoleVisible;
415
+ setState('consoleVisible', isVisible);
416
+ toast.show({
417
+ variant: 'info',
418
+ message: isVisible ? 'Console opened' : 'Console closed'
419
+ });
420
+ e.preventDefault();
421
+ return;
422
+ }
423
+
424
+ // Tab: path completion (#6)
425
+ if (e.name === 'tab' && !state.isExecuting && !state.menuVisible) {
426
+ handleTabCompletion();
427
+ e.preventDefault();
428
+ return;
429
+ }
430
+
431
+ // History navigation (only when not executing and menu not visible)
432
+ if (!state.isExecuting && !state.menuVisible) {
433
+ if (e.name === 'up') {
434
+ navigateHistory('up');
435
+ e.preventDefault();
436
+ return;
437
+ }
438
+ if (e.name === 'down') {
439
+ navigateHistory('down');
440
+ e.preventDefault();
441
+ return;
442
+ }
443
+ }
444
+
445
+ // Any other key resets exit pending
446
+ if (state.exitPending && e.name !== 'c') {
447
+ setState('exitPending', false);
448
+ }
449
+ });
450
+
451
+ // #6: Tab path completion
452
+ async function handleTabCompletion() {
453
+ const input = state.buffer;
454
+ if (!input || !input.trim()) return;
455
+
456
+ try {
457
+ const { getPathCompletions } = await import('../ink-repl/utils/pathCompletion.js');
458
+ const completions = await getPathCompletions(session.sessionManager, input);
459
+
460
+ if (completions.length === 0) return;
461
+
462
+ if (completions.length === 1) {
463
+ // Single completion: auto-fill
464
+ setState('buffer', completions[0].value);
465
+ } else {
466
+ // Multiple completions: show in dropdown menu
467
+ setState('menuItems', completions);
468
+ setState('menuVisible', true);
469
+ setState('selectedIndex', 0);
470
+ }
471
+ } catch {
472
+ // Silently fail
473
+ }
474
+ }
475
+
476
+ function navigateHistory(direction: 'up' | 'down') {
477
+ const history = state.commandHistory;
478
+ if (history.length === 0) return;
479
+
480
+ _navigatingHistory = true;
481
+
482
+ if (direction === 'up') {
483
+ if (state.historyIndex === -1) {
484
+ setState('savedBuffer', state.buffer);
485
+ }
486
+ const newIndex = Math.min(state.historyIndex + 1, history.length - 1);
487
+ setState('historyIndex', newIndex);
488
+ setState('buffer', history[newIndex]);
489
+ } else {
490
+ if (state.historyIndex <= 0) {
491
+ setState('historyIndex', -1);
492
+ setState('buffer', state.savedBuffer);
493
+ } else {
494
+ const newIndex = state.historyIndex - 1;
495
+ setState('historyIndex', newIndex);
496
+ setState('buffer', history[newIndex]);
497
+ }
498
+ }
499
+
500
+ queueMicrotask(() => { _navigatingHistory = false; });
501
+ }
502
+
503
+ async function handleCommand(input: string) {
504
+ const trimmed = input.trim();
505
+ if (!trimmed) return;
506
+
507
+ // Reset state
508
+ setState('exitPending', false);
509
+ setState('historyIndex', -1);
510
+ setState('savedBuffer', '');
511
+ setState('commandHistory', (prev) => [trimmed, ...prev]);
512
+ setState('buffer', '');
513
+ setState('menuVisible', false);
514
+ setState('menuItems', []);
515
+
516
+ // Check if it's a builtin command
517
+ if (await isBuiltinCommand(trimmed)) {
518
+ stats.builtinCommands++;
519
+ const outputId = nextId();
520
+ setState('outputs', (prev) => [
521
+ ...prev,
522
+ {
523
+ id: outputId,
524
+ command: trimmed,
525
+ output: '',
526
+ exitCode: null,
527
+ timestamp: Date.now(),
528
+ prompt: state.shellPrompt,
529
+ },
530
+ ]);
531
+
532
+ try {
533
+ const lastOutputs = state.outputs.filter((o) => !o.isWelcome);
534
+ const lastItem = lastOutputs.length > 0 ? lastOutputs[lastOutputs.length - 1] : null;
535
+
536
+ const ctx = {
537
+ client: session.client,
538
+ sessionManager: session.sessionManager,
539
+ historyManager: session.historyManager,
540
+ sandboxId: session.sandboxId,
541
+ version: session.version,
542
+ stats,
543
+ lastCommand: lastItem?.command || '',
544
+ lastOutput: lastItem?.output || '',
545
+ terminalWidth: process.stdout.columns || 120,
546
+ };
547
+
548
+ const result = await executeBuiltinCommand(trimmed, ctx);
549
+
550
+ if (result.action === 'exit') {
551
+ setState('outputs', (items) =>
552
+ items.map((item) =>
553
+ item.id === outputId
554
+ ? { ...item, output: result.output || 'Exiting...', exitCode: result.exitCode ?? 0 }
555
+ : item
556
+ )
557
+ );
558
+ saveOutputs();
559
+ // Destroy renderer to exit alternate screen
560
+ renderer.destroy();
561
+ // Wait a bit for renderer to fully destroy before calling onExit
562
+ setTimeout(() => {
563
+ // Notify render.tsx that we're done (triggers Promise resolve)
564
+ if (props.onExit) {
565
+ props.onExit();
566
+ }
567
+ }, 100);
568
+ return;
569
+ }
570
+
571
+ if (result.action === 'clear') {
572
+ setState('outputs', (prev) => prev.filter((o) => o.isWelcome));
573
+ return;
574
+ }
575
+
576
+ if (result.action === 'retry' && result.command) {
577
+ setState('outputs', (items) => items.filter((item) => item.id !== outputId));
578
+ handleCommand(result.command);
579
+ return;
580
+ }
581
+
582
+ setState('outputs', (items) =>
583
+ items.map((item) =>
584
+ item.id === outputId
585
+ ? { ...item, output: result.output || '', exitCode: result.exitCode ?? 0 }
586
+ : item
587
+ )
588
+ );
589
+ addLog('info', `Builtin command: ${trimmed.split(' ')[0]}`);
590
+
591
+ // Save builtin command to history manager
592
+ if (session.historyManager) {
593
+ try {
594
+ await session.historyManager.addCommand(trimmed);
595
+ } catch {
596
+ // Ignore history save errors
597
+ }
598
+ }
599
+ } catch (err: any) {
600
+ const friendlyMsg = friendlyErrorMessage(err.message || String(err));
601
+ setState('outputs', (items) =>
602
+ items.map((item) =>
603
+ item.id === outputId
604
+ ? { ...item, output: friendlyMsg, exitCode: 1 }
605
+ : item
606
+ )
607
+ );
608
+ addLog('error', `Builtin error: ${err.message || String(err)}`);
609
+ }
610
+ return;
611
+ }
612
+
613
+ // #2: Check for interactive commands before executing
614
+ try {
615
+ const interactiveCheck = await checkInteractiveCommand(trimmed);
616
+ if (interactiveCheck.isInteractive) {
617
+ const outputId = nextId();
618
+ const altMsg = interactiveCheck.alternative
619
+ ? `\n${interactiveCheck.alternative}`
620
+ : '';
621
+ setState('outputs', (prev) => [
622
+ ...prev,
623
+ {
624
+ id: outputId,
625
+ command: trimmed,
626
+ output: `'${interactiveCheck.cmdName}' is an interactive command and cannot run in REPL mode.${altMsg}`,
627
+ exitCode: 1,
628
+ timestamp: Date.now(),
629
+ prompt: state.shellPrompt,
630
+ },
631
+ ]);
632
+ return;
633
+ }
634
+ } catch {
635
+ // If check fails, proceed with execution
636
+ }
637
+
638
+ // Shell command
639
+ stats.shellCommands++;
640
+ const outputId = nextId();
641
+ setState('outputs', (prev) => [
642
+ ...prev,
643
+ {
644
+ id: outputId,
645
+ command: trimmed,
646
+ output: '',
647
+ exitCode: null,
648
+ timestamp: Date.now(),
649
+ prompt: state.shellPrompt,
650
+ },
651
+ ]);
652
+
653
+ setState('isExecuting', true);
654
+ setState('executingCommand', trimmed);
655
+ setState('executionStartTime', Date.now());
656
+
657
+ // #3: Create AbortController for this execution
658
+ abortController = new AbortController();
659
+ const currentAbort = abortController;
660
+
661
+ try {
662
+ const result = await session.sessionManager.execute(trimmed, { signal: currentAbort.signal });
663
+
664
+ // Check if cancelled during execution
665
+ if (currentAbort.signal.aborted) return;
666
+
667
+ // Format command output using responseFormatter
668
+ const formatted = formatCommandOutput(result ?? { exit_code: 0, output: '' }, { locale: 'zh' });
669
+ let output = formatted.output ?? '';
670
+ // #1: Fix exit_code field name (SDK returns snake_case)
671
+ const exitCode = formatted.exitCode ?? result?.exit_code ?? 0;
672
+
673
+ // Handle abnormal exit codes with helpful messages
674
+ if (exitCode === -1) {
675
+ // -1 typically indicates process was killed or internal error
676
+ const hint = output ? '' : 'Command terminated abnormally (exit code -1). This may indicate a timeout, signal termination, or internal error.';
677
+ output = output || hint;
678
+ addLog('error', `Command terminated abnormally (exit code -1)`);
679
+ } else if (exitCode === 137) {
680
+ // 128 + 9 (SIGKILL) - often OOM killed
681
+ output += output ? '\n' : '';
682
+ output += '[Process killed (exit code 137). Possibly OOM or manually terminated.]';
683
+ addLog('warn', `Process killed (exit code 137)`);
684
+ } else if (exitCode === 143) {
685
+ // 128 + 15 (SIGTERM) - graceful termination
686
+ output += output ? '\n' : '';
687
+ output += '[Process terminated (exit code 143). Received SIGTERM.]';
688
+ addLog('warn', `Process terminated (exit code 143)`);
689
+ }
690
+
691
+ setState('outputs', (items) =>
692
+ items.map((item) =>
693
+ item.id === outputId ? { ...item, output, exitCode, metaInfo: formatted.metaInfo, tips: formatted.tips } : item
694
+ )
695
+ );
696
+
697
+ // Log execution result
698
+ if (exitCode === 0) {
699
+ addLog('info', `Command completed: ${trimmed.slice(0, 30)}${trimmed.length > 30 ? '...' : ''}`);
700
+ } else if (exitCode > 0) {
701
+ addLog('warn', `Command exited with code ${exitCode}`);
702
+ }
703
+
704
+ // #7: Update CWD after every shell command
705
+ try {
706
+ const pwdResult = await session.sessionManager.execute('pwd');
707
+ if (pwdResult?.output) {
708
+ const cwd = pwdResult.output.trim();
709
+ const newPrompt = `${session.user}@${session.hostname}:${cwd}# `;
710
+ setState('shellPrompt', newPrompt);
711
+
712
+ // Update work_dir in history manager
713
+ if (session.historyManager && session.historyManager.updateWorkDir) {
714
+ await session.historyManager.updateWorkDir(cwd);
715
+ }
716
+ }
717
+ } catch {
718
+ // Ignore prompt update errors
719
+ }
720
+
721
+ // #5: Save command to history manager
722
+ if (session.historyManager) {
723
+ try {
724
+ await session.historyManager.addCommand(trimmed);
725
+ } catch {
726
+ // Ignore history save errors
727
+ }
728
+ }
729
+ } catch (err: any) {
730
+ if (currentAbort.signal.aborted) return;
731
+
732
+ // Parse error to extract meaningful output and exit code
733
+ const { output, exitCode } = parseExecutionError(err);
734
+
735
+ // Check if error message contains an actual exit code from command execution
736
+ // If not (timeout/network error), don't pass exit_code to formatCommandOutput
737
+ const hasExitCodeInMessage = /exit code \d+/.test(err.message || '');
738
+
739
+ // Format error output with metaInfo and tips
740
+ const errorResult = hasExitCodeInMessage ? { output, exit_code: exitCode } : { output };
741
+ const formatted = formatCommandOutput(errorResult, { locale: 'zh' });
742
+
743
+ setState('outputs', (items) =>
744
+ items.map((item) =>
745
+ item.id === outputId
746
+ ? { ...item, output: formatted.output || output, exitCode: formatted.exitCode, metaInfo: formatted.metaInfo, tips: formatted.tips }
747
+ : item
748
+ )
749
+ );
750
+ addLog('error', `Execution error: ${err.message || String(err)}`);
751
+ } finally {
752
+ // Only reset execution state if no new command has taken over
753
+ // (abortController changes when a new command starts)
754
+ const isCurrentExecution = abortController === currentAbort;
755
+ if (isCurrentExecution) {
756
+ abortController = null;
757
+ // Calculate execution duration
758
+ const startTime = state.executionStartTime;
759
+ if (startTime) {
760
+ setState('lastExecutionDuration', Date.now() - startTime);
761
+ }
762
+ setState('isExecuting', false);
763
+ setState('executingCommand', '');
764
+ setState('executionStartTime', null);
765
+ }
766
+ }
767
+ }
768
+
769
+ function handleInputChange(value: string) {
770
+ setState('buffer', value);
771
+
772
+ // #4: Reset historyIndex when typing after navigating history
773
+ if (state.historyIndex !== -1 && !_navigatingHistory) {
774
+ setState('historyIndex', -1);
775
+ }
776
+
777
+ // Slash command autocomplete
778
+ if (value === '/') {
779
+ const trigger = session.triggers[0];
780
+ if (trigger) {
781
+ const items = trigger.getItems('/');
782
+ setState('menuItems', items);
783
+ setState('menuVisible', true);
784
+ setState('selectedIndex', 0);
785
+ }
786
+ } else if (value.startsWith('/') && state.menuVisible) {
787
+ const trigger = session.triggers[0];
788
+ if (trigger) {
789
+ const items = trigger.getItems(value).filter(
790
+ (item: any) => item.value.toLowerCase().startsWith(value.toLowerCase())
791
+ );
792
+ setState('menuItems', items);
793
+ setState('selectedIndex', 0);
794
+ if (items.length === 0) {
795
+ setState('menuVisible', false);
796
+ }
797
+ }
798
+ } else if (!value.startsWith('/')) {
799
+ setState('menuVisible', false);
800
+ setState('menuItems', []);
801
+ }
802
+ }
803
+
804
+ return (
805
+ <box
806
+ width="100%"
807
+ height="100%"
808
+ flexDirection="column"
809
+ backgroundColor={theme.colors.background}
810
+ onMouseUp={handleMouseUp}
811
+ >
812
+ <Show when={ready()} fallback={<ConnectingScreen sandboxId={session.sandboxId} />}>
813
+ <Show when={state.viewMode === 'detail'}>
814
+ <DetailView />
815
+ </Show>
816
+ <Show when={state.viewMode === 'repl'}>
817
+ {/* Header */}
818
+ <Header />
819
+
820
+ {/* Scrollable content area */}
821
+ <box flexDirection="column" flexGrow={1} overflow="hidden" minHeight={0}>
822
+ <OutputArea />
823
+ <ExecutionStatus
824
+ isExecuting={state.isExecuting}
825
+ command={state.executingCommand}
826
+ lastDuration={state.lastExecutionDuration}
827
+ />
828
+ </box>
829
+
830
+ {/* Fixed bottom UI */}
831
+ <box flexDirection="column" flexShrink={0}>
832
+ <PromptInput onSubmit={handleCommand} onInputChange={handleInputChange} onInputRef={(ref) => { inputRef = ref; }} />
833
+ <DropdownMenu />
834
+ <Console />
835
+ </box>
836
+
837
+ <StatusBar />
838
+ <Toast />
839
+ </Show>
840
+ </Show>
841
+ </box>
842
+ );
843
+ }
844
+
845
+ function copyToClipboard(text: string) {
846
+ try {
847
+ const { execSync } = require('child_process');
848
+ if (process.platform === 'darwin') {
849
+ execSync('pbcopy', { input: text, stdio: ['pipe', 'pipe', 'pipe'] });
850
+ } else {
851
+ const linuxTools = [
852
+ 'xclip -selection clipboard',
853
+ 'xsel --clipboard --input',
854
+ 'wl-copy',
855
+ ];
856
+ for (const cmd of linuxTools) {
857
+ try {
858
+ execSync(cmd, { input: text, stdio: ['pipe', 'pipe', 'pipe'] });
859
+ break;
860
+ } catch {
861
+ // try next tool
862
+ }
863
+ }
864
+ }
865
+ } catch {
866
+ // Clipboard not available
867
+ }
868
+ }
869
+
870
+ // Re-import useSession here to avoid circular dependency
871
+ import { useSession } from './contexts/SessionContext.tsx';
872
+
873
+ export interface AppProps {
874
+ session: SessionInfo;
875
+ theme?: string;
876
+ onExit?: () => void;
877
+ }
878
+
879
+ export function App(props: AppProps) {
880
+ return (
881
+ <ThemeProvider theme={props.theme}>
882
+ <SessionProvider session={props.session}>
883
+ <ReplProvider shellPrompt={props.session.initialPrompt}>
884
+ <ToastProvider>
885
+ <ReplShell onExit={props.onExit} />
886
+ </ToastProvider>
887
+ </ReplProvider>
888
+ </SessionProvider>
889
+ </ThemeProvider>
890
+ );
891
+ }