@runloop/rl-cli 0.2.0 → 0.4.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 (42) hide show
  1. package/README.md +5 -75
  2. package/dist/cli.js +24 -56
  3. package/dist/commands/auth.js +2 -1
  4. package/dist/commands/blueprint/list.js +68 -22
  5. package/dist/commands/blueprint/preview.js +38 -42
  6. package/dist/commands/config.js +3 -2
  7. package/dist/commands/devbox/ssh.js +2 -1
  8. package/dist/commands/devbox/tunnel.js +2 -1
  9. package/dist/commands/mcp-http.js +6 -5
  10. package/dist/commands/mcp-install.js +9 -8
  11. package/dist/commands/mcp.js +5 -4
  12. package/dist/commands/menu.js +2 -1
  13. package/dist/components/ActionsPopup.js +18 -17
  14. package/dist/components/Banner.js +7 -1
  15. package/dist/components/Breadcrumb.js +10 -9
  16. package/dist/components/DevboxActionsMenu.js +18 -180
  17. package/dist/components/InteractiveSpawn.js +24 -14
  18. package/dist/components/LogsViewer.js +169 -0
  19. package/dist/components/MainMenu.js +2 -2
  20. package/dist/components/ResourceListView.js +3 -3
  21. package/dist/components/UpdateNotification.js +56 -0
  22. package/dist/hooks/useCursorPagination.js +3 -3
  23. package/dist/hooks/useExitOnCtrlC.js +2 -1
  24. package/dist/mcp/server-http.js +2 -1
  25. package/dist/mcp/server.js +7 -2
  26. package/dist/router/Router.js +3 -1
  27. package/dist/screens/BlueprintLogsScreen.js +74 -0
  28. package/dist/services/blueprintService.js +18 -22
  29. package/dist/utils/CommandExecutor.js +24 -53
  30. package/dist/utils/client.js +5 -1
  31. package/dist/utils/config.js +2 -1
  32. package/dist/utils/logFormatter.js +47 -1
  33. package/dist/utils/output.js +4 -3
  34. package/dist/utils/process.js +106 -0
  35. package/dist/utils/processUtils.js +135 -0
  36. package/dist/utils/screen.js +40 -2
  37. package/dist/utils/ssh.js +3 -2
  38. package/dist/utils/terminalDetection.js +120 -32
  39. package/dist/utils/theme.js +34 -19
  40. package/dist/utils/versionCheck.js +53 -0
  41. package/dist/version.js +12 -0
  42. package/package.json +4 -6
@@ -3,13 +3,14 @@ import { readFileSync, writeFileSync, existsSync, mkdirSync } from "fs";
3
3
  import { homedir, platform } from "os";
4
4
  import { join } from "path";
5
5
  import { execSync } from "child_process";
