rl-rockcli 0.0.9 → 0.0.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (90) 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/attach.js +186 -0
  90. package/package.json +1 -1
@@ -0,0 +1,791 @@
1
+ 'use strict';
2
+
3
+ const logger = require('../../utils/logger');
4
+ const i18n = require('../../utils/i18n');
5
+ const { t } = i18n;
6
+ const { CLI_NAME } = require('../../utils/cli');
7
+ const SessionManager = require('./session-manager');
8
+ const HistoryManager = require('./history-manager');
9
+ // ink-repl is ESM, use dynamic import
10
+ const configManager = require('../../utils/configManager');
11
+ const { SandboxClient, SandboxConfig } = require('../../utils/sandbox-client');
12
+ const { selectAttachUiMode, INK_MIN_NODE_MAJOR, isBunRuntime, findBunBinary, getBunVersion } = require('./ui-mode');
13
+ const { startBasicREPL } = require('./basic-repl');
14
+ const { ensureHistorySession } = require('./history-session');
15
+ const cleanupManager = require('./cleanup-manager');
16
+ const { getLogoLines } = require('../../utils/asciiArt');
17
+
18
+ const isOpenSource = process.env.ROCKCLI_MODE === 'opensource';
19
+ const DEFAULT_OPEN_SOURCE_BASE_URL = 'http://127.0.0.1:8080';
20
+
21
+ /**
22
+ * List of all builtin commands available in the REPL
23
+ */
24
+ const BUILTIN_COMMANDS = [
25
+ { name: 'log', description: 'View sandbox logs' },
26
+ { name: 'upload', description: 'Upload file to sandbox' },
27
+ { name: 'download', description: 'Download file from sandbox' },
28
+ { name: 'status', description: 'Show sandbox status' },
29
+ { name: 'stop', description: 'Stop the sandbox' },
30
+ { name: 'sessions', description: 'List attach sessions' },
31
+ // { name: 'resume', description: 'Restore previous session' }, // Disabled
32
+ { name: 'clear', description: 'Clear terminal screen' },
33
+ { name: 'stats', description: 'Show session statistics' },
34
+ { name: 'copy', description: 'Copy output of last command to clipboard' },
35
+ { name: 'about', description: 'Show about information' },
36
+ { name: 'docs', description: 'Open documentation' },
37
+ { name: 'bug', description: 'Report a bug' },
38
+ { name: 'help', description: 'Show help message' },
39
+ { name: 'exit', description: 'Exit the REPL (session kept alive)' },
40
+ { name: 'close', description: 'Close session permanently and exit' },
41
+ { name: 'retry', description: 'Retry last failed command' },
42
+ { name: 'cleanup-history', description: 'Clean up history sessions. Usage: /cleanup-history [--sandbox <id>] [--all]' },
43
+ ];
44
+
45
+ /**
46
+ * Get ASCII art logo from unified source
47
+ */
48
+ function getAsciiLogo() {
49
+ return getLogoLines();
50
+ }
51
+
52
+ /**
53
+ * SandboxREPL - Interactive REPL for sandbox interaction
54
+ */
55
+ class SandboxREPL {
56
+ /**
57
+ * Create a new SandboxREPL instance
58
+ * @param {string} sandboxId - The sandbox ID to connect to
59
+ * @param {Object} argv - Command line arguments
60
+ */
61
+ constructor(sandboxId, argv = {}) {
62
+ this.sandboxId = sandboxId;
63
+ this.argv = argv;
64
+ this.sessionManager = null;
65
+ this.historyManager = null;
66
+ this.client = null;
67
+ this.startTime = null;
68
+ }
69
+
70
+ /**
71
+ * Start the REPL
72
+ */
73
+ async start() {
74
+ // Validate auth BEFORE any UI initialization
75
+ const apiKey = configManager.getApiKeyWithPriority(this.argv);
76
+ if (!apiKey) {
77
+ const err = new Error('API key required. Set ROCK_API_KEY env var or use --api-key.');
78
+ err.exitCode = 1;
79
+ throw err;
80
+ }
81
+
82
+ // Initialize client
83
+ const config = configManager.readConfig();
84
+ const sandboxConfig = config?.sandbox || {};
85
+ const envBaseUrl = process.env.ROCKCLI_BASE_URL || process.env.ROCK_BASE_URL;
86
+
87
+ const baseUrl =
88
+ this.argv.baseUrl ||
89
+ envBaseUrl ||
90
+ sandboxConfig.base_url ||
91
+ (isOpenSource ? DEFAULT_OPEN_SOURCE_BASE_URL : undefined);
92
+
93
+ const sdkConfig = new SandboxConfig({
94
+ xrlAuthorization: apiKey,
95
+ baseUrl,
96
+ cluster: configManager.getClusterWithPriority(this.argv),
97
+ userId: configManager.getUserIdWithPriority(this.argv),
98
+ experimentId: configManager.getExperimentIdWithPriority(this.argv),
99
+ });
100
+
101
+ this.client = new SandboxClient(sdkConfig, { requireImage: false });
102
+ this.client._sandboxId = this.sandboxId;
103
+
104
+ // Initialize managers
105
+ this.sessionManager = new SessionManager(this.client, this.sandboxId);
106
+ this.historyManager = new HistoryManager(this.sandboxId);
107
+
108
+ // Unified exit & signal handling (best-effort)
109
+ cleanupManager.installSignalHandlers();
110
+ cleanupManager.registerSyncCleanup(() => {
111
+ try {
112
+ if (process.stdin && process.stdin.isTTY && typeof process.stdin.setRawMode === 'function') {
113
+ process.stdin.setRawMode(false);
114
+ }
115
+ } catch {
116
+ // ignore
117
+ }
118
+ });
119
+ cleanupManager.registerCleanup(async () => {
120
+ await this._cleanup();
121
+ });
122
+
123
+ // Determine UI mode early to decide how to show connecting screen
124
+ const uiModeCheck = selectAttachUiMode({ argv: this.argv, env: process.env, nodeVersion: process.versions.node });
125
+ const useOpenTuiUI = uiModeCheck.mode === 'opentui';
126
+ const useInkUI = uiModeCheck.mode === 'ink';
127
+
128
+ // Spinner frames for animation (ASCII based for better compatibility)
129
+ const SPINNER_FRAMES = ['|', '/', '-', '\\'];
130
+ let spinnerIndex = 0;
131
+ let spinnerTimer = null;
132
+
133
+ // Connecting animation state
134
+ let currentAttempt = 0;
135
+ let connectingScreen = null;
136
+
137
+ // ASCII banner for opentui/ink connecting screen
138
+ const ASCII_LINES = getAsciiLogo();
139
+
140
+ const centerText = (text, width) => {
141
+ const textWidth = this._getDisplayWidth ? this._getDisplayWidth(text) : text.length;
142
+ const padding = Math.max(0, Math.floor((width - textWidth) / 2));
143
+ return ' '.repeat(padding) + text;
144
+ };
145
+
146
+ let lastAttempt = 0;
147
+
148
+ // Function to render opentui connecting screen with ANSI codes
149
+ // Logo 全局居中,Logo 正下方显示连接状态
150
+ const renderOpenTuiConnecting = (attempt, maxAttempts, isInitial = false) => {
151
+ const spinner = SPINNER_FRAMES[spinnerIndex];
152
+ const shortId = this.sandboxId.slice(0, 8);
153
+ const terminalWidth = process.stdout.columns || 80;
154
+
155
+ // Calculate vertical center for logo
156
+ const logoHeight = ASCII_LINES.length;
157
+ const terminalHeight = process.stdout.rows || 24;
158
+ const topPadding = Math.max(2, Math.floor((terminalHeight - logoHeight - 3) / 2)); // -3 for status line and padding
159
+
160
+ // Status line position (right after logo)
161
+ const statusLine = topPadding + logoHeight + 2;
162
+
163
+ if (isInitial) {
164
+ // Clear screen and render everything once
165
+ let output = '\x1b[H\x1b[2J'; // Clear screen
166
+
167
+ // Top padding
168
+ output += '\n'.repeat(topPadding);
169
+
170
+ // Render ASCII banner with gradient
171
+ const colors = ['38;2;37;99;235', '38;2;59;130;246', '38;2;79;70;229', '38;2;99;102;241', '38;2;124;58;237', '38;2;139;92;246'];
172
+ ASCII_LINES.forEach((line, i) => {
173
+ const centeredLine = centerText(line, terminalWidth);
174
+ output += `\x1b[${colors[i % colors.length]}m\x1b[1m${centeredLine}\x1b[0m\n`;
175
+ });
176
+
177
+ // Empty line before status
178
+ output += '\n';
179
+
180
+ process.stdout.write(output);
181
+ }
182
+
183
+ // Build status text
184
+ const statusText = attempt > 1 ? `(${attempt}/${maxAttempts})` : '';
185
+ const line = `${spinner} 正在连接:${this.sandboxId} ${statusText}`;
186
+ const centeredLine = centerText(line, terminalWidth);
187
+
188
+ // Move to status line and write
189
+ process.stdout.write(`\x1b[${statusLine};1H\x1b[K\x1b[36m${centeredLine}\x1b[0m`);
190
+ };
191
+
192
+ if (useOpenTuiUI) {
193
+ // For OpenTUI, use ANSI-based connecting animation (not TUI) to avoid entering alternate screen early
194
+ // This prevents getting stuck in alternate screen if sandbox is not ready
195
+ process.stdout.write('\x1bc'); // Clear screen
196
+ renderOpenTuiConnecting(1, 30, true); // Initial render
197
+ // Start spinner timer for animation (120ms for smooth rotation)
198
+ spinnerTimer = setInterval(() => {
199
+ spinnerIndex = (spinnerIndex + 1) % SPINNER_FRAMES.length;
200
+ renderOpenTuiConnecting(currentAttempt || 1, 30, false);
201
+ }, 120);
202
+ } else if (useInkUI) {
203
+ // Use Ink's ConnectingScreen component
204
+ try {
205
+ const React = await import('react');
206
+ const { render } = await import('ink');
207
+ const { ThemeProvider } = await import('./ink-repl/contexts/ThemeContext.js');
208
+ const { ConnectingScreen } = await import('./ink-repl/components/ConnectingScreen.js');
209
+ const h = React.default.createElement;
210
+
211
+ process.stdout.write('\x1bc');
212
+
213
+ connectingScreen = render(
214
+ h(ThemeProvider, { initialTheme: 'catppuccin-mocha' },
215
+ h(ConnectingScreen, {
216
+ sandboxId: this.sandboxId,
217
+ attempt: currentAttempt,
218
+ maxAttempts: 30,
219
+ })
220
+ ),
221
+ { exitOnCtrlC: false }
222
+ );
223
+ } catch (e) {
224
+ logger.debug('Failed to render Ink connecting screen:', e);
225
+ process.stdout.write(`Connecting to ${this.sandboxId}...`);
226
+ }
227
+ } else {
228
+ process.stdout.write(`Connecting to ${this.sandboxId}...`);
229
+ }
230
+
231
+ // Wait for sandbox to be ready
232
+ const readyResult = await this.sessionManager.waitForReady({
233
+ maxAttempts: 30,
234
+ intervalMs: 1000,
235
+ onProgress: (attempt, max) => {
236
+ currentAttempt = attempt;
237
+ if (useOpenTuiUI) {
238
+ // For OpenTUI, update attempt counter (spinner is handled by timer)
239
+ // No need to manually trigger render, the timer updates spinner
240
+ } else if (useInkUI && connectingScreen) {
241
+ try {
242
+ const React = require('react');
243
+ const { ThemeProvider } = require('./ink-repl/contexts/ThemeContext.js');
244
+ const { ConnectingScreen } = require('./ink-repl/components/ConnectingScreen.js');
245
+ const h = React.createElement;
246
+
247
+ connectingScreen.rerender(
248
+ h(ThemeProvider, { initialTheme: 'catppuccin-mocha' },
249
+ h(ConnectingScreen, {
250
+ sandboxId: this.sandboxId,
251
+ attempt: currentAttempt,
252
+ maxAttempts: max,
253
+ })
254
+ )
255
+ );
256
+ } catch (e) {
257
+ // Ignore rerender errors
258
+ }
259
+ } else if (attempt > 1) {
260
+ process.stdout.write(`\rConnecting to ${this.sandboxId}... (${attempt}/${max})`);
261
+ }
262
+ },
263
+ });
264
+
265
+ const isReady = readyResult.ready;
266
+ const specificError = readyResult.error;
267
+ const sandboxStatus = readyResult.status;
268
+
269
+ // Stop spinner animation (only relevant for Ink mode)
270
+ if (spinnerTimer) {
271
+ clearInterval(spinnerTimer);
272
+ spinnerTimer = null;
273
+ // Keep the animation on screen for a moment before transitioning (1.5s for better visibility)
274
+ await new Promise(resolve => setTimeout(resolve, 1500));
275
+ }
276
+
277
+ if (!isReady) {
278
+ // Check if sandbox is in Failed state (destroyed or not exist) - handle immediately
279
+ if (sandboxStatus === 'Failed') {
280
+ const errorMessage = `Sandbox ${this.sandboxId} has been destroyed or does not exist.`;
281
+
282
+ // Clear screen and show error
283
+ process.stdout.write('\x1b[H\x1b[2J');
284
+ console.log('');
285
+ console.log(` ❌ ${errorMessage}`);
286
+ console.log('');
287
+ await new Promise(resolve => setTimeout(resolve, 1000));
288
+ process.exit(1);
289
+ return;
290
+ }
291
+
292
+ // Unmount/clear connecting screen
293
+ if (connectingScreen) {
294
+ connectingScreen.unmount();
295
+ }
296
+
297
+ // Determine the specific error message
298
+ let errorMessage = 'Sandbox is not ready. It may be starting up or stopped.';
299
+ let suggestion = `Try: ${CLI_NAME} sandbox ${this.sandboxId} status`;
300
+
301
+ // Check if we have a specific error from getStatus that indicates the sandbox doesn't exist
302
+ if (specificError && specificError.message && specificError.message.includes('not found')) {
303
+ errorMessage = `Sandbox ${this.sandboxId} does not exist.`;
304
+ suggestion = `Please verify the sandbox ID is correct. Try: ${CLI_NAME} sandbox list`;
305
+ }
306
+
307
+ if (useOpenTuiUI || useInkUI) {
308
+ // For TUI modes, show error in the TUI rather than just clearing screen
309
+ try {
310
+ if (useOpenTuiUI) {
311
+ // Clear screen and show error in a clean way
312
+ process.stdout.write('\x1b[H\x1b[2J'); // Clear screen
313
+ console.log(''); // Empty line for spacing
314
+ console.log(` ❌ ${errorMessage}`);
315
+ console.log(` 💡 ${suggestion}`);
316
+ console.log(''); // Empty line for spacing
317
+ console.log(' Press any key to exit...');
318
+
319
+ try {
320
+ // Ensure stdout is flushed before setting raw mode
321
+ process.stdout.write('', () => {
322
+ try {
323
+ process.stdin.setRawMode(true);
324
+ process.stdin.resume();
325
+ process.stdin.once('data', () => {
326
+ process.exit(1);
327
+ });
328
+ } catch (err) {
329
+ // If raw mode fails, just exit after a short delay
330
+ console.log('\nExiting in 3 seconds...');
331
+ setTimeout(() => process.exit(1), 3000);
332
+ }
333
+ });
334
+ } catch (err) {
335
+ // If anything goes wrong, exit after showing the message
336
+ console.log('\nExiting in 3 seconds...');
337
+ setTimeout(() => process.exit(1), 3000);
338
+ }
339
+ // Don't throw error, let the process exit via keypress
340
+ return;
341
+ } else if (useInkUI) {
342
+ // Clear the connecting screen
343
+ process.stdout.write('\x1b[H\x1b[2J');
344
+ }
345
+ } catch (e) {
346
+ // If TUI error display fails, fall back to basic error
347
+ process.stdout.write('\x1b[H\x1b[2J');
348
+ }
349
+ }
350
+ console.log(' failed');
351
+ logger.error(errorMessage);
352
+ logger.error(suggestion);
353
+ const err = new Error(errorMessage);
354
+ err.exitCode = 1;
355
+ throw err;
356
+ }
357
+
358
+ // Unmount/clear connecting screen on success
359
+ if (connectingScreen) {
360
+ connectingScreen.unmount();
361
+ }
362
+ if (!useOpenTuiUI && !useInkUI) {
363
+ console.log(' done');
364
+ }
365
+
366
+ // Get host IP and hostname from status
367
+ let hostIp = null;
368
+ let hostName = null;
369
+ try {
370
+ const status = await this.client.getStatus();
371
+ hostIp = status.hostIp || null;
372
+ // Extract first part of hostname (e.g., "rock-worker-zb-a-216" from "rock-worker-zb-a-216.xxx.local")
373
+ if (status.hostName) {
374
+ hostName = status.hostName.split('.')[0];
375
+ }
376
+ } catch (e) {
377
+ logger.debug('Failed to get host info:', e.message);
378
+ }
379
+
380
+ try {
381
+ // Record start time for session duration
382
+ this.startTime = Date.now();
383
+
384
+ // Check if --session is specified
385
+ // Note: When called via 'sandbox <id> attach -s', the value might be in argv.s instead of argv.session
386
+ let reuseSession = this.argv.session || this.argv.s || null;
387
+
388
+ if (reuseSession) {
389
+ logger.debug(`Attempting to resume session: ${reuseSession}`);
390
+ }
391
+
392
+ // Connect to session
393
+ const { sessionId, initialPrompt, user, reused } = await this.sessionManager.connect({
394
+ reuseSession,
395
+ });
396
+
397
+ // Restore or create transcript history for this shell session.
398
+ // If user specified -s, try to resume history from that session (even if shell session is dead).
399
+ // Otherwise, if we reused an existing shell session, prefer resuming the matching history session.
400
+ const historyShellSessionName = reuseSession || sessionId;
401
+ const shouldPreferResume = !!reuseSession || !!reused;
402
+ logger.debug(`History lookup: shellSessionName=${historyShellSessionName}, preferResume=${shouldPreferResume}, reuseSession=${reuseSession}, sessionId=${sessionId}, reused=${reused}`);
403
+ await ensureHistorySession({
404
+ historyManager: this.historyManager,
405
+ shellSessionName: historyShellSessionName,
406
+ preferResume: shouldPreferResume,
407
+ });
408
+ this.shellSessionId = sessionId; // Store for exit banner
409
+
410
+ if (reused && !(useInkUI || useOpenTuiUI)) {
411
+ // Only show resume message for basic REPL mode
412
+ // For TUI modes (Ink/OpenTUI), the session restoration is handled in-app
413
+ console.log(` Resumed session: ${sessionId.substring(0, 20)}...`);
414
+ }
415
+
416
+ // Start heartbeat to keep sandbox alive
417
+ this.sessionManager.startHeartbeat();
418
+
419
+ // Determine hostname for prompt: prefer hostName from status, fallback to sandboxId
420
+ const actualUser = user || 'root';
421
+ const actualHostname = hostName || this.sandboxId;
422
+
423
+ // Parse cwd from initialPrompt if available, otherwise default to /
424
+ // Format: <user>@<hostname>:<cwd>#
425
+ const cwdMatch = initialPrompt?.match(/:([^#]+)#/);
426
+ const initialCwd = cwdMatch ? cwdMatch[1] : '/';
427
+ const shellPrompt = `${actualUser}@${actualHostname}:${initialCwd}# `;
428
+
429
+ logger.debug(`Host info: hostName=${hostName}, hostIp=${hostIp}, actualHostname=${actualHostname}`);
430
+ logger.debug(`Prompt: user=${actualUser}, cwd=${initialCwd}, shellPrompt=${shellPrompt}`);
431
+
432
+ const ui = selectAttachUiMode({ argv: this.argv, env: process.env, nodeVersion: process.versions.node });
433
+
434
+ // Get version from package.json (read once at startup)
435
+ const { version: cliVersion } = require('../../package.json');
436
+
437
+ if (ui.mode === 'basic') {
438
+ console.log(` Using basic attach UI (${ui.reason}).`);
439
+ console.log(` Tip: upgrade Node to >= ${INK_MIN_NODE_MAJOR} or use ROCK_ATTACH_UI=ink to force Ink UI.`);
440
+ await startBasicREPL({
441
+ sandboxId: this.sandboxId,
442
+ hostname: actualHostname,
443
+ hostIp,
444
+ user: user || 'root',
445
+ client: this.client,
446
+ sessionManager: this.sessionManager,
447
+ historyManager: this.historyManager,
448
+ initialPrompt: shellPrompt,
449
+ version: cliVersion,
450
+ onExit: async () => {
451
+ await cleanupManager.runExitCleanup({ reason: 'basic-ui-exit' });
452
+ this._showExitBanner();
453
+ },
454
+ });
455
+ return;
456
+ }
457
+
458
+ // If opentui mode is selected but we're not running under Bun,
459
+ // re-exec the entire command under Bun runtime.
460
+ if (ui.mode === 'opentui' && !isBunRuntime()) {
461
+ const bunPath = findBunBinary();
462
+ if (bunPath) {
463
+ // Check Bun version before re-exec (Bun 1.0+ required for --preload)
464
+ const bunVersionInfo = getBunVersion(bunPath);
465
+ if (bunVersionInfo && bunVersionInfo.major < 1) {
466
+ logger.warn(`Bun ${bunVersionInfo.version} found, but OpenTUI requires Bun >= 1.0`);
467
+ logger.warn('Falling back to Ink UI mode...');
468
+ ui.mode = 'ink';
469
+ ui.reason = `bun ${bunVersionInfo.version} < 1.0, fallback to ink`;
470
+ } else {
471
+ try {
472
+ logger.debug(`Re-executing under Bun: ${bunPath}`);
473
+ const { spawnSync } = require('child_process');
474
+ const path = require('path');
475
+ const fs = require('fs');
476
+
477
+ // Find the entry script (all.js in dev, index.js in npm package)
478
+ let entryScript = path.resolve(__dirname, '..', '..', 'all.js');
479
+ if (!fs.existsSync(entryScript)) {
480
+ // Fallback to index.js (npm package)
481
+ entryScript = path.resolve(__dirname, '..', '..', 'index.js');
482
+ if (!fs.existsSync(entryScript)) {
483
+ throw new Error('Cannot find entry script (all.js or index.js)');
484
+ }
485
+ }
486
+
487
+ logger.debug(`Entry script: ${entryScript}`);
488
+ const args = process.argv.slice(2);
489
+ // --preload is required for @opentui/solid JSX transform
490
+ // Remove BUN_CONFIG_FILE override so preload works correctly
491
+ const bunEnv = { ...process.env };
492
+ delete bunEnv.BUN_CONFIG_FILE;
493
+ const result = spawnSync(bunPath, ['--preload', '@opentui/solid/preload', entryScript, ...args], {
494
+ stdio: 'inherit',
495
+ env: bunEnv,
496
+ });
497
+
498
+ // Check if bun execution failed
499
+ if (result.error) {
500
+ throw result.error;
501
+ }
502
+
503
+ process.exit(result.status || 0);
504
+ return;
505
+ } catch (bunError) {
506
+ logger.warn('Failed to re-execute with Bun:', bunError.message);
507
+ logger.debug('Bun error:', bunError);
508
+ logger.warn('Falling back to Ink UI mode...');
509
+ ui.mode = 'ink';
510
+ ui.reason = 'bun re-execution failed, fallback to ink';
511
+ }
512
+ }
513
+ } else {
514
+ // No bun binary available, fallback to ink mode
515
+ logger.debug('Bun binary not found, falling back to ink mode');
516
+ ui.mode = 'ink';
517
+ ui.reason = 'opentui requires Bun runtime, bun not found, fallback to ink';
518
+ }
519
+ }
520
+
521
+ if (ui.mode === 'opentui') {
522
+ try {
523
+ // Register @opentui/solid JSX transform plugin before loading .tsx files
524
+ // This must happen before any .tsx import so Bun knows how to compile Solid JSX
525
+ logger.debug('Registering @opentui/solid preload plugin...');
526
+ // Try to import the preload module - Bun can handle both .ts and .js
527
+ // First try the package export, fallback to direct path if needed
528
+ try {
529
+ await import('@opentui/solid/preload');
530
+ } catch (preloadErr) {
531
+ logger.debug('Package export preload failed, trying direct path:', preloadErr.message);
532
+ const preloadPath = require.resolve('@opentui/solid/scripts/preload.js');
533
+ await import(preloadPath);
534
+ }
535
+
536
+ logger.debug('Loading opentui-repl module...');
537
+ const { startOpenTuiREPL, createSlashTrigger } = await import('./opentui-repl/index.js');
538
+ logger.debug('opentui-repl module loaded successfully');
539
+
540
+ const slashTrigger = createSlashTrigger(BUILTIN_COMMANDS);
541
+ const initialTheme = configManager.getUIConfig('theme');
542
+
543
+ logger.debug('Starting OpenTUI REPL...');
544
+ await startOpenTuiREPL({
545
+ sandboxId: this.sandboxId,
546
+ hostname: actualHostname,
547
+ hostIp,
548
+ user: user || 'root',
549
+ client: this.client,
550
+ sessionManager: this.sessionManager,
551
+ historyManager: this.historyManager,
552
+ initialPrompt: shellPrompt,
553
+ version: cliVersion,
554
+ triggers: [slashTrigger],
555
+ theme: initialTheme,
556
+ onThemeChange: (name) => {
557
+ configManager.setUIConfig('theme', name);
558
+ },
559
+ onExit: async () => {
560
+ await cleanupManager.runExitCleanup({ reason: 'opentui-ui-exit' });
561
+ this._showExitBanner();
562
+ },
563
+ });
564
+ // Force exit to ensure program terminates
565
+ process.exit(0);
566
+ return;
567
+ } catch (opentuiError) {
568
+ // Use console.error for critical errors so user can see them without -v flag
569
+ console.error('Failed to start OpenTUI REPL:', opentuiError.message);
570
+ logger.debug('OpenTUI error stack:', opentuiError.stack);
571
+ logger.warn('Falling back to Ink UI mode...');
572
+ // Fallback to ink mode
573
+ ui.mode = 'ink';
574
+ ui.reason = 'opentui failed, fallback to ink';
575
+ }
576
+ }
577
+
578
+ try {
579
+ // Dynamic import for ESM ink-repl module (may fail on older Node)
580
+ const { startInkREPL, createSlashTrigger } = await import('./ink-repl/index.js');
581
+
582
+ // Create slash command trigger
583
+ const slashTrigger = createSlashTrigger(BUILTIN_COMMANDS);
584
+
585
+ // Start Ink REPL
586
+ const initialTheme = configManager.getUIConfig('theme');
587
+
588
+ await startInkREPL({
589
+ sandboxId: this.sandboxId,
590
+ hostname: actualHostname,
591
+ hostIp,
592
+ user: user || 'root',
593
+ client: this.client,
594
+ sessionManager: this.sessionManager,
595
+ historyManager: this.historyManager,
596
+ initialPrompt: shellPrompt,
597
+ version: cliVersion,
598
+ triggers: [slashTrigger],
599
+ theme: initialTheme,
600
+ onThemeChange: (name) => {
601
+ configManager.setUIConfig('theme', name);
602
+ },
603
+ onExit: async () => {
604
+ await cleanupManager.runExitCleanup({ reason: 'ink-ui-exit' });
605
+ this._showExitBanner();
606
+ },
607
+ });
608
+ } catch (e) {
609
+ const msg = e && e.message ? e.message : String(e);
610
+ const name = e && e.name ? e.name : '';
611
+
612
+ // If the user explicitly asked for Ink UI, surface the error.
613
+ if (this.argv && this.argv.ui === 'ink') {
614
+ console.error(`Ink UI error: ${msg}`);
615
+ throw e;
616
+ }
617
+
618
+ const looksLikeSyntax =
619
+ name === 'SyntaxError' ||
620
+ /Unexpected token/.test(msg) ||
621
+ /Cannot use import statement outside a module/.test(msg);
622
+
623
+ if (!looksLikeSyntax) {
624
+ console.error(`Ink UI error: ${msg}`);
625
+ throw e;
626
+ }
627
+
628
+ console.log(` Ink UI failed to start (${msg}). Falling back to basic attach UI.`);
629
+ await startBasicREPL({
630
+ sandboxId: this.sandboxId,
631
+ hostname: actualHostname,
632
+ hostIp,
633
+ user: user || 'root',
634
+ client: this.client,
635
+ sessionManager: this.sessionManager,
636
+ historyManager: this.historyManager,
637
+ initialPrompt: shellPrompt,
638
+ version: cliVersion,
639
+ onExit: async () => {
640
+ await cleanupManager.runExitCleanup({ reason: 'basic-ui-exit' });
641
+ this._showExitBanner();
642
+ },
643
+ });
644
+ }
645
+
646
+ } catch (error) {
647
+ // Use console.error for critical errors so user can see them without -v flag
648
+ console.error(`Error: ${error.message}`);
649
+ logger.debug(error.stack);
650
+ await cleanupManager.runExitCleanup({ reason: 'repl-error', exitCode: error && typeof error.exitCode === 'number' ? error.exitCode : 1 });
651
+ }
652
+ }
653
+
654
+ /**
655
+ * Cleanup on exit
656
+ */
657
+ async _cleanup() {
658
+ try {
659
+ if (this.historyManager) {
660
+ await this.historyManager.close();
661
+ }
662
+ if (this.sessionManager) {
663
+ await this.sessionManager.disconnect();
664
+ }
665
+ } catch (error) {
666
+ logger.debug('Cleanup error:', error.message);
667
+ }
668
+ }
669
+
670
+ /**
671
+ * Format duration in human readable format
672
+ */
673
+ _formatDuration(ms) {
674
+ const seconds = Math.floor(ms / 1000);
675
+ const minutes = Math.floor(seconds / 60);
676
+ const hours = Math.floor(minutes / 60);
677
+
678
+ if (hours > 0) {
679
+ const m = minutes % 60;
680
+ return `${hours}h ${m}m`;
681
+ } else if (minutes > 0) {
682
+ const s = seconds % 60;
683
+ return `${minutes}m ${s}s`;
684
+ } else {
685
+ return `${seconds}s`;
686
+ }
687
+ }
688
+
689
+ /**
690
+ * Calculate display width of a string (CJK characters count as 2)
691
+ */
692
+ _getDisplayWidth(str) {
693
+ let width = 0;
694
+ for (const char of str) {
695
+ const code = char.charCodeAt(0);
696
+ // CJK Unified Ideographs: 0x4E00-0x9FFF
697
+ // CJK symbols and punctuation: 0x3000-0x303F
698
+ // Fullwidth ASCII variants: 0xFF00-0xFFEF
699
+ // Hiragana: 0x3040-0x309F
700
+ // Katakana: 0x30A0-0x30FF
701
+ if ((code >= 0x4E00 && code <= 0x9FFF) ||
702
+ (code >= 0x3000 && code <= 0x303F) ||
703
+ (code >= 0xFF00 && code <= 0xFFEF) ||
704
+ (code >= 0x3040 && code <= 0x309F) ||
705
+ (code >= 0x30A0 && code <= 0x30FF)) {
706
+ width += 2;
707
+ } else {
708
+ width += 1;
709
+ }
710
+ }
711
+ return width;
712
+ }
713
+
714
+ /**
715
+ * Show exit banner with resume hint
716
+ */
717
+ _showExitBanner() {
718
+ // Clear screen first to remove any leftover content (like loading animation)
719
+ // Also exit alternate screen and reset terminal attributes
720
+ process.stdout.write('\x1b[?1049l\x1b[0m\x1b[2J\x1b[H');
721
+
722
+ const duration = this.startTime ? this._formatDuration(Date.now() - this.startTime) : '';
723
+ const newSessionCmd = `${CLI_NAME} sandbox ${this.sandboxId} attach`;
724
+ const resumeSessionCmd = this.shellSessionId
725
+ ? `${CLI_NAME} sandbox ${this.sandboxId} attach -s ${this.shellSessionId}`
726
+ : '';
727
+
728
+ // Use i18n for labels
729
+ const durationLabel = t('exit.session.duration');
730
+ const newSessionLabel = t('exit.new.session');
731
+ const resumeSessionLabel = t('exit.resume.session');
732
+
733
+ // Calculate display width for alignment (CJK characters count as 2)
734
+ const durationWidth = this._getDisplayWidth(durationLabel);
735
+ const newSessionWidth = this._getDisplayWidth(newSessionLabel);
736
+ const resumeSessionWidth = this._getDisplayWidth(resumeSessionLabel);
737
+
738
+ // Find max label width
739
+ const maxLabelWidth = Math.max(durationWidth, newSessionWidth, resumeSessionWidth);
740
+
741
+ // Calculate padding for each label to align commands
742
+ const durationPadding = ' '.repeat(maxLabelWidth - durationWidth);
743
+ const newSessionPadding = ' '.repeat(maxLabelWidth - newSessionWidth);
744
+ const resumeSessionPadding = ' '.repeat(maxLabelWidth - resumeSessionWidth);
745
+
746
+ // Calculate box width based on content
747
+ const contentWidth = Math.max(
748
+ this._getDisplayWidth(`${durationLabel}${durationPadding} ${duration}`),
749
+ this._getDisplayWidth(`${newSessionLabel}${newSessionPadding} ${newSessionCmd}`),
750
+ resumeSessionCmd ? this._getDisplayWidth(`${resumeSessionLabel}${resumeSessionPadding} ${resumeSessionCmd}`) : 0,
751
+ 61 // minimum width for ASCII art
752
+ );
753
+ const boxWidth = contentWidth + 4; // 2 chars padding on each side
754
+
755
+ const horizontalLine = '═'.repeat(boxWidth);
756
+ const emptyLine = '║' + ' '.repeat(boxWidth) + '║';
757
+ const padLine = (text) => {
758
+ const textWidth = this._getDisplayWidth(text);
759
+ const padding = boxWidth - 2 - textWidth;
760
+ return '║ ' + text + ' '.repeat(Math.max(0, padding)) + '║';
761
+ };
762
+
763
+ // Use process.stdout.write instead of console.log to avoid any redirection issues
764
+ process.stdout.write('\n');
765
+ process.stdout.write('╔' + horizontalLine + '╗\n');
766
+ process.stdout.write(emptyLine + '\n');
767
+ process.stdout.write(padLine(`${durationLabel}${durationPadding} ${duration}`) + '\n');
768
+ process.stdout.write(emptyLine + '\n');
769
+ process.stdout.write(padLine('██████╗ ██████ ██████╗ ██╗ ██╗ ██████╗ ██╗ ██╗') + '\n');
770
+ process.stdout.write(padLine('██╔══██╗ ██╔═══██╗ ██╔════╝ ██║ ██╔╝ ██╔════╝ ██║ ██║') + '\n');
771
+ process.stdout.write(padLine('██████╔╝ ██║ ██║ ██║ █████╔╝ ██║ ██║ ██║') + '\n');
772
+ process.stdout.write(padLine('██╔══██╗ ██║ ██║ ██║ ██╔═██╗ ██║ ██║ ██║') + '\n');
773
+ process.stdout.write(padLine('██║ ██║ ╚██████╔╝ ╚██████╗ ██║ ██╗ ╚██████╗ ███████╗ ██║') + '\n');
774
+ process.stdout.write(padLine('╚═╝ ╚═╝ ╚═════╝ ╚═════╝ ╚═╝ ╚═╝ ╚═════╝ ╚══════╝ ╚═╝') + '\n');
775
+ process.stdout.write(emptyLine + '\n');
776
+ process.stdout.write(padLine(`${newSessionLabel}${newSessionPadding} ${newSessionCmd}`) + '\n');
777
+ if (resumeSessionCmd) {
778
+ process.stdout.write(padLine(`${resumeSessionLabel}${resumeSessionPadding} ${resumeSessionCmd}`) + '\n');
779
+ }
780
+ process.stdout.write(emptyLine + '\n');
781
+ process.stdout.write('╚' + horizontalLine + '╝\n');
782
+ process.stdout.write('\n');
783
+ }
784
+ }
785
+
786
+ // Export for backwards compatibility with tests
787
+ module.exports = {
788
+ SandboxREPL,
789
+ BUILTIN_COMMANDS: BUILTIN_COMMANDS.map(c => c.name),
790
+ getAsciiLogo,
791
+ };