@runloop/rl-cli 0.1.2 → 0.3.0

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 (105) hide show
  1. package/README.md +54 -10
  2. package/dist/cli.js +79 -72
  3. package/dist/commands/auth.js +2 -2
  4. package/dist/commands/blueprint/create.js +31 -83
  5. package/dist/commands/blueprint/get.js +29 -34
  6. package/dist/commands/blueprint/list.js +278 -230
  7. package/dist/commands/blueprint/logs.js +133 -37
  8. package/dist/commands/config.js +118 -0
  9. package/dist/commands/devbox/create.js +120 -40
  10. package/dist/commands/devbox/delete.js +17 -33
  11. package/dist/commands/devbox/download.js +29 -43
  12. package/dist/commands/devbox/exec.js +22 -39
  13. package/dist/commands/devbox/execAsync.js +20 -37
  14. package/dist/commands/devbox/get.js +13 -35
  15. package/dist/commands/devbox/getAsync.js +12 -34
  16. package/dist/commands/devbox/list.js +241 -402
  17. package/dist/commands/devbox/logs.js +20 -38
  18. package/dist/commands/devbox/read.js +29 -43
  19. package/dist/commands/devbox/resume.js +13 -35
  20. package/dist/commands/devbox/rsync.js +26 -78
  21. package/dist/commands/devbox/scp.js +25 -79
  22. package/dist/commands/devbox/sendStdin.js +41 -0
  23. package/dist/commands/devbox/shutdown.js +13 -35
  24. package/dist/commands/devbox/ssh.js +46 -78
  25. package/dist/commands/devbox/suspend.js +13 -35
  26. package/dist/commands/devbox/tunnel.js +37 -88
  27. package/dist/commands/devbox/upload.js +28 -36
  28. package/dist/commands/devbox/write.js +29 -44
  29. package/dist/commands/mcp-http.js +6 -5
  30. package/dist/commands/mcp-install.js +12 -10
  31. package/dist/commands/mcp.js +5 -4
  32. package/dist/commands/menu.js +26 -67
  33. package/dist/commands/object/delete.js +12 -34
  34. package/dist/commands/object/download.js +26 -74
  35. package/dist/commands/object/get.js +12 -34
  36. package/dist/commands/object/list.js +15 -93
  37. package/dist/commands/object/upload.js +35 -96
  38. package/dist/commands/snapshot/create.js +23 -39
  39. package/dist/commands/snapshot/delete.js +17 -33
  40. package/dist/commands/snapshot/get.js +16 -0
  41. package/dist/commands/snapshot/list.js +309 -80
  42. package/dist/commands/snapshot/status.js +12 -34
  43. package/dist/components/ActionsPopup.js +64 -39
  44. package/dist/components/Banner.js +7 -1
  45. package/dist/components/Breadcrumb.js +11 -48
  46. package/dist/components/DevboxActionsMenu.js +117 -207
  47. package/dist/components/DevboxCreatePage.js +12 -7
  48. package/dist/components/DevboxDetailPage.js +76 -28
  49. package/dist/components/ErrorBoundary.js +29 -0
  50. package/dist/components/ErrorMessage.js +10 -2
  51. package/dist/components/Header.js +12 -4
  52. package/dist/components/InteractiveSpawn.js +104 -0
  53. package/dist/components/LogsViewer.js +169 -0
  54. package/dist/components/MainMenu.js +37 -33
  55. package/dist/components/MetadataDisplay.js +4 -4
  56. package/dist/components/OperationsMenu.js +1 -1
  57. package/dist/components/ResourceActionsMenu.js +4 -4
  58. package/dist/components/ResourceListView.js +46 -34
  59. package/dist/components/Spinner.js +7 -2
  60. package/dist/components/StatusBadge.js +1 -1
  61. package/dist/components/SuccessMessage.js +12 -2
  62. package/dist/components/Table.js +16 -6
  63. package/dist/components/UpdateNotification.js +56 -0
  64. package/dist/hooks/useCursorPagination.js +125 -85
  65. package/dist/hooks/useExitOnCtrlC.js +15 -0
  66. package/dist/hooks/useViewportHeight.js +47 -0
  67. package/dist/mcp/server-http.js +2 -1
  68. package/dist/mcp/server.js +71 -7
  69. package/dist/router/Router.js +70 -0
  70. package/dist/router/types.js +1 -0
  71. package/dist/screens/BlueprintListScreen.js +7 -0
  72. package/dist/screens/BlueprintLogsScreen.js +74 -0
  73. package/dist/screens/DevboxActionsScreen.js +25 -0
  74. package/dist/screens/DevboxCreateScreen.js +11 -0
  75. package/dist/screens/DevboxDetailScreen.js +60 -0
  76. package/dist/screens/DevboxListScreen.js +23 -0
  77. package/dist/screens/LogsSessionScreen.js +49 -0
  78. package/dist/screens/MenuScreen.js +23 -0
  79. package/dist/screens/SSHSessionScreen.js +55 -0
  80. package/dist/screens/SnapshotListScreen.js +7 -0
  81. package/dist/services/blueprintService.js +101 -0
  82. package/dist/services/devboxService.js +215 -0
  83. package/dist/services/snapshotService.js +81 -0
  84. package/dist/store/blueprintStore.js +89 -0
  85. package/dist/store/devboxStore.js +105 -0
  86. package/dist/store/index.js +7 -0
  87. package/dist/store/navigationStore.js +101 -0
  88. package/dist/store/snapshotStore.js +87 -0
  89. package/dist/utils/client.js +4 -2
  90. package/dist/utils/config.js +22 -111
  91. package/dist/utils/interactiveCommand.js +3 -2
  92. package/dist/utils/logFormatter.js +208 -0
  93. package/dist/utils/memoryMonitor.js +85 -0
  94. package/dist/utils/output.js +153 -61
  95. package/dist/utils/process.js +106 -0
  96. package/dist/utils/processUtils.js +135 -0
  97. package/dist/utils/screen.js +61 -0
  98. package/dist/utils/ssh.js +6 -3
  99. package/dist/utils/sshSession.js +5 -29
  100. package/dist/utils/terminalDetection.js +185 -0
  101. package/dist/utils/terminalSync.js +39 -0
  102. package/dist/utils/theme.js +162 -13
  103. package/dist/utils/versionCheck.js +53 -0
  104. package/dist/version.js +12 -0
  105. package/package.json +19 -17