6
+ import { processUtils } from "../utils/processUtils.js";
6
7
  function getClaudeConfigPath() {
7
8
  const plat = platform();
8
9
  if (plat === "darwin") {
9
10
  return join(homedir(), "Library", "Application Support", "Claude", "claude_desktop_config.json");
10
11
  }
11
12
  else if (plat === "win32") {
12
- const appData = process.env.APPDATA;
13
+ const appData = processUtils.env.APPDATA;
13
14
  if (!appData) {
14
15
  throw new Error("APPDATA environment variable not found");
15
16
  }
@@ -51,7 +52,7 @@ export async function installMcpConfig() {
51
52
  catch {
52
53
  console.error("❌ Error: Claude config file exists but is not valid JSON");
53
54
  console.error("Please fix the file manually or delete it to create a new one");
54
- process.exit(1);
55
+ processUtils.exit(1);
55
56
  }
56
57
  }
57
58
  else {
@@ -71,20 +72,20 @@ export async function installMcpConfig() {
71
72
  // Ask if they want to overwrite
72
73
  console.log("\n❓ Do you want to overwrite it? (y/N): ");
73
74
  // For non-interactive mode, just exit
74
- if (process.stdin.isTTY) {
75
+ if (processUtils.stdin.isTTY) {
75
76
  const response = await new Promise((resolve) => {
76
- process.stdin.once("data", (data) => {
77
+ processUtils.stdin.on("data", (data) => {
77
78
  resolve(data.toString().trim().toLowerCase());
78
79
  });
79
80
  });
80
81
  if (response !== "y" && response !== "yes") {
81
82
  console.log("\n✓ Keeping existing configuration");
82
- process.exit(0);
83
+ processUtils.exit(0);
83
84
  }
84
85
  }
85
86
  else {
86
87
  console.log("\n✓ Keeping existing configuration (non-interactive mode)");
87
- process.exit(0);
88
+ processUtils.exit(0);
88
89
  }
89
90
  }
90
91
  // Add runloop MCP server config
@@ -100,7 +101,7 @@ export async function installMcpConfig() {
100
101
  console.log("\n📝 Next steps:");
101
102
  console.log("1. Restart Claude Desktop completely (quit and reopen)");
102
103
  console.log('2. Ask Claude: "List my devboxes" or "What Runloop tools do you have?"');
103
- console.log('\n💡 Tip: Make sure you\'ve run "rli auth" to configure your API key first!');
104
+ console.log("\n💡 Tip: Make sure RUNLOOP_API_KEY environment variable is set!");
104
105
  }
105
106
  catch (error) {
106
107
  const errorMessage = error instanceof Error ? error.message : String(error);
@@ -116,6 +117,6 @@ export async function installMcpConfig() {
116
117
  },
117
118
  },
118
119
  }, null, 2));
119
- process.exit(1);
120
+ processUtils.exit(1);
120
121
  }
121
122
  }
@@ -2,6 +2,7 @@
2
2
  import { spawn } from "child_process";
3
3
  import { fileURLToPath } from "url";
4
4
  import { dirname, join } from "path";
5
+ import { processUtils } from "../utils/processUtils.js";
5
6
  const __filename = fileURLToPath(import.meta.url);
6
7
  const __dirname = dirname(__filename);
7
8
  export async function startMcpServer() {
@@ -14,17 +15,17 @@ export async function startMcpServer() {
14
15
  });
15
16
  serverProcess.on("error", (error) => {
16
17
  console.error("Failed to start MCP server:", error);
17
- process.exit(1);
18
+ processUtils.exit(1);
18
19
  });
19
20
  serverProcess.on("exit", (code) => {
20
21
  if (code !== 0) {
21
22
  console.error(`MCP server exited with code ${code}`);
22
- process.exit(code || 1);
23
+ processUtils.exit(code || 1);
23
24
  }
24
25
  });
25
26
  // Handle Ctrl+C
26
- process.on("SIGINT", () => {
27
+ processUtils.on("SIGINT", () => {
27
28
  serverProcess.kill("SIGINT");
28
- process.exit(0);
29
+ processUtils.exit(0);
29
30
  });
30
31
  }
@@ -1,6 +1,7 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
2
  import { render } from "ink";
3
3
  import { enterAlternateScreenBuffer, exitAlternateScreenBuffer, } from "../utils/screen.js";
4
+ import { processUtils } from "../utils/processUtils.js";
4
5
  import { Router } from "../router/Router.js";
5
6
  import { NavigationProvider } from "../store/navigationStore.js";
6
7
  function AppInner() {
@@ -24,5 +25,5 @@ export async function runMainMenu(initialScreen = "menu", focusDevboxId) {
24
25
  console.error("Error in menu:", error);
25
26
  }
26
27
  exitAlternateScreenBuffer();
27
- process.exit(0);
28
+ processUtils.exit(0);
28
29
  }
@@ -2,7 +2,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { Box, Text } from "ink";
3
3
  import figures from "figures";
4
4
  import chalk from "chalk";
5
- import { isLightMode } from "../utils/theme.js";
5
+ import { getChalkTextColor, getChalkColor } from "../utils/theme.js";
6
6
  export const ActionsPopup = ({ devbox: _devbox, operations, selectedOperation, onClose: _onClose, }) => {
7
7
  // Calculate max width needed for content (visible characters only)
8
8
  // CRITICAL: Ensure all values are valid numbers to prevent Yoga crashes
@@ -15,11 +15,12 @@ export const ActionsPopup = ({ devbox: _devbox, operations, selectedOperation, o
15
15
  // Plus 2 for border characters = 6 total extra
16
16
  // CRITICAL: Validate all computed widths are positive integers
17
17
  const contentWidth = Math.max(10, maxContentWidth + 4);
18
- // Get background color chalk function - inverted for contrast
19
- // In light mode (light terminal), use black background for popup
20
- // In dark mode (dark terminal), use white background for popup
21
- const bgColor = isLightMode() ? chalk.bgBlack : chalk.bgWhite;
22
- const textColor = isLightMode() ? chalk.white : chalk.black;
18
+ // Get background color chalk function - use theme colors to match the theme mode
19
+ // In light mode, use light background; in dark mode, use dark background
20
+ const popupBgHex = getChalkColor("background");
21
+ const popupTextHex = getChalkColor("text");
22
+ const bgColorFn = chalk.bgHex(popupBgHex);
23
+ const textColorFn = chalk.hex(popupTextHex);
23
24
  // Helper to create background lines with proper padding including left/right margins
24
25
  const createBgLine = (styledContent, plainContent) => {
25
26
  const visibleLength = plainContent.length;
@@ -27,11 +28,11 @@ export const ActionsPopup = ({ devbox: _devbox, operations, selectedOperation, o
27
28
  const repeatCount = Math.max(0, Math.floor(maxContentWidth - visibleLength));
28
29
  const rightPadding = " ".repeat(repeatCount);
29
30
  // Apply background to left padding + content + right padding
30
- return bgColor(" " + styledContent + rightPadding + " ");
31
+ return bgColorFn(" " + styledContent + rightPadding + " ");
31
32
  };
32
33
  // Create empty line with full background
33
34
  // CRITICAL: Validate repeat count is positive integer
34
- const emptyLine = bgColor(" ".repeat(Math.max(1, Math.floor(contentWidth))));
35
+ const emptyLine = bgColorFn(" ".repeat(Math.max(1, Math.floor(contentWidth))));
35
36
  // Create border lines with background and integrated title
36
37
  const title = `${figures.play} Quick Actions`;
37
38
  // The content between ╭ and ╮ should be exactly contentWidth
@@ -41,12 +42,12 @@ export const ActionsPopup = ({ devbox: _devbox, operations, selectedOperation, o
41
42
  // CRITICAL: Validate repeat counts are non-negative integers
42
43
  const remainingDashes = Math.max(0, Math.floor(contentWidth - titleTotalLength));
43
44
  // Use theme primary color for borders to match theme
44
- const borderColorFn = isLightMode() ? chalk.cyan : chalk.blue;
45
- const borderTop = bgColor(borderColorFn("╭─" + titleWithSpaces + "─".repeat(remainingDashes) + "╮"));
45
+ const borderColorFn = getChalkTextColor("primary");
46
+ const borderTop = bgColorFn(borderColorFn("╭─" + titleWithSpaces + "─".repeat(remainingDashes) + "╮"));
46
47
  // CRITICAL: Validate contentWidth is a positive integer
47
- const borderBottom = bgColor(borderColorFn("╰" + "─".repeat(Math.max(1, Math.floor(contentWidth))) + "╯"));
48
+ const borderBottom = bgColorFn(borderColorFn("╰" + "─".repeat(Math.max(1, Math.floor(contentWidth))) + "╯"));
48
49
  const borderSide = (content) => {
49
- return bgColor(borderColorFn("│") + content + borderColorFn("│"));
50
+ return bgColorFn(borderColorFn("│") + content + borderColorFn("│"));
50
51
  };
51
52
  return (_jsx(Box, { flexDirection: "column", alignItems: "center", children: _jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { children: borderTop }), _jsx(Text, { children: borderSide(emptyLine) }), operations.map((op, index) => {
52
53
  const isSelected = index === selectedOperation;
@@ -56,14 +57,14 @@ export const ActionsPopup = ({ devbox: _devbox, operations, selectedOperation, o
56
57
  if (isSelected) {
57
58
  // Selected: use operation-specific color for icon and label
58
59
  const opColor = op.color;
59
- const colorFn = chalk[opColor] || textColor;
60
- styledLine = `${textColor(pointer)} ${colorFn(op.icon)} ${colorFn.bold(op.label)} ${textColor(`[${op.shortcut}]`)}`;
60
+ const colorFn = chalk[opColor] || textColorFn;
61
+ styledLine = `${textColorFn(pointer)} ${colorFn(op.icon)} ${colorFn.bold(op.label)} ${textColorFn(`[${op.shortcut}]`)}`;
61
62
  }
62
63
  else {
63
- // Unselected: gray/dim text for everything
64
- const dimFn = isLightMode() ? chalk.gray : chalk.gray;
64
+ // Unselected: use theme's textDim color for dimmed text
65
+ const dimFn = getChalkTextColor("textDim");
65
66
  styledLine = `${dimFn(pointer)} ${dimFn(op.icon)} ${dimFn(op.label)} ${dimFn(`[${op.shortcut}]`)}`;
66
67
  }
67
68
  return (_jsx(Text, { children: borderSide(createBgLine(styledLine, lineText)) }, op.key));
68
- }), _jsx(Text, { children: borderSide(emptyLine) }), _jsx(Text, { children: borderSide(createBgLine(textColor(`${figures.arrowUp}${figures.arrowDown} Nav • [Enter] • [Esc] Close`), `${figures.arrowUp}${figures.arrowDown} Nav • [Enter] • [Esc] Close`)) }), _jsx(Text, { children: borderSide(emptyLine) }), _jsx(Text, { children: borderBottom })] }) }));
69
+ }), _jsx(Text, { children: borderSide(emptyLine) }), _jsx(Text, { children: borderSide(createBgLine(textColorFn(`${figures.arrowUp}${figures.arrowDown} Nav • [Enter] • [Esc] Close`), `${figures.arrowUp}${figures.arrowDown} Nav • [Enter] • [Esc] Close`)) }), _jsx(Text, { children: borderSide(emptyLine) }), _jsx(Text, { children: borderBottom })] }) }));
69
70
  };
