@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,6 +1,6 @@
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, useStdout } from "ink";
3
+ import { Box, Text, useInput } from "ink";
4
4
  import figures from "figures";
5
5
  import { Header } from "./Header.js";
6
6
  import { StatusBadge } from "./StatusBadge.js";
@@ -9,6 +9,9 @@ import { Breadcrumb } from "./Breadcrumb.js";
9
9
  import { DevboxActionsMenu } from "./DevboxActionsMenu.js";
10
10
  import { getDevboxUrl } from "../utils/url.js";
11
11
  import { colors } from "../utils/theme.js";
12
+ import { useViewportHeight } from "../hooks/useViewportHeight.js";
13
+ import { useExitOnCtrlC } from "../hooks/useExitOnCtrlC.js";
14
+ import { getDevbox } from "../services/devboxService.js";
12
15
  // Format time ago in a succinct way
13
16
  const formatTimeAgo = (timestamp) => {
14
17
  const seconds = Math.floor((Date.now() - timestamp) / 1000);
@@ -29,13 +32,53 @@ const formatTimeAgo = (timestamp) => {
29
32
  const years = Math.floor(months / 12);
30
33
  return `${years}y ago`;
31
34
  };
32
- export const DevboxDetailPage = ({ devbox: initialDevbox, onBack, onSSHRequest, }) => {
33
- const { stdout } = useStdout();
35
+ export const DevboxDetailPage = ({ devbox: initialDevbox, onBack, }) => {
36
+ const isMounted = React.useRef(true);
37
+ // Track mounted state
38
+ React.useEffect(() => {
39
+ isMounted.current = true;
40
+ return () => {
41
+ isMounted.current = false;
42
+ };
43
+ }, []);
44
+ // Local state for devbox data (updated by polling)
45
+ const [currentDevbox, setCurrentDevbox] = React.useState(initialDevbox);
34
46
  const [showDetailedInfo, setShowDetailedInfo] = React.useState(false);
35
47
  const [detailScroll, setDetailScroll] = React.useState(0);
36
48
  const [showActions, setShowActions] = React.useState(false);
37
49
  const [selectedOperation, setSelectedOperation] = React.useState(0);
38
- const selectedDevbox = initialDevbox;
50
+ // Background polling for devbox details
51
+ React.useEffect(() => {
52
+ // Skip polling if showing actions, detailed info, or not mounted
53
+ if (showActions || showDetailedInfo)
54
+ return;
55
+ const interval = setInterval(async () => {
56
+ // Only poll when not in actions/detail mode and component is mounted
57
+ if (!showActions && !showDetailedInfo && isMounted.current) {
58
+ try {
59
+ const updatedDevbox = await getDevbox(initialDevbox.id);
60
+ // Only update if still mounted
61
+ if (isMounted.current) {
62
+ setCurrentDevbox(updatedDevbox);
63
+ }
64
+ }
65
+ catch {
66
+ // Silently ignore polling errors to avoid disrupting user experience
67
+ }
68
+ }
69
+ }, 3000); // Poll every 3 seconds
70
+ return () => clearInterval(interval);
71
+ }, [initialDevbox.id, showActions, showDetailedInfo]);
72
+ // Calculate viewport for detailed info view:
73
+ // - Breadcrumb (3 lines + marginBottom): 4 lines
74
+ // - Header (title + underline + marginBottom): 3 lines
75
+ // - Status box (content + marginBottom): 2 lines
76
+ // - Content box (marginTop + border + paddingY top/bottom + border + marginBottom): 6 lines
77
+ // - Help bar (marginTop + content): 2 lines
78
+ // - Safety buffer: 1 line
79
+ // Total: 18 lines
80
+ const detailViewport = useViewportHeight({ overhead: 18, minHeight: 10 });
81
+ const selectedDevbox = currentDevbox;
39
82
  const allOperations = [
40
83
  {
41
84
  key: "logs",
@@ -123,14 +166,18 @@ export const DevboxDetailPage = ({ devbox: initialDevbox, onBack, onSSHRequest,
123
166
  return op.key === "logs" || op.key === "delete";
124
167
  })
125
168
  : allOperations;
126
- // Memoize time-based values to prevent re-rendering on every tick
127
- const formattedCreateTime = React.useMemo(() => selectedDevbox.create_time_ms
169
+ const formattedCreateTime = selectedDevbox.create_time_ms
128
170
  ? new Date(selectedDevbox.create_time_ms).toLocaleString()
129
- : "", [selectedDevbox.create_time_ms]);
130
- const createTimeAgo = React.useMemo(() => selectedDevbox.create_time_ms
171
+ : "";
172
+ const createTimeAgo = selectedDevbox.create_time_ms
131
173
  ? formatTimeAgo(selectedDevbox.create_time_ms)
132
- : "", [selectedDevbox.create_time_ms]);
174
+ : "";
175
+ // Handle Ctrl+C to exit
176
+ useExitOnCtrlC();
133
177
  useInput((input, key) => {
178
+ // Don't process input if unmounting
179
+ if (!isMounted.current)
180
+ return;
134
181
  // Skip input handling when in actions view
135
182
  if (showActions) {
136
183
  return;
@@ -161,7 +208,6 @@ export const DevboxDetailPage = ({ devbox: initialDevbox, onBack, onSSHRequest,
161
208
  }
162
209
  // Main view input handling
163
210
  if (input === "q" || key.escape) {
164
- console.clear();
165
211
  onBack();
166
212
  }
167
213
  else if (input === "i") {
@@ -175,7 +221,6 @@ export const DevboxDetailPage = ({ devbox: initialDevbox, onBack, onSSHRequest,
175
221
  setSelectedOperation(selectedOperation + 1);
176
222
  }
177
223
  else if (key.return || input === "a") {
178
- console.clear();
179
224
  setShowActions(true);
180
225
  }
181
226
  else if (input) {
@@ -183,7 +228,6 @@ export const DevboxDetailPage = ({ devbox: initialDevbox, onBack, onSSHRequest,
183
228
  const matchedOpIndex = operations.findIndex((op) => op.shortcut === input);
184
229
  if (matchedOpIndex !== -1) {
185
230
  setSelectedOperation(matchedOpIndex);
186
- console.clear();
187
231
  setShowActions(true);
188
232
  }
189
233
  }
@@ -217,10 +261,12 @@ export const DevboxDetailPage = ({ devbox: initialDevbox, onBack, onSSHRequest,
217
261
  const capitalize = (str) => str.charAt(0).toUpperCase() + str.slice(1);
218
262
  // Core Information
219
263
  lines.push(_jsx(Text, { color: colors.warning, bold: true, children: "Devbox Details" }, "core-title"));
220
- lines.push(_jsxs(Text, { dimColor: true, children: [" ", "ID: ", selectedDevbox.id] }, "core-id"));
264
+ lines.push(_jsxs(Text, { color: colors.idColor, children: [" ", "ID: ", selectedDevbox.id] }, "core-id"));
221
265
  lines.push(_jsxs(Text, { dimColor: true, children: [" ", "Name: ", selectedDevbox.name || "(none)"] }, "core-name"));
222
266
  lines.push(_jsxs(Text, { dimColor: true, children: [" ", "Status: ", capitalize(selectedDevbox.status)] }, "core-status"));
223
- lines.push(_jsxs(Text, { dimColor: true, children: [" ", "Created: ", new Date(selectedDevbox.create_time_ms).toLocaleString()] }, "core-created"));
267
+ if (selectedDevbox.create_time_ms) {
268
+ lines.push(_jsxs(Text, { dimColor: true, children: [" ", "Created: ", new Date(selectedDevbox.create_time_ms).toLocaleString()] }, "core-created"));
269
+ }
224
270
  if (selectedDevbox.end_time_ms) {
225
271
  lines.push(_jsxs(Text, { dimColor: true, children: [" ", "Ended: ", new Date(selectedDevbox.end_time_ms).toLocaleString()] }, "core-ended"));
226
272
  }
@@ -263,9 +309,14 @@ export const DevboxDetailPage = ({ devbox: initialDevbox, onBack, onSSHRequest,
263
309
  }
264
310
  if (lp.launch_commands && lp.launch_commands.length > 0) {
265
311
  lines.push(_jsxs(Text, { dimColor: true, children: [" ", "Launch Commands:"] }, "launch-launch-cmds"));
266
- lp.launch_commands.forEach((cmd, idx) => {
267
- lines.push(_jsxs(Text, { dimColor: true, children: [" ", figures.pointer, " ", cmd] }, `launch-cmd-${idx}`));
268
- });
312
+ // lp.launch_commands.forEach((cmd: string, idx: number) => {
313
+ // lines.push(
314
+ // <Text key={`launch-cmd-${idx}`} dimColor>
315
+ // {" "}
316
+ // {figures.pointer} {cmd}
317
+ // </Text>,
318
+ // );
319
+ // });
269
320
  }
270
321
  if (lp.required_services && lp.required_services.length > 0) {
271
322
  lines.push(_jsxs(Text, { dimColor: true, children: [" ", "Required Services: ", lp.required_services.join(", ")] }, "launch-services"));
@@ -285,10 +336,10 @@ export const DevboxDetailPage = ({ devbox: initialDevbox, onBack, onSSHRequest,
285
336
  if (selectedDevbox.blueprint_id || selectedDevbox.snapshot_id) {
286
337
  lines.push(_jsx(Text, { color: colors.warning, bold: true, children: "Source" }, "source-title"));
287
338
  if (selectedDevbox.blueprint_id) {
288
- lines.push(_jsxs(Text, { dimColor: true, children: [" ", "Blueprint: ", selectedDevbox.blueprint_id] }, "source-bp"));
339
+ lines.push(_jsxs(Text, { color: colors.idColor, children: [" ", selectedDevbox.blueprint_id] }, "source-bp"));
289
340
  }
290
341
  if (selectedDevbox.snapshot_id) {
291
- lines.push(_jsxs(Text, { dimColor: true, children: [" ", "Snapshot: ", selectedDevbox.snapshot_id] }, "source-snap"));
342
+ lines.push(_jsxs(Text, { color: colors.idColor, children: [" ", selectedDevbox.snapshot_id] }, "source-snap"));
292
343
  }
293
344
  lines.push(_jsx(Text, { children: " " }, "source-space"));
294
345
  }
@@ -297,7 +348,7 @@ export const DevboxDetailPage = ({ devbox: initialDevbox, onBack, onSSHRequest,
297
348
  lines.push(_jsx(Text, { color: colors.warning, bold: true, children: "Initiator" }, "init-title"));
298
349
  lines.push(_jsxs(Text, { dimColor: true, children: [" ", "Type: ", selectedDevbox.initiator_type] }, "init-type"));
299
350
  if (selectedDevbox.initiator_id) {
300
- lines.push(_jsxs(Text, { dimColor: true, children: [" ", "ID: ", selectedDevbox.initiator_id] }, "init-id"));
351
+ lines.push(_jsxs(Text, { color: colors.idColor, children: [" ", "ID: ", selectedDevbox.initiator_id] }, "init-id"));
301
352
  }
302
353
  lines.push(_jsx(Text, { children: " " }, "init-space"));
303
354
  }
@@ -348,13 +399,12 @@ export const DevboxDetailPage = ({ devbox: initialDevbox, onBack, onSSHRequest,
348
399
  }, breadcrumbItems: [
349
400
  { label: "Devboxes" },
350
401
  { label: selectedDevbox.name || selectedDevbox.id },
351
- ], initialOperation: selectedOp?.key, skipOperationsMenu: true, onSSHRequest: onSSHRequest }));
402
+ ], initialOperation: selectedOp?.key, skipOperationsMenu: true }));
352
403
  }
353
404
  // Detailed info mode - full screen
354
405
  if (showDetailedInfo) {
355
406
  const detailLines = buildDetailLines();
356
- const terminalHeight = stdout?.rows || 30;
357
- const viewportHeight = Math.max(10, terminalHeight - 12); // Reserve space for header/footer
407
+ const viewportHeight = detailViewport.viewportHeight;
358
408
  const maxScroll = Math.max(0, detailLines.length - viewportHeight);
359
409
  const actualScroll = Math.min(detailScroll, maxScroll);
360
410
  const visibleLines = detailLines.slice(actualScroll, actualScroll + viewportHeight);
@@ -364,7 +414,7 @@ export const DevboxDetailPage = ({ devbox: initialDevbox, onBack, onSSHRequest,
364
414
  { label: "Devboxes" },
365
415
  { label: selectedDevbox.name || selectedDevbox.id },
366
416
  { label: "Full Details", active: true },
367
- ] }), _jsx(Header, { title: `${selectedDevbox.name || selectedDevbox.id} - Complete Information` }), _jsx(Box, { flexDirection: "column", marginBottom: 1, children: _jsxs(Box, { marginBottom: 1, children: [_jsx(StatusBadge, { status: selectedDevbox.status }), _jsx(Text, { children: " " }), _jsx(Text, { color: colors.textDim, dimColor: true, children: selectedDevbox.id })] }) }), _jsxs(Box, { flexDirection: "column", marginTop: 1, marginBottom: 1, borderStyle: "round", borderColor: colors.border, paddingX: 2, paddingY: 1, children: [_jsx(Box, { flexDirection: "column", children: visibleLines }), 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"] }) }))] }), _jsx(Box, { marginTop: 1, children: _jsxs(Text, { color: colors.textDim, dimColor: true, children: [figures.arrowUp, figures.arrowDown, " Scroll \u2022 [q or esc] Back to Details \u2022 Line", " ", actualScroll + 1, "-", Math.min(actualScroll + viewportHeight, detailLines.length), " of", " ", detailLines.length] }) })] }));
417
+ ] }), _jsx(Header, { title: `${selectedDevbox.name || selectedDevbox.id} - Complete Information` }), _jsx(Box, { flexDirection: "column", marginBottom: 1, children: _jsxs(Box, { marginBottom: 1, children: [_jsx(StatusBadge, { status: selectedDevbox.status }), _jsx(Text, { children: " " }), _jsx(Text, { color: colors.idColor, children: selectedDevbox.id })] }) }), _jsx(Box, { flexDirection: "column", marginTop: 1, marginBottom: 1, borderStyle: "round", borderColor: colors.border, paddingX: 2, paddingY: 1, children: _jsx(Box, { flexDirection: "column", children: visibleLines }) }), _jsxs(Box, { marginTop: 1, children: [_jsxs(Text, { color: colors.textDim, dimColor: true, children: [figures.arrowUp, figures.arrowDown, " Scroll \u2022 Line ", actualScroll + 1, "-", Math.min(actualScroll + viewportHeight, detailLines.length), " of", " ", detailLines.length] }), hasLess && _jsxs(Text, { color: colors.primary, children: [" ", figures.arrowUp] }), hasMore && _jsxs(Text, { color: colors.primary, children: [" ", figures.arrowDown] }), _jsxs(Text, { color: colors.textDim, dimColor: true, children: [" ", "\u2022 [q or esc] Back to Details"] })] })] }));
368
418
  }
369
419
  // Main detail view
370
420
  const lp = selectedDevbox.launch_parameters;
@@ -374,7 +424,7 @@ export const DevboxDetailPage = ({ devbox: initialDevbox, onBack, onSSHRequest,
374
424
  return (_jsxs(_Fragment, { children: [_jsx(Breadcrumb, { items: [
375
425
  { label: "Devboxes" },
376
426
  { label: selectedDevbox.name || selectedDevbox.id, active: true },
377
- ] }), _jsxs(Box, { flexDirection: "column", marginTop: 1, marginBottom: 1, paddingX: 1, children: [_jsxs(Box, { children: [_jsx(Text, { color: colors.primary, bold: true, children: selectedDevbox.name || selectedDevbox.id }), _jsx(Text, { children: " " }), _jsx(StatusBadge, { status: selectedDevbox.status }), _jsxs(Text, { color: colors.textDim, dimColor: true, children: [" ", "\u2022 ", selectedDevbox.id] })] }), _jsxs(Box, { children: [_jsx(Text, { color: colors.textDim, dimColor: true, children: formattedCreateTime }), _jsxs(Text, { color: colors.textDim, dimColor: true, children: [" ", "(", createTimeAgo, ")"] })] }), uptime !== null && selectedDevbox.status === "running" && (_jsxs(Box, { children: [_jsxs(Text, { color: colors.success, dimColor: true, children: ["Uptime:", " ", uptime < 60
427
+ ] }), _jsxs(Box, { flexDirection: "column", marginTop: 1, marginBottom: 1, paddingX: 1, children: [_jsxs(Box, { children: [_jsx(Text, { color: colors.primary, bold: true, children: selectedDevbox.name || selectedDevbox.id }), _jsx(Text, { children: " " }), _jsx(StatusBadge, { status: selectedDevbox.status }), _jsxs(Text, { color: colors.idColor, children: [" \u2022 ", selectedDevbox.id] })] }), _jsxs(Box, { children: [_jsx(Text, { color: colors.textDim, dimColor: true, children: formattedCreateTime }), _jsxs(Text, { color: colors.textDim, dimColor: true, children: [" ", "(", createTimeAgo, ")"] })] }), uptime !== null && selectedDevbox.status === "running" && (_jsxs(Box, { children: [_jsxs(Text, { color: colors.success, dimColor: true, children: ["Uptime:", " ", uptime < 60
378
428
  ? `${uptime}m`
379
429
  : `${Math.floor(uptime / 60)}h ${uptime % 60}m`] }), lp?.keep_alive_time_seconds && (_jsxs(Text, { color: colors.textDim, dimColor: true, children: [" ", "\u2022 Keep-alive: ", Math.floor(lp.keep_alive_time_seconds / 60), "m"] }))] }))] }), _jsxs(Box, { flexDirection: "row", gap: 1, marginBottom: 1, children: [(lp?.resource_size_request ||
380
430
  lp?.custom_cpu_cores ||
@@ -382,9 +432,7 @@ export const DevboxDetailPage = ({ devbox: initialDevbox, onBack, onSSHRequest,
382
432
  lp?.custom_disk_size ||
383
433
  lp?.architecture) && (_jsxs(Box, { flexDirection: "column", paddingX: 1, flexGrow: 1, children: [_jsxs(Text, { color: colors.warning, bold: true, children: [figures.squareSmallFilled, " Resources"] }), _jsxs(Text, { dimColor: true, children: [lp?.resource_size_request && `${lp.resource_size_request}`, lp?.architecture && ` • ${lp.architecture}`, lp?.custom_cpu_cores && ` • ${lp.custom_cpu_cores}VCPU`, lp?.custom_gb_memory && ` • ${lp.custom_gb_memory}GB RAM`, lp?.custom_disk_size && ` • ${lp.custom_disk_size}GB DISC`] })] })), hasCapabilities && (_jsxs(Box, { flexDirection: "column", paddingX: 1, flexGrow: 1, children: [_jsxs(Text, { color: colors.info, bold: true, children: [figures.tick, " Capabilities"] }), _jsx(Text, { dimColor: true, children: selectedDevbox.capabilities
384
434
  .filter((c) => c !== "unknown")
385
- .join(", ") })] })), (selectedDevbox.blueprint_id || selectedDevbox.snapshot_id) && (_jsxs(Box, { flexDirection: "column", paddingX: 1, flexGrow: 1, children: [_jsxs(Text, { color: colors.secondary, bold: true, children: [figures.circleFilled, " Source"] }), _jsxs(Text, { dimColor: true, children: [selectedDevbox.blueprint_id &&
386
- `BP: ${selectedDevbox.blueprint_id}`, selectedDevbox.snapshot_id &&
387
- `Snap: ${selectedDevbox.snapshot_id}`] })] }))] }), selectedDevbox.metadata &&
435
+ .join(", ") })] })), (selectedDevbox.blueprint_id || selectedDevbox.snapshot_id) && (_jsxs(Box, { flexDirection: "column", paddingX: 1, flexGrow: 1, children: [_jsxs(Text, { color: colors.secondary, bold: true, children: [figures.circleFilled, " Source"] }), selectedDevbox.blueprint_id && (_jsxs(_Fragment, { children: [_jsx(Text, { dimColor: true, children: "BP: " }), _jsx(Text, { color: colors.idColor, children: selectedDevbox.blueprint_id })] })), selectedDevbox.snapshot_id && (_jsxs(_Fragment, { children: [_jsx(Text, { dimColor: true, children: "Snap: " }), _jsx(Text, { color: colors.idColor, children: selectedDevbox.snapshot_id })] }))] }))] }), selectedDevbox.metadata &&
388
436
  Object.keys(selectedDevbox.metadata).length > 0 && (_jsx(Box, { marginBottom: 1, paddingX: 1, children: _jsx(MetadataDisplay, { metadata: selectedDevbox.metadata, showBorder: false }) })), selectedDevbox.failure_reason && (_jsxs(Box, { marginBottom: 1, paddingX: 1, children: [_jsxs(Text, { color: colors.error, bold: true, children: [figures.cross, " "] }), _jsx(Text, { color: colors.error, dimColor: true, children: selectedDevbox.failure_reason })] })), _jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsxs(Text, { color: colors.primary, bold: true, children: [figures.play, " Actions"] }), _jsx(Box, { flexDirection: "column", children: operations.map((op, index) => {
389
437
  const isSelected = index === selectedOperation;
390
438
  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));
@@ -0,0 +1,29 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import React from "react";
3
+ import { Box, Text } from "ink";
4
+ import { colors } from "../utils/theme.js";
5
+ /**
6
+ * ErrorBoundary to catch and handle React errors gracefully
7
+ * Particularly useful for catching Yoga WASM layout errors
8
+ */
9
+ export class ErrorBoundary extends React.Component {
10
+ constructor(props) {
11
+ super(props);
12
+ this.state = { hasError: false };
13
+ }
14
+ static getDerivedStateFromError(error) {
15
+ return { hasError: true, error };
16
+ }
17
+ componentDidCatch(error, errorInfo) {
18
+ console.error("ErrorBoundary caught an error:", error, errorInfo);
19
+ }
20
+ render() {
21
+ if (this.state.hasError) {
22
+ if (this.props.fallback) {
23
+ return this.props.fallback;
24
+ }
25
+ return (_jsxs(Box, { flexDirection: "column", padding: 1, children: [_jsx(Text, { color: colors.error, bold: true, children: "\u26A0\uFE0F Rendering Error" }), _jsx(Text, { color: colors.textDim, children: this.state.error?.message || "An unexpected error occurred" }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { color: colors.textDim, dimColor: true, children: "Press Ctrl+C to exit" }) })] }));
26
+ }
27
+ return this.props.children;
28
+ }
29
+ }
@@ -2,6 +2,14 @@ import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
2
2
  import { Box, Text } from "ink";
3
3
  import figures from "figures";
4
4
  import { colors } from "../utils/theme.js";
5
- export const ErrorMessage = ({ message, error, }) => {
6
- return (_jsxs(Box, { flexDirection: "column", marginY: 1, children: [_jsx(Box, { children: _jsxs(Text, { color: colors.error, bold: true, children: [figures.cross, " ", message] }) }), error && (_jsx(Box, { marginLeft: 2, children: _jsx(Text, { color: colors.textDim, dimColor: true, children: error.message }) }))] }));
5
+ export const ErrorMessage = ({ message, error }) => {
6
+ // Limit message length to prevent Yoga layout engine errors
7
+ const MAX_LENGTH = 500;
8
+ const truncatedMessage = message.length > MAX_LENGTH
9
+ ? message.substring(0, MAX_LENGTH) + "..."
10
+ : message;
11
+ const truncatedError = error?.message && error.message.length > MAX_LENGTH
12
+ ? error.message.substring(0, MAX_LENGTH) + "..."
13
+ : error?.message;
14
+ return (_jsxs(Box, { flexDirection: "column", marginY: 1, children: [_jsx(Box, { children: _jsxs(Text, { color: colors.error, bold: true, children: [figures.cross, " ", truncatedMessage] }) }), truncatedError && (_jsx(Box, { marginLeft: 2, children: _jsx(Text, { color: colors.textDim, dimColor: true, children: truncatedError }) }))] }));
7
15
  };
@@ -1,7 +1,15 @@
1
1
  import { jsxs as _jsxs, jsx as _jsx, Fragment as _Fragment } from "react/jsx-runtime";
2
- import React from "react";
3
2
  import { Box, Text } from "ink";
4
3
  import { colors } from "../utils/theme.js";
5
- export const Header = React.memo(({ title, subtitle }) => {
6
- return (_jsxs(Box, { flexDirection: "column", marginBottom: 1, children: [_jsxs(Box, { children: [_jsxs(Text, { bold: true, color: colors.accent3, children: ["\u258C", title] }), subtitle && (_jsxs(_Fragment, { children: [_jsx(Text, { children: " " }), _jsx(Text, { color: colors.textDim, dimColor: true, children: subtitle })] }))] }), _jsx(Box, { marginLeft: 1, children: _jsx(Text, { color: colors.accent3, children: "─".repeat(title.length + 1) }) })] }));
7
- });
4
+ export const Header = ({ title, subtitle }) => {
5
+ // Limit lengths to prevent Yoga layout engine errors
6
+ const MAX_TITLE_LENGTH = 100;
7
+ const MAX_SUBTITLE_LENGTH = 150;
8
+ const truncatedTitle = title.length > MAX_TITLE_LENGTH
9
+ ? title.substring(0, MAX_TITLE_LENGTH) + "..."
10
+ : title;
11
+ const truncatedSubtitle = subtitle && subtitle.length > MAX_SUBTITLE_LENGTH
12
+ ? subtitle.substring(0, MAX_SUBTITLE_LENGTH) + "..."
13
+ : subtitle;
14
+ return (_jsxs(Box, { flexDirection: "column", marginBottom: 1, children: [_jsxs(Box, { children: [_jsxs(Text, { bold: true, color: colors.accent3, children: ["\u258C", truncatedTitle] }), truncatedSubtitle && (_jsxs(_Fragment, { children: [_jsx(Text, { children: " " }), _jsx(Text, { color: colors.textDim, dimColor: true, children: truncatedSubtitle })] }))] }), _jsx(Box, { marginLeft: 1, children: _jsx(Text, { color: colors.accent3, children: "─".repeat(Math.max(0, Math.floor(Math.min(truncatedTitle.length + 1, MAX_TITLE_LENGTH + 1)))) }) })] }));
15
+ };
@@ -0,0 +1,104 @@
1
+ /**
2
+ * InteractiveSpawn - Custom component for running interactive subprocesses
3
+ * Based on Ink's subprocess-output example pattern
4
+ * Handles proper TTY allocation for interactive commands like SSH
5
+ */
6
+ import React from "react";
7
+ import { spawn } from "child_process";
8
+ import { showCursor, clearScreen, enterAlternateScreenBuffer, } from "../utils/screen.js";
9
+ import { processUtils } from "../utils/processUtils.js";
10
+ /**
11
+ * Releases terminal control from Ink so a subprocess can take over.
12
+ * This directly manipulates stdin to bypass Ink's input handling.
13
+ */
14
+ function releaseTerminal() {
15
+ // Pause stdin to stop Ink from reading input
16
+ process.stdin.pause();
17
+ // Disable raw mode so the subprocess can control terminal echo and line buffering
18
+ // SSH needs to set its own terminal modes
19
+ if (processUtils.stdin.isTTY && processUtils.stdin.setRawMode) {
20
+ processUtils.stdin.setRawMode(false);
21
+ }
22
+ // Reset terminal attributes (SGR reset) - clears any colors/styles Ink may have set
23
+ if (processUtils.stdout.isTTY) {
24
+ processUtils.stdout.write("\x1b[0m");
25
+ }
26
+ // Show cursor - Ink may have hidden it, and subprocesses expect it to be visible
27
+ showCursor();
28
+ // Flush stdout to ensure all pending writes are complete before handoff
29
+ if (processUtils.stdout.isTTY) {
30
+ processUtils.stdout.write("");
31
+ }
32
+ }
33
+ /**
34
+ * Restores terminal control to Ink after subprocess exits.
35
+ */
36
+ function restoreTerminal() {
37
+ // Clear the screen to remove subprocess output before Ink renders
38
+ clearScreen();
39
+ // Re-enter alternate screen buffer for Ink's fullscreen UI
40
+ enterAlternateScreenBuffer();
41
+ // Re-enable raw mode for Ink's input handling
42
+ if (processUtils.stdin.isTTY && processUtils.stdin.setRawMode) {
43
+ processUtils.stdin.setRawMode(true);
44
+ }
45
+ // Resume stdin so Ink can read input again
46
+ process.stdin.resume();
47
+ }
48
+ export const InteractiveSpawn = ({ command, args, onExit, onError, }) => {
49
+ const processRef = React.useRef(null);
50
+ const hasSpawnedRef = React.useRef(false);
51
+ // Use a stable string representation of args for dependency comparison
52
+ const argsKey = React.useMemo(() => JSON.stringify(args), [args]);
53
+ React.useEffect(() => {
54
+ // Only spawn once - prevent re-spawning if component re-renders
55
+ if (hasSpawnedRef.current) {
56
+ return;
57
+ }
58
+ hasSpawnedRef.current = true;
59
+ // Release terminal from Ink's control
60
+ releaseTerminal();
61
+ // Use setImmediate to ensure terminal state is released without noticeable delay
62
+ // This is faster than setTimeout and ensures the event loop has processed the release
63
+ setImmediate(() => {
64
+ // Spawn the process with inherited stdio for proper TTY allocation
65
+ const child = spawn(command, args, {
66
+ stdio: "inherit", // This allows the process to use the terminal directly
67
+ shell: false,
68
+ });
69
+ processRef.current = child;
70
+ // Handle process exit
71
+ child.on("exit", (code, _signal) => {
72
+ processRef.current = null;
73
+ hasSpawnedRef.current = false;
74
+ // Restore terminal control to Ink
75
+ restoreTerminal();
76
+ if (onExit) {
77
+ onExit(code);
78
+ }
79
+ });
80
+ // Handle spawn errors
81
+ child.on("error", (error) => {
82
+ processRef.current = null;
83
+ hasSpawnedRef.current = false;
84
+ // Restore terminal control to Ink
85
+ restoreTerminal();
86
+ if (onError) {
87
+ onError(error);
88
+ }
89
+ });
90
+ });
91
+ // Cleanup function - kill the process if component unmounts
92
+ return () => {
93
+ if (processRef.current && !processRef.current.killed) {
94
+ processRef.current.kill("SIGTERM");
95
+ }
96
+ // Restore terminal state on cleanup
97
+ restoreTerminal();
98
+ hasSpawnedRef.current = false;
99
+ };
100
+ }, [command, argsKey, onExit, onError]);
101
+ // This component doesn't render anything - it just manages the subprocess
102
+ // The subprocess output goes directly to the terminal via stdio: "inherit"
103
+ return null;
104
+ };
@@ -0,0 +1,169 @@
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ /**
3
+ * LogsViewer - Shared component for viewing logs (devbox or blueprint)
4
+ * Extracted from DevboxActionsMenu for reuse
5
+ */
6
+ import React from "react";
7
+ import { Box, Text, useInput } from "ink";
8
+ import figures from "figures";
9
+ import { Breadcrumb } from "./Breadcrumb.js";
10
+ import { colors } from "../utils/theme.js";
11
+ import { useViewportHeight } from "../hooks/useViewportHeight.js";
12
+ import { parseAnyLogEntry } from "../utils/logFormatter.js";
13
+ export const LogsViewer = ({ logs, breadcrumbItems = [{ label: "Logs", active: true }], onBack, title = "Logs", }) => {
14
+ const [logsWrapMode, setLogsWrapMode] = React.useState(false);
15
+ const [logsScroll, setLogsScroll] = React.useState(0);
16
+ const [copyStatus, setCopyStatus] = React.useState(null);
17
+ // Calculate viewport for logs output:
18
+ // - Breadcrumb (3 lines + marginBottom): 4 lines
19
+ // - Log box borders: 2 lines
20
+ // - Stats bar (marginTop + content): 2 lines
21
+ // - Help bar (marginTop + content): 2 lines
22
+ // - Safety buffer: 1 line
23
+ // Total: 11 lines
24
+ const logsViewport = useViewportHeight({ overhead: 11, minHeight: 10 });
25
+ // Handle input for logs navigation
26
+ useInput((input, key) => {
27
+ if (key.upArrow || input === "k") {
28
+ setLogsScroll(Math.max(0, logsScroll - 1));
29
+ }
30
+ else if (key.downArrow || input === "j") {
31
+ setLogsScroll(logsScroll + 1);
32
+ }
33
+ else if (key.pageUp) {
34
+ setLogsScroll(Math.max(0, logsScroll - 10));
35
+ }
36
+ else if (key.pageDown) {
37
+ setLogsScroll(logsScroll + 10);
38
+ }
39
+ else if (input === "g") {
40
+ setLogsScroll(0);
41
+ }
42
+ else if (input === "G") {
43
+ const maxScroll = Math.max(0, logs.length - logsViewport.viewportHeight);
44
+ setLogsScroll(maxScroll);
45
+ }
46
+ else if (input === "w") {
47
+ setLogsWrapMode(!logsWrapMode);
48
+ }
49
+ else if (input === "c") {
50
+ // Copy logs to clipboard using shared formatter
51
+ const logsText = logs
52
+ .map((log) => {
53
+ const parts = parseAnyLogEntry(log);
54
+ const cmd = parts.cmd ? `$ ${parts.cmd} ` : "";
55
+ const exitCode = parts.exitCode !== null ? `exit=${parts.exitCode} ` : "";
56
+ const shell = parts.shellName ? `(${parts.shellName}) ` : "";
57
+ return `${parts.timestamp} ${parts.level} [${parts.source}] ${shell}${cmd}${parts.message} ${exitCode}`.trim();
58
+ })
59
+ .join("\n");
60
+ const copyToClipboard = async (text) => {
61
+ const { spawn } = await import("child_process");
62
+ const platform = process.platform;
63
+ let command;
64
+ let args;
65
+ if (platform === "darwin") {
66
+ command = "pbcopy";
67
+ args = [];
68
+ }
69
+ else if (platform === "win32") {
70
+ command = "clip";
71
+ args = [];
72
+ }
73
+ else {
74
+ command = "xclip";
75
+ args = ["-selection", "clipboard"];
76
+ }
77
+ const proc = spawn(command, args);
78
+ proc.stdin.write(text);
79
+ proc.stdin.end();
80
+ proc.on("exit", (code) => {
81
+ if (code === 0) {
82
+ setCopyStatus("Copied to clipboard!");
83
+ setTimeout(() => setCopyStatus(null), 2000);
84
+ }
85
+ else {
86
+ setCopyStatus("Failed to copy");
87
+ setTimeout(() => setCopyStatus(null), 2000);
88
+ }
89
+ });
90
+ proc.on("error", () => {
91
+ setCopyStatus("Copy not supported");
92
+ setTimeout(() => setCopyStatus(null), 2000);
93
+ });
94
+ };
95
+ copyToClipboard(logsText);
96
+ }
97
+ else if (input === "q" || key.escape || key.return) {
98
+ onBack();
99
+ }
100
+ });
101
+ const viewportHeight = Math.max(1, logsViewport.viewportHeight);
102
+ const terminalWidth = logsViewport.terminalWidth;
103
+ const maxScroll = Math.max(0, logs.length - viewportHeight);
104
+ const actualScroll = Math.min(logsScroll, maxScroll);
105
+ const visibleLogs = logs.slice(actualScroll, actualScroll + viewportHeight);
106
+ const hasMore = actualScroll + viewportHeight < logs.length;
107
+ const hasLess = actualScroll > 0;
108
+ return (_jsxs(_Fragment, { children: [_jsx(Breadcrumb, { items: breadcrumbItems }), _jsx(Box, { flexDirection: "column", borderStyle: "round", borderColor: colors.border, paddingX: 1, children: logs.length === 0 ? (_jsx(Text, { color: colors.textDim, dimColor: true, children: "No logs available" })) : (visibleLogs.map((log, index) => {
109
+ const parts = parseAnyLogEntry(log);
110
+ // Sanitize message: escape special chars to prevent layout breaks
111
+ const escapedMessage = parts.message
112
+ .replace(/\r\n/g, "\\n")
113
+ .replace(/\n/g, "\\n")
114
+ .replace(/\r/g, "\\r")
115
+ .replace(/\t/g, "\\t");
116
+ // Limit message length to prevent Yoga layout engine errors
117
+ const MAX_MESSAGE_LENGTH = 1000;
118
+ const fullMessage = escapedMessage.length > MAX_MESSAGE_LENGTH
119
+ ? escapedMessage.substring(0, MAX_MESSAGE_LENGTH) + "..."
120
+ : escapedMessage;
121
+ const cmd = parts.cmd
122
+ ? `$ ${parts.cmd.substring(0, 40)}${parts.cmd.length > 40 ? "..." : ""} `
123
+ : "";
124
+ const exitCode = parts.exitCode !== null ? `exit=${parts.exitCode} ` : "";
125
+ // Map color names to theme colors
126
+ const levelColorMap = {
127
+ red: colors.error,
128
+ yellow: colors.warning,
129
+ blue: colors.primary,
130
+ gray: colors.textDim,
131
+ };
132
+ const sourceColorMap = {
133
+ magenta: "#d33682",
134
+ cyan: colors.info,
135
+ green: colors.success,
136
+ yellow: colors.warning,
137
+ gray: colors.textDim,
138
+ white: colors.text,
139
+ };
140
+ const levelColor = levelColorMap[parts.levelColor] || colors.textDim;
141
+ const sourceColor = sourceColorMap[parts.sourceColor] || colors.textDim;
142
+ if (logsWrapMode) {
143
+ return (_jsxs(Box, { children: [_jsx(Text, { color: colors.textDim, dimColor: true, children: parts.timestamp }), _jsx(Text, { children: " " }), _jsx(Text, { color: levelColor, bold: parts.levelColor === "red", children: parts.level }), _jsx(Text, { children: " " }), _jsxs(Text, { color: sourceColor, children: ["[", parts.source, "]"] }), _jsx(Text, { children: " " }), parts.shellName && (_jsxs(Text, { color: colors.textDim, dimColor: true, children: ["(", parts.shellName, ")", " "] })), cmd && _jsx(Text, { color: colors.info, children: cmd }), _jsx(Text, { children: fullMessage }), exitCode && (_jsxs(Text, { color: parts.exitCode === 0 ? colors.success : colors.error, children: [" ", exitCode] }))] }, index));
144
+ }
145
+ else {
146
+ // Calculate available width for message truncation
147
+ const timestampLen = parts.timestamp.length;
148
+ const levelLen = parts.level.length;
149
+ const sourceLen = parts.source.length + 2; // brackets
150
+ const shellLen = parts.shellName ? parts.shellName.length + 3 : 0;
151
+ const cmdLen = cmd.length;
152
+ const exitLen = exitCode.length;
153
+ const spacesLen = 5; // spaces between elements
154
+ const metadataWidth = timestampLen +
155
+ levelLen +
156
+ sourceLen +
157
+ shellLen +
158
+ cmdLen +
159
+ exitLen +
160
+ spacesLen;
161
+ const safeTerminalWidth = Math.max(80, terminalWidth);
162
+ const availableMessageWidth = Math.max(20, safeTerminalWidth - metadataWidth);
163
+ const truncatedMessage = fullMessage.length > availableMessageWidth
164
+ ? fullMessage.substring(0, Math.max(1, availableMessageWidth - 3)) + "..."
165
+ : fullMessage;
166
+ return (_jsxs(Box, { children: [_jsx(Text, { color: colors.textDim, dimColor: true, children: parts.timestamp }), _jsx(Text, { children: " " }), _jsx(Text, { color: levelColor, bold: parts.levelColor === "red", children: parts.level }), _jsx(Text, { children: " " }), _jsxs(Text, { color: sourceColor, children: ["[", parts.source, "]"] }), _jsx(Text, { children: " " }), parts.shellName && (_jsxs(Text, { color: colors.textDim, dimColor: true, children: ["(", parts.shellName, ")", " "] })), cmd && _jsx(Text, { color: colors.info, children: cmd }), _jsx(Text, { children: truncatedMessage }), exitCode && (_jsxs(Text, { color: parts.exitCode === 0 ? colors.success : colors.error, children: [" ", exitCode] }))] }, index));
167
+ }
168
+ })) }), _jsxs(Box, { marginTop: 1, paddingX: 1, children: [_jsxs(Text, { color: colors.primary, bold: true, children: [figures.hamburger, " ", logs.length] }), _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] }), hasLess && _jsxs(Text, { color: colors.primary, children: [" ", figures.arrowUp] }), hasMore && _jsxs(Text, { color: colors.primary, children: [" ", figures.arrowDown] }), _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"] }) })] }));
169
+ };