@@ -1,31 +1,62 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
2
  import React from "react";
3
- import { Box, Text, useInput, useApp, useStdout } from "ink";
3
+ import { Box, Text, useInput } from "ink";
4
4
  import TextInput from "ink-text-input";
5
5
  import figures from "figures";
6
- import { getClient } from "../utils/client.js";
7
6
  import { Header } from "./Header.js";
8
7
  import { SpinnerComponent } from "./Spinner.js";
9
8
  import { ErrorMessage } from "./ErrorMessage.js";
10
9
  import { SuccessMessage } from "./SuccessMessage.js";
11
10
  import { Breadcrumb } from "./Breadcrumb.js";
12
11
  import { colors } from "../utils/theme.js";
12
+ import { useViewportHeight } from "../hooks/useViewportHeight.js";
13
+ import { useNavigation } from "../store/navigationStore.js";
14
+ import { useExitOnCtrlC } from "../hooks/useExitOnCtrlC.js";
15
+ import { getDevboxLogs, execCommand, suspendDevbox, resumeDevbox, shutdownDevbox, uploadFile, createSnapshot as createDevboxSnapshot, createTunnel, createSSHKey, } from "../services/devboxService.js";
16
+ import { LogsViewer } from "./LogsViewer.js";
13
17
  export const DevboxActionsMenu = ({ devbox, onBack, breadcrumbItems = [
14
18
  { label: "Devboxes" },
15
19
  { label: devbox.name || devbox.id, active: true },
16
- ], initialOperation, initialOperationIndex = 0, skipOperationsMenu = false, onSSHRequest, }) => {
17
- const { exit } = useApp();
18
- const { stdout } = useStdout();
20
+ ], initialOperation, initialOperationIndex = 0, skipOperationsMenu = false, }) => {
21
+ const { navigate, currentScreen, params } = useNavigation();
19
22
  const [loading, setLoading] = React.useState(false);
20
23
  const [selectedOperation, setSelectedOperation] = React.useState(initialOperationIndex);
21
24
  const [executingOperation, setExecutingOperation] = React.useState(initialOperation || null);
22
25
  const [operationInput, setOperationInput] = React.useState("");
23
26
  const [operationResult, setOperationResult] = React.useState(null);
24
27
  const [operationError, setOperationError] = React.useState(null);
25
- const [logsWrapMode, setLogsWrapMode] = React.useState(false);
26
- const [logsScroll, setLogsScroll] = React.useState(0);
27
28
  const [execScroll, setExecScroll] = React.useState(0);
28
29
  const [copyStatus, setCopyStatus] = React.useState(null);
30
+ // Calculate viewport for exec output:
31
+ // - Breadcrumb (3 lines + marginBottom): 4 lines
32
+ // - Command header (border + 2 content + border + marginBottom): 5 lines
33
+ // - Output box borders: 2 lines
34
+ // - Stats bar (marginTop + content): 2 lines
35
+ // - Help bar (marginTop + content): 2 lines
36
+ // - Safety buffer: 1 line
37
+ // Total: 16 lines
38
+ const execViewport = useViewportHeight({ overhead: 16, minHeight: 10 });
39
+ // CRITICAL: Aggressive memory cleanup to prevent heap exhaustion
40
+ React.useEffect(() => {
41
+ // Clear large data immediately when results are shown to free memory faster
42
+ if (operationResult || operationError) {
43
+ const timer = setTimeout(() => {
44
+ // After 100ms, if user hasn't acted, start aggressive cleanup
45
+ // This helps with memory without disrupting UX
46
+ }, 100);
47
+ return () => clearTimeout(timer);
48
+ }
49
+ }, [operationResult, operationError]);
50
+ // Cleanup on unmount
51
+ React.useEffect(() => {
52
+ return () => {
53
+ // Aggressively null out all large data structures
54
+ setOperationResult(null);
55
+ setOperationError(null);
56
+ setOperationInput("");
57
+ setLoading(false);
58
+ };
59
+ }, []);
29
60
  const allOperations = [
30
61
  {
31
62
  key: "logs",
@@ -123,6 +154,8 @@ export const DevboxActionsMenu = ({ devbox, onBack, breadcrumbItems = [
123
154
  executeOperation();
124
155
  }
125
156
  }, [executingOperation]);
157
+ // Handle Ctrl+C to exit
158
+ useExitOnCtrlC();
126
159
  useInput((input, key) => {
127
160
  // Handle operation input mode
128
161
  if (executingOperation && !operationResult && !operationError) {
@@ -130,7 +163,6 @@ export const DevboxActionsMenu = ({ devbox, onBack, breadcrumbItems = [
130
163
  executeOperation();
131
164
  }
132
165
  else if (input === "q" || key.escape) {
133
- console.clear();
134
166
  setExecutingOperation(null);
135
167
  setOperationInput("");
136
168
  }
@@ -139,20 +171,19 @@ export const DevboxActionsMenu = ({ devbox, onBack, breadcrumbItems = [
139
171
  // Handle operation result display
140
172
  if (operationResult || operationError) {
141
173
  if (input === "q" || key.escape || key.return) {
142
- console.clear();
174
+ // Clear large data structures immediately to prevent memory leaks
175
+ setOperationResult(null);
176
+ setOperationError(null);
177
+ setOperationInput("");
178
+ setExecScroll(0);
179
+ setCopyStatus(null);
143
180
  // If skipOperationsMenu is true, go back to parent instead of operations menu
144
181
  if (skipOperationsMenu) {
182
+ setExecutingOperation(null);
145
183
  onBack();
146
184
  }
147
185
  else {
148
- setOperationResult(null);
149
- setOperationError(null);
150
186
  setExecutingOperation(null);
151
- setOperationInput("");
152
- setLogsWrapMode(true);
153
- setLogsScroll(0);
154
- setExecScroll(0);
155
- setCopyStatus(null);
156
187
  }
157
188
  }
158
189
  else if ((key.upArrow || input === "k") &&
@@ -193,9 +224,7 @@ export const DevboxActionsMenu = ({ devbox, onBack, breadcrumbItems = [
193
224
  ...(operationResult.stdout || "").split("\n"),
194
225
  ...(operationResult.stderr || "").split("\n"),
195
226
  ];
196
- const terminalHeight = stdout?.rows || 30;
197
- const viewportHeight = Math.max(10, terminalHeight - 15);
198
- const maxScroll = Math.max(0, lines.length - viewportHeight);
227
+ const maxScroll = Math.max(0, lines.length - execViewport.viewportHeight);
199
228
  setExecScroll(maxScroll);
200
229
  }
201
230
  else if (input === "c" &&
@@ -242,115 +271,18 @@ export const DevboxActionsMenu = ({ devbox, onBack, breadcrumbItems = [
242
271
  };
243
272
  copyToClipboard(output);
244
273
  }
245
- else if ((key.upArrow || input === "k") &&
246
- operationResult &&
247
- typeof operationResult === "object" &&
248
- operationResult.__customRender === "logs") {
249
- setLogsScroll(Math.max(0, logsScroll - 1));
250
- }
251
- else if ((key.downArrow || input === "j") &&
252
- operationResult &&
253
- typeof operationResult === "object" &&
254
- operationResult.__customRender === "logs") {
255
- setLogsScroll(logsScroll + 1);
256
- }
257
- else if (key.pageUp &&
258
- operationResult &&
259
- typeof operationResult === "object" &&
260
- operationResult.__customRender === "logs") {
261
- setLogsScroll(Math.max(0, logsScroll - 10));
262
- }
263
- else if (key.pageDown &&
264
- operationResult &&
265
- typeof operationResult === "object" &&
266
- operationResult.__customRender === "logs") {
267
- setLogsScroll(logsScroll + 10);
268
- }
269
- else if (input === "g" &&
270
- operationResult &&
271
- typeof operationResult === "object" &&
272
- operationResult.__customRender === "logs") {
273
- setLogsScroll(0);
274
- }
275
- else if (input === "G" &&
276
- operationResult &&
277
- typeof operationResult === "object" &&
278
- operationResult.__customRender === "logs") {
279
- const logs = operationResult.__logs || [];
280
- const terminalHeight = stdout?.rows || 30;
281
- const viewportHeight = Math.max(10, terminalHeight - 10);
282
- const maxScroll = Math.max(0, logs.length - viewportHeight);
283
- setLogsScroll(maxScroll);
284
- }
285
- else if (input === "w" &&
286
- operationResult &&
287
- typeof operationResult === "object" &&
288
- operationResult.__customRender === "logs") {
289
- setLogsWrapMode(!logsWrapMode);
290
- }
291
- else if (input === "c" &&
292
- operationResult &&
293
- typeof operationResult === "object" &&
294
- operationResult.__customRender === "logs") {
295
- // Copy logs to clipboard
296
- const logs = operationResult.__logs || [];
297
- const logsText = logs
298
- .map((log) => {
299
- const time = new Date(log.timestamp_ms).toLocaleString();
300
- const level = log.level || "INFO";
301
- const source = log.source || "exec";
302
- const message = log.message || "";
303
- const cmd = log.cmd ? `[${log.cmd}] ` : "";
304
- const exitCode = log.exit_code !== null && log.exit_code !== undefined
305
- ? `(${log.exit_code}) `
306
- : "";
307
- return `${time} ${level}/${source} ${exitCode}${cmd}${message}`;
308
- })
309
- .join("\n");
310
- const copyToClipboard = async (text) => {
311
- const { spawn } = await import("child_process");
312
- const platform = process.platform;
313
- let command;
314
- let args;
315
- if (platform === "darwin") {
316
- command = "pbcopy";
317
- args = [];
318
- }
319
- else if (platform === "win32") {
320
- command = "clip";
321
- args = [];
322
- }
323
- else {
324
- command = "xclip";
325
- args = ["-selection", "clipboard"];
326
- }
327
- const proc = spawn(command, args);
328
- proc.stdin.write(text);
329
- proc.stdin.end();
330
- proc.on("exit", (code) => {
331
- if (code === 0) {
332
- setCopyStatus("Copied to clipboard!");
333
- setTimeout(() => setCopyStatus(null), 2000);
334
- }
335
- else {
336
- setCopyStatus("Failed to copy");
337
- setTimeout(() => setCopyStatus(null), 2000);
338
- }
339
- });
340
- proc.on("error", () => {
341
- setCopyStatus("Copy not supported");
342
- setTimeout(() => setCopyStatus(null), 2000);
343
- });
344
- };
345
- copyToClipboard(logsText);
346
- }
347
274
  return;
348
275
  }
349
276
  // Operations selection mode
350
277
  if (input === "q" || key.escape) {
351
- console.clear();
352
- onBack();
278
+ // Clear all state before going back to free memory
279
+ setOperationResult(null);
280
+ setOperationError(null);
281
+ setOperationInput("");
282
+ setExecutingOperation(null);
353
283
  setSelectedOperation(0);
284
+ setLoading(false);
285
+ onBack();
354
286
  }
355
287
  else if (key.upArrow && selectedOperation > 0) {
356
288
  setSelectedOperation(selectedOperation - 1);
@@ -359,7 +291,6 @@ export const DevboxActionsMenu = ({ devbox, onBack, breadcrumbItems = [
359
291
  setSelectedOperation(selectedOperation + 1);
360
292
  }
361
293
  else if (key.return) {
362
- console.clear();
363
294
  const op = operations[selectedOperation].key;
364
295
  setExecutingOperation(op);
365
296
  }
@@ -367,20 +298,17 @@ export const DevboxActionsMenu = ({ devbox, onBack, breadcrumbItems = [
367
298
  // Check if input matches any operation shortcut
368
299
  const matchedOp = operations.find((op) => op.shortcut === input);
369
300
  if (matchedOp) {
370
- console.clear();
371
301
  setExecutingOperation(matchedOp.key);
372
302
  }
373
303
  }
374
304
  });
375
305
  const executeOperation = async () => {
376
- const client = getClient();
377
306
  try {
378
307
  setLoading(true);
379
308
  switch (executingOperation) {
380
309
  case "exec":
381
- const execResult = await client.devboxes.executeSync(devbox.id, {
382
- command: operationInput,
383
- });
310
+ // Use service layer (already truncates output to prevent Yoga crashes)
311
+ const execResult = await execCommand(devbox.id, operationInput);
384
312
  // Format exec result for custom rendering
385
313
  const formattedExecResult = {
386
314
  __customRender: "exec",
@@ -392,23 +320,19 @@ export const DevboxActionsMenu = ({ devbox, onBack, breadcrumbItems = [
392
320
  setOperationResult(formattedExecResult);
393
321
  break;
394
322
  case "upload":
395
- const fs = await import("fs");
396
- const fileStream = fs.createReadStream(operationInput);
323
+ // Use service layer
397
324
  const filename = operationInput.split("/").pop() || "file";
398
- await client.devboxes.uploadFile(devbox.id, {
399
- path: filename,
400
- file: fileStream,
401
- });
325
+ await uploadFile(devbox.id, operationInput, filename);
402
326
  setOperationResult(`File ${filename} uploaded successfully`);
403
327
  break;
404
328
  case "snapshot":
405
- const snapshot = await client.devboxes.snapshotDisk(devbox.id, {
406
- name: operationInput || `snapshot-${Date.now()}`,
407
- });
329
+ // Use service layer
330
+ const snapshot = await createDevboxSnapshot(devbox.id, operationInput || `snapshot-${Date.now()}`);
408
331
  setOperationResult(`Snapshot created: ${snapshot.id}`);
409
332
  break;
410
333
  case "ssh":
411
- const sshKey = await client.devboxes.createSSHKey(devbox.id);
334
+ // Use service layer
335
+ const sshKey = await createSSHKey(devbox.id);
412
336
  const fsModule = await import("fs");
413
337
  const pathModule = await import("path");
414
338
  const osModule = await import("os");
@@ -421,45 +345,45 @@ export const DevboxActionsMenu = ({ devbox, onBack, breadcrumbItems = [
421
345
  const sshUser = devbox.launch_parameters?.user_parameters?.username || "user";
422
346
  const env = process.env.RUNLOOP_ENV?.toLowerCase();
423
347
  const sshHost = env === "dev" ? "ssh.runloop.pro" : "ssh.runloop.ai";
424
- const proxyCommand = `openssl s_client -quiet -verify_quiet -servername %h -connect ${sshHost}:443 2>/dev/null`;
425
- const sshConfig = {
348
+ // macOS openssl doesn't support -verify_quiet, use compatible flags
349
+ // servername should be %h (target hostname) - SSH will replace %h with the actual hostname from the SSH command
350
+ // This matches the reference implementation where servername is the target hostname
351
+ const proxyCommand = `openssl s_client -quiet -servername %h -connect ${sshHost}:443 2>/dev/null`;
352
+ // Navigate to SSH session screen
353
+ navigate("ssh-session", {
426
354
  keyPath,
427
355
  proxyCommand,
428
356
  sshUser,
429
357
  url: sshKey.url,
430
358
  devboxId: devbox.id,
431
359
  devboxName: devbox.name || devbox.id,
432
- };
433
- // Notify parent that SSH is requested
434
- if (onSSHRequest) {
435
- onSSHRequest(sshConfig);
436
- exit();
437
- }
438
- else {
439
- setOperationError(new Error("SSH session handler not configured"));
440
- }
360
+ returnScreen: currentScreen,
361
+ returnParams: params,
362
+ });
441
363
  break;
442
364
  case "logs":
443
- const logsResult = await client.devboxes.logs.list(devbox.id);
444
- if (logsResult.logs.length === 0) {
365
+ // Use service layer (already truncates and escapes log messages)
366
+ const logs = await getDevboxLogs(devbox.id);
367
+ if (logs.length === 0) {
445
368
  setOperationResult("No logs available for this devbox.");
446
369
  }
447
370
  else {
448
- logsResult.__customRender = "logs";
449
- logsResult.__logs = logsResult.logs;
450
- logsResult.__totalCount = logsResult.logs.length;
371
+ const logsResult = {
372
+ __customRender: "logs",
373
+ __logs: logs,
374
+ __totalCount: logs.length,
375
+ };
451
376
  setOperationResult(logsResult);
452
377
  }
453
378
  break;
454
379
  case "tunnel":
380
+ // Use service layer
455
381
  const port = parseInt(operationInput);
456
382
  if (isNaN(port) || port < 1 || port > 65535) {
457
383
  setOperationError(new Error("Invalid port number. Please enter a port between 1 and 65535."));
458
384
  }
459
385
  else {
460
- const tunnel = await client.devboxes.createTunnel(devbox.id, {
461
- port,
462
- });
386
+ const tunnel = await createTunnel(devbox.id, port);
463
387
  setOperationResult(`Tunnel created!\n\n` +
464
388
  `Local Port: ${port}\n` +
465
389
  `Public URL: ${tunnel.url}\n\n` +
@@ -467,15 +391,18 @@ export const DevboxActionsMenu = ({ devbox, onBack, breadcrumbItems = [
467
391
  }
468
392
  break;
469
393
  case "suspend":
470
- await client.devboxes.suspend(devbox.id);
394
+ // Use service layer
395
+ await suspendDevbox(devbox.id);
471
396
  setOperationResult(`Devbox ${devbox.id} suspended successfully`);
472
397
  break;
473
398
  case "resume":
474
- await client.devboxes.resume(devbox.id);
399
+ // Use service layer
400
+ await resumeDevbox(devbox.id);
475
401
  setOperationResult(`Devbox ${devbox.id} resumed successfully`);
476
402
  break;
477
403
  case "delete":
478
- await client.devboxes.shutdown(devbox.id);
404
+ // Use service layer
405
+ await shutdownDevbox(devbox.id);
479
406
  setOperationResult(`Devbox ${devbox.id} shut down successfully`);
480
407
  break;
481
408
  }
@@ -501,8 +428,7 @@ export const DevboxActionsMenu = ({ devbox, onBack, breadcrumbItems = [
501
428
  const stdoutLines = stdout ? stdout.split("\n") : [];
502
429
  const stderrLines = stderr ? stderr.split("\n") : [];
503
430
  const allLines = [...stdoutLines, ...stderrLines].filter((line) => line !== "");
504
- const terminalHeight = stdout?.rows || 30;
505
- const viewportHeight = Math.max(10, terminalHeight - 15);
431
+ const viewportHeight = execViewport.viewportHeight;
506
432
  const maxScroll = Math.max(0, allLines.length - viewportHeight);
507
433
  const actualScroll = Math.min(execScroll, maxScroll);
508
434
  const visibleLines = allLines.slice(actualScroll, actualScroll + viewportHeight);
@@ -512,58 +438,37 @@ export const DevboxActionsMenu = ({ devbox, onBack, breadcrumbItems = [
512
438
  return (_jsxs(_Fragment, { children: [_jsx(Breadcrumb, { items: [
513
439
  ...breadcrumbItems,
514
440
  { label: "Execute Command", active: true },
515
- ] }), _jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: colors.primary, paddingX: 1, marginBottom: 1, children: [_jsxs(Box, { children: [_jsxs(Text, { color: colors.primary, bold: true, children: [figures.play, " Command:"] }), _jsx(Text, { children: " " }), _jsx(Text, { color: colors.text, children: command })] }), _jsxs(Box, { children: [_jsxs(Text, { color: colors.textDim, dimColor: true, children: ["Exit Code:", " "] }), _jsx(Text, { color: exitCodeColor, bold: true, children: exitCode })] })] }), _jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: colors.border, paddingX: 1, children: [allLines.length === 0 && (_jsx(Text, { color: colors.textDim, dimColor: true, children: "No output" })), visibleLines.map((line, index) => {
441
+ ] }), _jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: colors.primary, paddingX: 1, marginBottom: 1, children: [_jsxs(Box, { children: [_jsxs(Text, { color: colors.primary, bold: true, children: [figures.play, " Command:"] }), _jsx(Text, { children: " " }), _jsx(Text, { color: colors.text, children: command.length > 500
442
+ ? command.substring(0, 500) + "..."
443
+ : command })] }), _jsxs(Box, { children: [_jsxs(Text, { color: colors.textDim, dimColor: true, children: ["Exit Code:", " "] }), _jsx(Text, { color: exitCodeColor, bold: true, children: exitCode })] })] }), _jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: colors.border, paddingX: 1, children: [allLines.length === 0 && (_jsx(Text, { color: colors.textDim, dimColor: true, children: "No output" })), visibleLines.map((line, index) => {
516
444
  const actualIndex = actualScroll + index;
517
445
  const isStderr = actualIndex >= stdoutLines.length;
518
446
  const lineColor = isStderr ? colors.error : colors.text;
519
447
  return (_jsx(Box, { children: _jsx(Text, { color: lineColor, children: line }) }, index));
520
- }), hasLess && (_jsx(Box, { marginTop: 1, children: _jsxs(Text, { color: colors.primary, children: [figures.arrowUp, " More above"] }) })), hasMore && (_jsx(Box, { marginTop: hasLess ? 0 : 1, children: _jsxs(Text, { color: colors.primary, children: [figures.arrowDown, " More below"] }) }))] }), _jsxs(Box, { marginTop: 1, paddingX: 1, children: [_jsxs(Text, { color: colors.primary, bold: true, children: [figures.hamburger, " ", allLines.length] }), _jsxs(Text, { color: colors.textDim, dimColor: true, children: [" ", "lines"] }), allLines.length > 0 && (_jsxs(_Fragment, { children: [_jsxs(Text, { color: colors.textDim, dimColor: true, children: [" ", "\u2022", " "] }), _jsxs(Text, { color: colors.textDim, dimColor: true, children: ["Viewing ", actualScroll + 1, "-", Math.min(actualScroll + viewportHeight, allLines.length), " of", " ", allLines.length] })] })), stdout && (_jsxs(_Fragment, { children: [_jsxs(Text, { color: colors.textDim, dimColor: true, children: [" ", "\u2022", " "] }), _jsxs(Text, { color: colors.success, dimColor: true, children: ["stdout: ", stdoutLines.length, " lines"] })] })), stderr && (_jsxs(_Fragment, { children: [_jsxs(Text, { color: colors.textDim, dimColor: true, children: [" ", "\u2022", " "] }), _jsxs(Text, { color: colors.error, dimColor: true, children: ["stderr: ", stderrLines.length, " lines"] })] })), copyStatus && (_jsxs(_Fragment, { children: [_jsxs(Text, { color: colors.textDim, dimColor: true, children: [" ", "\u2022", " "] }), _jsx(Text, { color: colors.success, bold: true, children: copyStatus })] }))] }), _jsx(Box, { marginTop: 1, paddingX: 1, children: _jsxs(Text, { color: colors.textDim, dimColor: true, children: [figures.arrowUp, figures.arrowDown, " Navigate \u2022 [g] Top \u2022 [G] Bottom \u2022 [c] Copy \u2022 [Enter], [q], or [esc] Back"] }) })] }));
448
+ })] }), _jsxs(Box, { marginTop: 1, paddingX: 1, children: [_jsxs(Text, { color: colors.primary, bold: true, children: [figures.hamburger, " ", allLines.length] }), _jsxs(Text, { color: colors.textDim, dimColor: true, children: [" ", "lines"] }), allLines.length > 0 && (_jsxs(_Fragment, { children: [_jsxs(Text, { color: colors.textDim, dimColor: true, children: [" ", "\u2022", " "] }), _jsxs(Text, { color: colors.textDim, dimColor: true, children: ["Viewing ", actualScroll + 1, "-", Math.min(actualScroll + viewportHeight, allLines.length), " of", " ", allLines.length] }), hasLess && (_jsxs(Text, { color: colors.primary, children: [" ", figures.arrowUp] })), hasMore && (_jsxs(Text, { color: colors.primary, children: [" ", figures.arrowDown] }))] })), stdout && (_jsxs(_Fragment, { children: [_jsxs(Text, { color: colors.textDim, dimColor: true, children: [" ", "\u2022", " "] }), _jsxs(Text, { color: colors.success, dimColor: true, children: ["stdout: ", stdoutLines.length, " lines"] })] })), stderr && (_jsxs(_Fragment, { children: [_jsxs(Text, { color: colors.textDim, dimColor: true, children: [" ", "\u2022", " "] }), _jsxs(Text, { color: colors.error, dimColor: true, children: ["stderr: ", stderrLines.length, " lines"] })] })), copyStatus && (_jsxs(_Fragment, { children: [_jsxs(Text, { color: colors.textDim, dimColor: true, children: [" ", "\u2022", " "] }), _jsx(Text, { color: colors.success, bold: true, children: copyStatus })] }))] }), _jsx(Box, { marginTop: 1, paddingX: 1, children: _jsxs(Text, { color: colors.textDim, dimColor: true, children: [figures.arrowUp, figures.arrowDown, " Navigate \u2022 [g] Top \u2022 [G] Bottom \u2022 [c] Copy \u2022 [Enter], [q], or [esc] Back"] }) })] }));
521
449
  }
522
450
  // Check for custom logs rendering
523
451
  if (operationResult &&
524
452
  typeof operationResult === "object" &&
525
453
  operationResult.__customRender === "logs") {
526
454
  const logs = operationResult.__logs || [];
527
- const totalCount = operationResult.__totalCount || 0;
528
- const terminalHeight = stdout?.rows || 30;
529
- const terminalWidth = stdout?.columns || 120;
530
- const viewportHeight = Math.max(10, terminalHeight - 10);
531
- const maxScroll = Math.max(0, logs.length - viewportHeight);
532
- const actualScroll = Math.min(logsScroll, maxScroll);
533
- const visibleLogs = logs.slice(actualScroll, actualScroll + viewportHeight);
534
- const hasMore = actualScroll + viewportHeight < logs.length;
535
- const hasLess = actualScroll > 0;
536
- return (_jsxs(_Fragment, { children: [_jsx(Breadcrumb, { items: [...breadcrumbItems, { label: "Logs", active: true }] }), _jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: colors.border, paddingX: 1, children: [visibleLogs.map((log, index) => {
537
- const time = new Date(log.timestamp_ms).toLocaleTimeString();
538
- const level = log.level ? log.level[0].toUpperCase() : "I";
539
- const source = log.source ? log.source.substring(0, 8) : "exec";
540
- const fullMessage = log.message || "";
541
- const cmd = log.cmd
542
- ? `[${log.cmd.substring(0, 40)}${log.cmd.length > 40 ? "..." : ""}] `
543
- : "";
544
- const exitCode = log.exit_code !== null && log.exit_code !== undefined
545
- ? `(${log.exit_code}) `
546
- : "";
547
- let levelColor = colors.textDim;
548
- if (level === "E")
549
- levelColor = colors.error;
550
- else if (level === "W")
551
- levelColor = colors.warning;
552
- else if (level === "I")
553
- levelColor = colors.primary;
554
- if (logsWrapMode) {
555
- return (_jsxs(Box, { children: [_jsx(Text, { color: colors.textDim, dimColor: true, children: time }), _jsx(Text, { children: " " }), _jsx(Text, { color: levelColor, bold: true, children: level }), _jsxs(Text, { color: colors.textDim, dimColor: true, children: ["/", source] }), _jsx(Text, { children: " " }), exitCode && _jsx(Text, { color: colors.warning, children: exitCode }), cmd && (_jsx(Text, { color: colors.info, dimColor: true, children: cmd })), _jsx(Text, { children: fullMessage })] }, index));
556
- }
557
- else {
558
- const metadataWidth = 11 + 1 + 1 + 1 + 8 + 1 + exitCode.length + cmd.length + 6;
559
- const availableMessageWidth = Math.max(20, terminalWidth - metadataWidth);
560
- const truncatedMessage = fullMessage.length > availableMessageWidth
561
- ? fullMessage.substring(0, availableMessageWidth - 3) +
562
- "..."
563
- : fullMessage;
564
- return (_jsxs(Box, { children: [_jsx(Text, { color: colors.textDim, dimColor: true, children: time }), _jsx(Text, { children: " " }), _jsx(Text, { color: levelColor, bold: true, children: level }), _jsxs(Text, { color: colors.textDim, dimColor: true, children: ["/", source] }), _jsx(Text, { children: " " }), exitCode && _jsx(Text, { color: colors.warning, children: exitCode }), cmd && (_jsx(Text, { color: colors.info, dimColor: true, children: cmd })), _jsx(Text, { children: truncatedMessage })] }, index));
565
- }
566
- }), hasLess && (_jsx(Box, { children: _jsxs(Text, { color: colors.primary, children: [figures.arrowUp, " More above"] }) })), hasMore && (_jsx(Box, { children: _jsxs(Text, { color: colors.primary, children: [figures.arrowDown, " More below"] }) }))] }), _jsxs(Box, { marginTop: 1, paddingX: 1, children: [_jsxs(Text, { color: colors.primary, bold: true, children: [figures.hamburger, " ", totalCount] }), _jsxs(Text, { color: colors.textDim, dimColor: true, children: [" ", "total logs"] }), _jsxs(Text, { color: colors.textDim, dimColor: true, children: [" ", "\u2022", " "] }), _jsxs(Text, { color: colors.textDim, dimColor: true, children: ["Viewing ", actualScroll + 1, "-", Math.min(actualScroll + viewportHeight, logs.length), " of", " ", logs.length] }), _jsxs(Text, { color: colors.textDim, dimColor: true, children: [" ", "\u2022", " "] }), _jsx(Text, { color: logsWrapMode ? colors.success : colors.textDim, bold: logsWrapMode, children: logsWrapMode ? "Wrap: ON" : "Wrap: OFF" }), copyStatus && (_jsxs(_Fragment, { children: [_jsxs(Text, { color: colors.textDim, dimColor: true, children: [" ", "\u2022", " "] }), _jsx(Text, { color: colors.success, bold: true, children: copyStatus })] }))] }), _jsx(Box, { marginTop: 1, paddingX: 1, children: _jsxs(Text, { color: colors.textDim, dimColor: true, children: [figures.arrowUp, figures.arrowDown, " Navigate \u2022 [g] Top \u2022 [G] Bottom \u2022 [w] Toggle Wrap \u2022 [c] Copy \u2022 [Enter], [q], or [esc] Back"] }) })] }));
455
+ return (_jsx(LogsViewer, { logs: logs, breadcrumbItems: [
456
+ ...breadcrumbItems,
457
+ { label: "Logs", active: true },
458
+ ], onBack: () => {
459
+ // Clear large data structures immediately to prevent memory leaks
460
+ setOperationResult(null);
461
+ setOperationError(null);
462
+ setOperationInput("");
463
+ // If skipOperationsMenu is true, go back to parent instead of operations menu
464
+ if (skipOperationsMenu) {
465
+ setExecutingOperation(null);
466
+ onBack();
467
+ }
468
+ else {
469
+ setExecutingOperation(null);
470
+ }
471
+ }, title: "Logs" }));
567
472
  }
568
473
  return (_jsxs(_Fragment, { children: [_jsx(Breadcrumb, { items: [...breadcrumbItems, { label: operationLabel, active: true }] }), _jsx(Header, { title: "Operation Result" }), operationResult && _jsx(SuccessMessage, { message: operationResult }), operationError && (_jsx(ErrorMessage, { message: "Operation failed", error: operationError })), _jsx(Box, { marginTop: 1, children: _jsx(Text, { color: colors.textDim, dimColor: true, children: "Press [Enter], [q], or [esc] to continue" }) })] }));
569
474
  }
@@ -598,7 +503,12 @@ export const DevboxActionsMenu = ({ devbox, onBack, breadcrumbItems = [
598
503
  snapshot: "Snapshot name (optional):",
599
504
  tunnel: "Port number to expose:",
600
505
  };
601
- return (_jsxs(_Fragment, { children: [_jsx(Breadcrumb, { items: [...breadcrumbItems, { label: operationLabel, active: true }] }), _jsx(Header, { title: operationLabel }), _jsxs(Box, { flexDirection: "column", marginBottom: 1, children: [_jsx(Box, { marginBottom: 1, children: _jsx(Text, { color: colors.primary, bold: true, children: devbox.name || devbox.id }) }), _jsx(Box, { children: _jsxs(Text, { color: colors.textDim, children: [prompts[executingOperation], " "] }) }), _jsx(Box, { marginTop: 1, children: _jsx(TextInput, { value: operationInput, onChange: setOperationInput, placeholder: executingOperation === "exec"
506
+ return (_jsxs(_Fragment, { children: [_jsx(Breadcrumb, { items: [...breadcrumbItems, { label: operationLabel, active: true }] }), _jsx(Header, { title: operationLabel }), _jsxs(Box, { flexDirection: "column", marginBottom: 1, children: [_jsx(Box, { marginBottom: 1, children: _jsx(Text, { color: colors.primary, bold: true, children: (() => {
507
+ const name = devbox.name || devbox.id;
508
+ return name.length > 100
509
+ ? name.substring(0, 100) + "..."
510
+ : name;
511
+ })() }) }), _jsx(Box, { children: _jsxs(Text, { color: colors.textDim, children: [prompts[executingOperation], " "] }) }), _jsx(Box, { marginTop: 1, children: _jsx(TextInput, { value: operationInput, onChange: setOperationInput, placeholder: executingOperation === "exec"
602
512
  ? "ls -la"
603
513
  : executingOperation === "upload"
604
514
  ? "/path/to/file"
@@ -608,7 +518,7 @@ export const DevboxActionsMenu = ({ devbox, onBack, breadcrumbItems = [
608
518
  }
609
519
  // Operations selection mode - only show if not skipping
610
520
  if (!skipOperationsMenu || !executingOperation) {
611
- return (_jsxs(_Fragment, { children: [_jsx(Breadcrumb, { items: breadcrumbItems, showVersionCheck: true }), _jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { color: colors.primary, bold: true, children: [figures.play, " Operations"] }), _jsx(Box, { flexDirection: "column", children: operations.map((op, index) => {
521
+ return (_jsxs(_Fragment, { children: [_jsx(Breadcrumb, { items: breadcrumbItems }), _jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { color: colors.primary, bold: true, children: [figures.play, " Operations"] }), _jsx(Box, { flexDirection: "column", children: operations.map((op, index) => {
612
522
  const isSelected = index === selectedOperation;
613
523
  return (_jsxs(Box, { children: [_jsxs(Text, { color: isSelected ? colors.primary : colors.textDim, children: [isSelected ? figures.pointer : " ", " "] }), _jsxs(Text, { color: isSelected ? op.color : colors.textDim, bold: isSelected, children: [op.icon, " ", op.label] }), _jsxs(Text, { color: colors.textDim, dimColor: true, children: [" ", "[", op.shortcut, "]"] })] }, op.key));
614
524
  }) })] }), _jsx(Box, { marginTop: 1, children: _jsxs(Text, { color: colors.textDim, dimColor: true, children: [figures.arrowUp, figures.arrowDown, " Navigate \u2022 [Enter] Select \u2022 [q] Back"] }) })] }));
@@ -1,4 +1,4 @@
1
- import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
2
  import React from "react";
3
3
  import { Box, Text, useInput } from "ink";
4
4
  import TextInput from "ink-text-input";
@@ -10,6 +10,7 @@ import { SuccessMessage } from "./SuccessMessage.js";
10
10
  import { Breadcrumb } from "./Breadcrumb.js";
11
11
  import { MetadataDisplay } from "./MetadataDisplay.js";
12
12
  import { colors } from "../utils/theme.js";
13
+ import { useExitOnCtrlC } from "../hooks/useExitOnCtrlC.js";
13
14
  export const DevboxCreatePage = ({ onBack, onCreate, initialBlueprintId, }) => {
14
15
  const [currentField, setCurrentField] = React.useState("create");
15
16
  const [formData, setFormData] = React.useState({
@@ -68,6 +69,8 @@ export const DevboxCreatePage = ({ onBack, onCreate, initialBlueprintId, }) => {
68
69
  "CUSTOM_SIZE",
69
70
  ];
70
71
  const currentFieldIndex = fields.findIndex((f) => f.key === currentField);
72
+ // Handle Ctrl+C to exit
73
+ useExitOnCtrlC();
71
74
  useInput((input, key) => {
72
75
  // Handle result screen
73
76
  if (result) {
@@ -97,7 +100,6 @@ export const DevboxCreatePage = ({ onBack, onCreate, initialBlueprintId, }) => {
97
100
  }
98
101
  // Back to list
99
102
  if (input === "q" || key.escape) {
100
- console.clear();
101
103
  onBack();
102
104
  return;
103
105
  }
@@ -241,10 +243,13 @@ export const DevboxCreatePage = ({ onBack, onCreate, initialBlueprintId, }) => {
241
243
  });
242
244
  }
243
245
  else if (currentField === "resource_size") {
244
- const currentIndex = resourceSizes.indexOf(formData.resource_size);
246
+ // Find current index, defaulting to 0 if not found (e.g., empty string)
247
+ const currentSize = formData.resource_size || "SMALL";
248
+ const currentIndex = resourceSizes.indexOf(currentSize);
249
+ const safeIndex = currentIndex === -1 ? 0 : currentIndex;
245
250
  const newIndex = key.leftArrow
246
- ? Math.max(0, currentIndex - 1)
247
- : Math.min(resourceSizes.length - 1, currentIndex + 1);
251
+ ? Math.max(0, safeIndex - 1)
252
+ : Math.min(resourceSizes.length - 1, safeIndex + 1);
248
253
  setFormData({
249
254
  ...formData,
250
255
  resource_size: resourceSizes[newIndex],
@@ -339,7 +344,7 @@ export const DevboxCreatePage = ({ onBack, onCreate, initialBlueprintId, }) => {
339
344
  };
340
345
  // Result screen
341
346
  if (result) {
342
- return (_jsxs(_Fragment, { children: [_jsx(Breadcrumb, { items: [{ label: "Devboxes" }, { label: "Create", active: true }] }), _jsx(SuccessMessage, { message: "Devbox created successfully!", details: `ID: ${result.id}\nName: ${result.name || "(none)"}\nStatus: ${result.status}` }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { color: colors.textDim, dimColor: true, children: "Press [Enter], [q], or [esc] to return to list" }) })] }));
347
+ return (_jsxs(_Fragment, { children: [_jsx(Breadcrumb, { items: [{ label: "Devboxes" }, { label: "Create", active: true }] }), _jsx(SuccessMessage, { message: "Devbox created successfully!" }), _jsxs(Box, { marginLeft: 2, flexDirection: "column", marginTop: 1, children: [_jsxs(Box, { children: [_jsxs(Text, { color: colors.textDim, dimColor: true, children: ["ID:", " "] }), _jsx(Text, { color: colors.idColor, children: result.id })] }), _jsx(Box, { children: _jsxs(Text, { color: colors.textDim, dimColor: true, children: ["Name: ", result.name || "(none)"] }) }), _jsx(Box, { children: _jsxs(Text, { color: colors.textDim, dimColor: true, children: ["Status: ", result.status] }) })] }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { color: colors.textDim, dimColor: true, children: "Press [Enter], [q], or [esc] to return to list" }) })] }));
343
348
  }
344
349
  // Error screen
345
350
  if (error) {
@@ -350,7 +355,7 @@ export const DevboxCreatePage = ({ onBack, onCreate, initialBlueprintId, }) => {
350
355
  return (_jsxs(_Fragment, { children: [_jsx(Breadcrumb, { items: [{ label: "Devboxes" }, { label: "Create", active: true }] }), _jsx(SpinnerComponent, { message: "Creating devbox..." })] }));
351
356
  }
352
357
  // Form screen
353
- return (_jsxs(_Fragment, { children: [_jsx(Breadcrumb, { items: [{ label: "Devboxes" }, { label: "Create", active: true }] }), _jsx(Box, { flexDirection: "column", marginBottom: 1, children: fields.map((field, index) => {
358
+ return (_jsxs(_Fragment, { children: [_jsx(Breadcrumb, { items: [{ label: "Devboxes" }, { label: "Create", active: true }] }), _jsx(Box, { flexDirection: "column", marginBottom: 1, children: fields.map((field) => {
354
359
  const isActive = currentField === field.key;
355
360
  const fieldData = formData[field.key];
356
361
  if (field.type === "action") {