@@ -3,6 +3,12 @@ import React from "react";
3
3
  import { Box } from "ink";
4
4
  import BigText from "ink-big-text";
5
5
  import Gradient from "ink-gradient";
6
+ import { isLightMode } from "../utils/theme.js";
6
7
  export const Banner = React.memo(() => {
7
- return (_jsx(Box, { flexDirection: "column", alignItems: "flex-start", children: _jsx(Gradient, { name: "vice", children: _jsx(BigText, { text: "RUNLOOP.ai", font: "simple3d" }) }) }));
8
+ // Use theme-aware gradient colors
9
+ // In light mode, use darker/deeper colors for better contrast on light backgrounds
10
+ // "teen" has darker colors (blue/purple) that work well on light backgrounds
11
+ // In dark mode, use the vibrant "vice" gradient (pink/cyan) that works well on dark backgrounds
12
+ const gradientName = isLightMode() ? "teen" : "vice";
13
+ return (_jsx(Box, { flexDirection: "column", alignItems: "flex-start", paddingX: 1, children: _jsx(Gradient, { name: gradientName, children: _jsx(BigText, { text: "RUNLOOP.ai", font: "simple3d" }) }) }));
8
14
  });
@@ -2,15 +2,16 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import React from "react";
3
3
  import { Box, Text } from "ink";
4
4
  import { colors } from "../utils/theme.js";
5
- export const Breadcrumb = ({ items }) => {
5
+ import { UpdateNotification } from "./UpdateNotification.js";
6
+ export const Breadcrumb = ({ items, showVersionCheck = false, }) => {
6
7
  const env = process.env.RUNLOOP_ENV?.toLowerCase();
7
8
  const isDevEnvironment = env === "dev";
8
- return (_jsx(Box, { marginBottom: 1, paddingX: 1, paddingY: 0, children: _jsxs(Box, { borderStyle: "round", borderColor: colors.primary, paddingX: 2, paddingY: 0, children: [_jsx(Text, { color: colors.primary, bold: true, children: "rl" }), isDevEnvironment && (_jsxs(Text, { color: colors.error, bold: true, children: [" ", "(dev)"] })), _jsx(Text, { color: colors.textDim, children: " \u203A " }), items.map((item, index) => {
9
- // Limit label length to prevent Yoga layout engine errors
10
- const MAX_LABEL_LENGTH = 80;
11
- const truncatedLabel = item.label.length > MAX_LABEL_LENGTH
12
- ? item.label.substring(0, MAX_LABEL_LENGTH) + "..."
13
- : item.label;
14
- return (_jsxs(React.Fragment, { children: [_jsx(Text, { color: item.active ? colors.primary : colors.textDim, children: truncatedLabel }), index < items.length - 1 && (_jsx(Text, { color: colors.textDim, children: " \u203A " }))] }, index));
15
- })] }) }));
9
+ return (_jsxs(Box, { marginBottom: 1, paddingX: 0, paddingY: 0, children: [_jsxs(Box, { borderStyle: "round", borderColor: colors.primary, paddingX: 2, paddingY: 0, children: [_jsx(Text, { color: colors.primary, bold: true, children: "rl" }), isDevEnvironment && (_jsxs(Text, { color: colors.error, bold: true, children: [" ", "(dev)"] })), _jsx(Text, { color: colors.textDim, children: " \u203A " }), items.map((item, index) => {
10
+ // Limit label length to prevent Yoga layout engine errors
11
+ const MAX_LABEL_LENGTH = 80;
12
+ const truncatedLabel = item.label.length > MAX_LABEL_LENGTH
13
+ ? item.label.substring(0, MAX_LABEL_LENGTH) + "..."
14
+ : item.label;
15
+ return (_jsxs(React.Fragment, { children: [_jsx(Text, { color: item.active ? colors.primary : colors.textDim, children: truncatedLabel }), index < items.length - 1 && (_jsx(Text, { color: colors.textDim, children: " \u203A " }))] }, index));
16
+ })] }), showVersionCheck && (_jsx(Box, { paddingX: 2, marginTop: 0, children: _jsx(UpdateNotification, {}) }))] }));
16
17
  };
@@ -13,7 +13,7 @@ import { useViewportHeight } from "../hooks/useViewportHeight.js";
13
13
  import { useNavigation } from "../store/navigationStore.js";
14
14
  import { useExitOnCtrlC } from "../hooks/useExitOnCtrlC.js";
15
15
  import { getDevboxLogs, execCommand, suspendDevbox, resumeDevbox, shutdownDevbox, uploadFile, createSnapshot as createDevboxSnapshot, createTunnel, createSSHKey, } from "../services/devboxService.js";
16
- import { parseLogEntry } from "../utils/logFormatter.js";
16
+ import { LogsViewer } from "./LogsViewer.js";
17
17
  export const DevboxActionsMenu = ({ devbox, onBack, breadcrumbItems = [
18
18
  { label: "Devboxes" },
19
19
  { label: devbox.name || devbox.id, active: true },
@@ -25,8 +25,6 @@ export const DevboxActionsMenu = ({ devbox, onBack, breadcrumbItems = [
25
25
  const [operationInput, setOperationInput] = React.useState("");
26
26
  const [operationResult, setOperationResult] = React.useState(null);
27
27
  const [operationError, setOperationError] = React.useState(null);
28
- const [logsWrapMode, setLogsWrapMode] = React.useState(false);
29
- const [logsScroll, setLogsScroll] = React.useState(0);
30
28
  const [execScroll, setExecScroll] = React.useState(0);
31
29
  const [copyStatus, setCopyStatus] = React.useState(null);
32
30
  // Calculate viewport for exec output:
@@ -38,14 +36,6 @@ export const DevboxActionsMenu = ({ devbox, onBack, breadcrumbItems = [
38
36
  // - Safety buffer: 1 line
39
37
  // Total: 16 lines
40
38
  const execViewport = useViewportHeight({ overhead: 16, minHeight: 10 });
41
- // Calculate viewport for logs output:
42
- // - Breadcrumb (3 lines + marginBottom): 4 lines
43
- // - Log box borders: 2 lines
44
- // - Stats bar (marginTop + content): 2 lines
45
- // - Help bar (marginTop + content): 2 lines
46
- // - Safety buffer: 1 line
47
- // Total: 11 lines
48
- const logsViewport = useViewportHeight({ overhead: 11, minHeight: 10 });
49
39
  // CRITICAL: Aggressive memory cleanup to prevent heap exhaustion
50
40
  React.useEffect(() => {
51
41
  // Clear large data immediately when results are shown to free memory faster
@@ -185,8 +175,6 @@ export const DevboxActionsMenu = ({ devbox, onBack, breadcrumbItems = [
185
175
  setOperationResult(null);
186
176
  setOperationError(null);
187
177
  setOperationInput("");
188
- setLogsWrapMode(true);
189
- setLogsScroll(0);
190
178
  setExecScroll(0);
191
179
  setCopyStatus(null);
192
180
  // If skipOperationsMenu is true, go back to parent instead of operations menu
@@ -283,102 +271,6 @@ export const DevboxActionsMenu = ({ devbox, onBack, breadcrumbItems = [
283
271
  };
284
272
  copyToClipboard(output);
285
273
  }
286
- else if ((key.upArrow || input === "k") &&
287
- operationResult &&
288
- typeof operationResult === "object" &&
289
- operationResult.__customRender === "logs") {
290
- setLogsScroll(Math.max(0, logsScroll - 1));
291
- }
292
- else if ((key.downArrow || input === "j") &&
293
- operationResult &&
294
- typeof operationResult === "object" &&
295
- operationResult.__customRender === "logs") {
296
- setLogsScroll(logsScroll + 1);
297
- }
298
- else if (key.pageUp &&
299
- operationResult &&
300
- typeof operationResult === "object" &&
301
- operationResult.__customRender === "logs") {
302
- setLogsScroll(Math.max(0, logsScroll - 10));
303
- }
304
- else if (key.pageDown &&
305
- operationResult &&
306
- typeof operationResult === "object" &&
307
- operationResult.__customRender === "logs") {
308
- setLogsScroll(logsScroll + 10);
309
- }
310
- else if (input === "g" &&
311
- operationResult &&
312
- typeof operationResult === "object" &&
313
- operationResult.__customRender === "logs") {
314
- setLogsScroll(0);
315
- }
316
- else if (input === "G" &&
317
- operationResult &&
318
- typeof operationResult === "object" &&
319
- operationResult.__customRender === "logs") {
320
- const logs = operationResult.__logs || [];
321
- const maxScroll = Math.max(0, logs.length - logsViewport.viewportHeight);
322
- setLogsScroll(maxScroll);
323
- }
324
- else if (input === "w" &&
325
- operationResult &&
326
- typeof operationResult === "object" &&
327
- operationResult.__customRender === "logs") {
328
- setLogsWrapMode(!logsWrapMode);
329
- }
330
- else if (input === "c" &&
331
- operationResult &&
332
- typeof operationResult === "object" &&
333
- operationResult.__customRender === "logs") {
334
- // Copy logs to clipboard using shared formatter
335
- const logs = operationResult.__logs || [];
336
- const logsText = logs
337
- .map((log) => {
338
- const parts = parseLogEntry(log);
339
- const cmd = parts.cmd ? `$ ${parts.cmd} ` : "";
340
- const exitCode = parts.exitCode !== null ? `exit=${parts.exitCode} ` : "";
341
- const shell = parts.shellName ? `(${parts.shellName}) ` : "";
342
- return `${parts.timestamp} ${parts.level} [${parts.source}] ${shell}${cmd}${parts.message} ${exitCode}`.trim();
343
- })
344
- .join("\n");
345
- const copyToClipboard = async (text) => {
346
- const { spawn } = await import("child_process");
347
- const platform = process.platform;
348
- let command;
349
- let args;
350
- if (platform === "darwin") {
351
- command = "pbcopy";
352
- args = [];
353
- }
354
- else if (platform === "win32") {
355
- command = "clip";
356
- args = [];
357
- }
358
- else {
359
- command = "xclip";
360
- args = ["-selection", "clipboard"];
361
- }
362
- const proc = spawn(command, args);
363
- proc.stdin.write(text);
364
- proc.stdin.end();
365
- proc.on("exit", (code) => {
366
- if (code === 0) {
367
- setCopyStatus("Copied to clipboard!");
368
- setTimeout(() => setCopyStatus(null), 2000);
369
- }
370
- else {
371
- setCopyStatus("Failed to copy");
372
- setTimeout(() => setCopyStatus(null), 2000);
373
- }
374
- });
375
- proc.on("error", () => {
376
- setCopyStatus("Copy not supported");
377
- setTimeout(() => setCopyStatus(null), 2000);
378
- });
379
- };
380
- copyToClipboard(logsText);
381
- }
382
274
  return;
383
275
  }
384
276
  // Operations selection mode
@@ -560,77 +452,23 @@ export const DevboxActionsMenu = ({ devbox, onBack, breadcrumbItems = [
560
452
  typeof operationResult === "object" &&
561
453
  operationResult.__customRender === "logs") {
562
454
  const logs = operationResult.__logs || [];
563
- const totalCount = operationResult.__totalCount || 0;
564
- const viewportHeight = logsViewport.viewportHeight;
565
- const terminalWidth = logsViewport.terminalWidth;
566
- const maxScroll = Math.max(0, logs.length - viewportHeight);
567
- const actualScroll = Math.min(logsScroll, maxScroll);
568
- const visibleLogs = logs.slice(actualScroll, actualScroll + viewportHeight);
569
- const hasMore = actualScroll + viewportHeight < logs.length;
570
- const hasLess = actualScroll > 0;
571
- return (_jsxs(_Fragment, { children: [_jsx(Breadcrumb, { items: [...breadcrumbItems, { label: "Logs", active: true }] }), _jsx(Box, { flexDirection: "column", borderStyle: "round", borderColor: colors.border, paddingX: 1, children: visibleLogs.map((log, index) => {
572
- const parts = parseLogEntry(log);
573
- // Sanitize message: escape special chars to prevent layout breaks
574
- const escapedMessage = parts.message
575
- .replace(/\r\n/g, "\\n")
576
- .replace(/\n/g, "\\n")
577
- .replace(/\r/g, "\\r")
578
- .replace(/\t/g, "\\t");
579
- // Limit message length to prevent Yoga layout engine errors
580
- const MAX_MESSAGE_LENGTH = 1000;
581
- const fullMessage = escapedMessage.length > MAX_MESSAGE_LENGTH
582
- ? escapedMessage.substring(0, MAX_MESSAGE_LENGTH) + "..."
583
- : escapedMessage;
584
- const cmd = parts.cmd
585
- ? `$ ${parts.cmd.substring(0, 40)}${parts.cmd.length > 40 ? "..." : ""} `
586
- : "";
587
- const exitCode = parts.exitCode !== null ? `exit=${parts.exitCode} ` : "";
588
- // Map color names to theme colors
589
- const levelColorMap = {
590
- red: colors.error,
591
- yellow: colors.warning,
592
- blue: colors.primary,
593
- gray: colors.textDim,
594
- };
595
- const sourceColorMap = {
596
- magenta: "#d33682",
597
- cyan: colors.info,
598
- green: colors.success,
599
- yellow: colors.warning,
600
- gray: colors.textDim,
601
- white: colors.text,
602
- };
603
- const levelColor = levelColorMap[parts.levelColor] || colors.textDim;
604
- const sourceColor = sourceColorMap[parts.sourceColor] || colors.textDim;
605
- if (logsWrapMode) {
606
- 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));
607
- }
608
- else {
609
- // Calculate available width for message truncation
610
- const timestampLen = parts.timestamp.length;
611
- const levelLen = parts.level.length;
612
- const sourceLen = parts.source.length + 2; // brackets
613
- const shellLen = parts.shellName
614
- ? parts.shellName.length + 3
615
- : 0;
616
- const cmdLen = cmd.length;
617
- const exitLen = exitCode.length;
618
- const spacesLen = 5; // spaces between elements
619
- const metadataWidth = timestampLen +
620
- levelLen +
621
- sourceLen +
622
- shellLen +
623
- cmdLen +
624
- exitLen +
625
- spacesLen;
626
- const safeTerminalWidth = Math.max(80, terminalWidth);
627
- const availableMessageWidth = Math.max(20, safeTerminalWidth - metadataWidth);
628
- const truncatedMessage = fullMessage.length > availableMessageWidth
629
- ? fullMessage.substring(0, Math.max(1, availableMessageWidth - 3)) + "..."
630
- : fullMessage;
631
- 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));
632
- }
633
- }) }), _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] }), 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"] }) })] }));
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" }));
634
472
  }
635
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" }) })] }));
636
474
  }
@@ -5,7 +5,8 @@
5
5
  */
6
6
  import React from "react";
7
7
  import { spawn } from "child_process";
8
- import { exitAlternateScreenBuffer, enterAlternateScreenBuffer, } from "../utils/screen.js";
8
+ import { showCursor, clearScreen, enterAlternateScreenBuffer, } from "../utils/screen.js";
9
+ import { processUtils } from "../utils/processUtils.js";
9
10
  /**
10
11
  * Releases terminal control from Ink so a subprocess can take over.
11
12
  * This directly manipulates stdin to bypass Ink's input handling.
@@ -15,17 +16,31 @@ function releaseTerminal() {
15
16
  process.stdin.pause();
16
17
  // Disable raw mode so the subprocess can control terminal echo and line buffering
17
18
  // SSH needs to set its own terminal modes
18
- if (process.stdin.isTTY && process.stdin.setRawMode) {
19
- process.stdin.setRawMode(false);
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("");
20
31
  }
21
32
  }
22
33
  /**
23
34
  * Restores terminal control to Ink after subprocess exits.
24
35
  */
25
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();
26
41
  // Re-enable raw mode for Ink's input handling
27
- if (process.stdin.isTTY && process.stdin.setRawMode) {
28
- process.stdin.setRawMode(true);
42
+ if (processUtils.stdin.isTTY && processUtils.stdin.setRawMode) {
43
+ processUtils.stdin.setRawMode(true);
29
44
  }
30
45
  // Resume stdin so Ink can read input again
31
46
  process.stdin.resume();
@@ -41,12 +56,11 @@ export const InteractiveSpawn = ({ command, args, onExit, onError, }) => {
41
56
  return;
42
57
  }
43
58
  hasSpawnedRef.current = true;
44
- // Exit alternate screen so subprocess gets a clean terminal
45
- exitAlternateScreenBuffer();
46
59
  // Release terminal from Ink's control
47
60
  releaseTerminal();
48
- // Small delay to ensure terminal state is fully released
49
- setTimeout(() => {
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(() => {
50
64
  // Spawn the process with inherited stdio for proper TTY allocation
51
65
  const child = spawn(command, args, {
52
66
  stdio: "inherit", // This allows the process to use the terminal directly
@@ -59,8 +73,6 @@ export const InteractiveSpawn = ({ command, args, onExit, onError, }) => {
59
73
  hasSpawnedRef.current = false;
60
74
  // Restore terminal control to Ink
61
75
  restoreTerminal();
62
- // Re-enter alternate screen after process exits
63
- enterAlternateScreenBuffer();
64
76
  if (onExit) {
65
77
  onExit(code);
66
78
  }
@@ -71,13 +83,11 @@ export const InteractiveSpawn = ({ command, args, onExit, onError, }) => {
71
83
  hasSpawnedRef.current = false;
72
84
  // Restore terminal control to Ink
73
85
  restoreTerminal();
74
- // Re-enter alternate screen on error
75
- enterAlternateScreenBuffer();
76
86
  if (onError) {
77
87
  onError(error);
78
88
  }
79
89
  });
80
- }, 50);
90
+ });
81
91
  // Cleanup function - kill the process if component unmounts
82
92
  return () => {
83
93
  if (processRef.current && !processRef.current.killed) {