@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,39 +1,43 @@
1
- import { jsx as _jsx, 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, useApp } from "ink";
4
4
  import figures from "figures";
5
5
  import { Banner } from "./Banner.js";
6
6
  import { Breadcrumb } from "./Breadcrumb.js";
7
- import { VERSION } from "../cli.js";
7
+ import { VERSION } from "../version.js";
8
8
  import { colors } from "../utils/theme.js";
9
- export const MainMenu = React.memo(({ onSelect }) => {
9
+ import { useViewportHeight } from "../hooks/useViewportHeight.js";
10
+ import { useExitOnCtrlC } from "../hooks/useExitOnCtrlC.js";
11
+ const menuItems = [
12
+ {
13
+ key: "devboxes",
14
+ label: "Devboxes",
15
+ description: "Manage cloud development environments",
16
+ icon: "◉",
17
+ color: colors.accent1,
18
+ },
19
+ {
20
+ key: "blueprints",
21
+ label: "Blueprints",
22
+ description: "Create and manage devbox templates",
23
+ icon: "▣",
24
+ color: colors.accent2,
25
+ },
26
+ {
27
+ key: "snapshots",
28
+ label: "Snapshots",
29
+ description: "Save and restore devbox states",
30
+ icon: "◈",
31
+ color: colors.accent3,
32
+ },
33
+ ];
34
+ export const MainMenu = ({ onSelect }) => {
10
35
  const { exit } = useApp();
11
36
  const [selectedIndex, setSelectedIndex] = React.useState(0);
12
- // Calculate terminal height once at mount and memoize
13
- const terminalHeight = React.useMemo(() => process.stdout.rows || 24, []);
14
- const menuItems = React.useMemo(() => [
15
- {
16
- key: "devboxes",
17
- label: "Devboxes",
18
- description: "Manage cloud development environments",
19
- icon: "◉",
20
- color: colors.accent1,
21
- },
22
- {
23
- key: "blueprints",
24
- label: "Blueprints",
25
- description: "Create and manage devbox templates",
26
- icon: "▣",
27
- color: colors.accent2,
28
- },
29
- {
30
- key: "snapshots",
31
- label: "Snapshots",
32
- description: "Save and restore devbox states",
33
- icon: "◈",
34
- color: colors.accent3,
35
- },
36
- ], []);
37
+ // Use centralized viewport hook for consistent layout
38
+ const { terminalHeight } = useViewportHeight({ overhead: 0 });
39
+ // Handle Ctrl+C to exit
40
+ useExitOnCtrlC();
37
41
  useInput((input, key) => {
38
42
  if (key.upArrow && selectedIndex > 0) {
39
43
  setSelectedIndex(selectedIndex - 1);
@@ -58,15 +62,15 @@ export const MainMenu = React.memo(({ onSelect }) => {
58
62
  }
59
63
  });
60
64
  // Use compact layout if terminal height is less than 20 lines (memoized)
61
- const useCompactLayout = React.useMemo(() => terminalHeight < 20, [terminalHeight]);
65
+ const useCompactLayout = terminalHeight < 20;
62
66
  if (useCompactLayout) {
63
- return (_jsxs(Box, { flexDirection: "column", height: "100%", children: [_jsxs(Box, { paddingX: 2, marginBottom: 1, children: [_jsx(Text, { color: colors.primary, bold: true, children: "RUNLOOP.ai" }), _jsxs(Text, { color: colors.textDim, dimColor: true, children: [" ", "\u2022 Cloud development environments \u2022 v", VERSION] })] }), _jsx(Box, { flexDirection: "column", paddingX: 2, children: menuItems.map((item, index) => {
67
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { paddingX: 2, marginBottom: 1, children: [_jsx(Text, { color: colors.primary, bold: true, children: "RUNLOOP.ai" }), _jsxs(Text, { color: colors.textDim, dimColor: true, children: [" ", "\u2022 Cloud development environments \u2022 v", VERSION] })] }), _jsx(Box, { flexDirection: "column", paddingX: 2, children: menuItems.map((item, index) => {
64
68
  const isSelected = index === selectedIndex;
65
69
  return (_jsxs(Box, { marginBottom: 0, children: [_jsx(Text, { color: isSelected ? item.color : colors.textDim, children: isSelected ? figures.pointer : " " }), _jsx(Text, { children: " " }), _jsx(Text, { color: item.color, bold: true, children: item.icon }), _jsx(Text, { children: " " }), _jsx(Text, { color: isSelected ? item.color : colors.text, bold: isSelected, children: item.label }), _jsxs(Text, { color: colors.textDim, dimColor: true, children: [" ", "- ", item.description] }), _jsxs(Text, { color: colors.textDim, dimColor: true, children: [" ", "[", index + 1, "]"] })] }, item.key));
66
70
  }) }), _jsx(Box, { paddingX: 2, marginTop: 1, children: _jsxs(Text, { color: colors.textDim, dimColor: true, children: [figures.arrowUp, figures.arrowDown, " Navigate \u2022 [1-3] Quick select \u2022 [Enter] Select \u2022 [Esc] Quit"] }) })] }));
67
71
  }
68
- return (_jsxs(Box, { flexDirection: "column", height: "100%", children: [_jsx(Breadcrumb, { items: [{ label: "Home", active: true }], showVersionCheck: true }), _jsx(Box, { flexShrink: 0, children: _jsx(Banner, {}) }), _jsx(Box, { flexDirection: "column", paddingX: 2, flexShrink: 0, children: _jsx(Box, { paddingX: 1, children: _jsxs(Text, { color: colors.textDim, dimColor: true, children: ["Cloud development environments for your team \u2022 v", VERSION] }) }) }), _jsxs(Box, { flexDirection: "column", paddingX: 2, marginTop: 1, flexGrow: 1, children: [_jsx(Box, { paddingX: 1, flexShrink: 0, children: _jsx(Text, { color: colors.text, bold: true, children: "Select a resource:" }) }), menuItems.map((item, index) => {
72
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Breadcrumb, { items: [{ label: "Home", active: true }], showVersionCheck: true }), _jsx(Banner, {}), _jsx(Box, { flexDirection: "column", paddingX: 2, flexShrink: 0, children: _jsx(Box, { paddingX: 1, children: _jsxs(Text, { color: colors.textDim, dimColor: true, children: ["Cloud development environments for your team \u2022 v", VERSION] }) }) }), _jsxs(Box, { flexDirection: "column", paddingX: 2, marginTop: 1, flexGrow: 1, children: [_jsx(Box, { paddingX: 1, flexShrink: 0, children: _jsx(Text, { color: colors.text, bold: true, children: "Select a resource:" }) }), menuItems.map((item, index) => {
69
73
  const isSelected = index === selectedIndex;
70
- return (_jsxs(Box, { paddingX: 2, paddingY: 0, borderStyle: isSelected ? "round" : "single", borderColor: isSelected ? item.color : colors.border, marginTop: index === 0 ? 1 : 0, flexShrink: 0, children: [_jsx(Text, { color: item.color, bold: true, children: item.icon }), _jsx(Text, { children: " " }), _jsx(Text, { color: isSelected ? item.color : colors.text, bold: isSelected, children: item.label }), _jsx(Text, { color: colors.textDim, children: " " }), _jsx(Text, { color: colors.textDim, dimColor: true, children: item.description }), _jsx(Text, { children: " " }), _jsxs(Text, { color: colors.textDim, dimColor: true, children: ["[", index + 1, "]"] })] }, item.key));
74
+ return (_jsxs(Box, { paddingX: 2, paddingY: 0, borderStyle: "single", borderColor: isSelected ? item.color : colors.border, marginTop: index === 0 ? 1 : 0, flexShrink: 0, children: [isSelected && (_jsxs(_Fragment, { children: [_jsx(Text, { color: item.color, bold: true, children: figures.pointer }), _jsx(Text, { children: " " })] })), _jsx(Text, { color: item.color, bold: true, children: item.icon }), _jsx(Text, { children: " " }), _jsx(Text, { color: isSelected ? item.color : colors.text, bold: isSelected, children: item.label }), _jsx(Text, { color: colors.textDim, children: " " }), _jsx(Text, { color: colors.textDim, dimColor: true, children: item.description }), _jsx(Text, { children: " " }), _jsxs(Text, { color: colors.textDim, dimColor: true, children: ["[", index + 1, "]"] })] }, item.key));
71
75
  })] }), _jsx(Box, { paddingX: 2, flexShrink: 0, children: _jsx(Box, { paddingX: 1, children: _jsxs(Text, { color: colors.textDim, dimColor: true, children: [figures.arrowUp, figures.arrowDown, " Navigate \u2022 [1-3] Quick select \u2022 [Enter] Select \u2022 [Esc] Quit"] }) }) })] }));
72
- });
76
+ };
@@ -1,8 +1,8 @@
1
- import { jsxs as _jsxs, jsx as _jsx, Fragment as _Fragment } from "react/jsx-runtime";
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
2
  import { Box, Text } from "ink";
3
- import { Badge } from "@inkjs/ui";
4
3
  import figures from "figures";
5
4
  import { colors } from "../utils/theme.js";
5
+ const renderKeyValueBadge = (keyText, value, color) => (_jsxs(Box, { borderStyle: "round", borderColor: color, paddingX: 1, marginRight: 1, children: [_jsx(Text, { color: color, bold: true, children: keyText }), _jsx(Text, { color: color, children: ": " }), _jsx(Text, { color: color, children: value })] }));
6
6
  // Generate color for each key based on hash
7
7
  const getColorForKey = (key, index) => {
8
8
  const colorList = [
@@ -20,10 +20,10 @@ export const MetadataDisplay = ({ metadata, title = "Metadata", showBorder = fal
20
20
  if (entries.length === 0) {
21
21
  return null;
22
22
  }
23
- const content = (_jsxs(Box, { flexDirection: "row", alignItems: "center", flexWrap: "wrap", children: [title && (_jsxs(_Fragment, { children: [_jsxs(Text, { color: colors.accent3, bold: true, children: [figures.info, " ", title] }), _jsx(Text, { children: " " })] })), entries.map(([key, value], index) => {
23
+ const content = (_jsxs(Box, { flexDirection: "row", alignItems: "center", flexWrap: "wrap", gap: 1, children: [title && (_jsxs(_Fragment, { children: [_jsxs(Text, { color: colors.accent3, bold: true, children: [figures.identical, " ", title] }), _jsx(Text, { children: " " })] })), entries.map(([key, value], index) => {
24
24
  const color = getColorForKey(key, index);
25
25
  const isSelected = selectedKey === key;
26
- return (_jsxs(Box, { flexDirection: "row", alignItems: "center", children: [isSelected && (_jsxs(Text, { color: colors.primary, bold: true, children: [figures.pointer, " "] })), _jsx(Badge, { color: isSelected ? colors.primary : color, children: `${key}: ${value}` })] }, key));
26
+ return (_jsxs(Box, { flexDirection: "row", alignItems: "center", children: [isSelected && (_jsxs(Text, { color: colors.primary, bold: true, children: [figures.pointer, " "] })), renderKeyValueBadge(key, value, isSelected ? colors.primary : color)] }, key));
27
27
  })] }));
28
28
  if (showBorder) {
29
29
  return (_jsx(Box, { borderStyle: "round", borderColor: colors.accent3, paddingX: 2, paddingY: 1, flexDirection: "column", children: content }));
@@ -6,7 +6,7 @@ import { colors } from "../utils/theme.js";
6
6
  * Reusable operations menu component for detail pages
7
7
  * Displays a list of available operations with keyboard navigation
8
8
  */
9
- export const OperationsMenu = ({ operations, selectedIndex, onNavigate, onSelect, onBack, additionalActions = [], }) => {
9
+ export const OperationsMenu = ({ operations, selectedIndex, onNavigate: _onNavigate, onSelect: _onSelect, onBack: _onBack, additionalActions = [], }) => {
10
10
  return (_jsxs(_Fragment, { children: [_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) => {
11
11
  const isSelected = index === selectedIndex;
12
12
  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] })] }, op.key));
@@ -8,8 +8,8 @@ import { ActionsPopup } from "./ActionsPopup.js";
8
8
  import { DevboxActionsMenu } from "./DevboxActionsMenu.js";
9
9
  export const ResourceActionsMenu = (props) => {
10
10
  if (props.resourceType === "devbox") {
11
- const { resource, onBack, breadcrumbItems, initialOperation, initialOperationIndex, skipOperationsMenu, onSSHRequest, } = props;
12
- return (_jsx(DevboxActionsMenu, { devbox: resource, onBack: onBack, breadcrumbItems: breadcrumbItems, initialOperation: initialOperation, initialOperationIndex: initialOperationIndex, skipOperationsMenu: skipOperationsMenu, onSSHRequest: onSSHRequest }));
11
+ const { resource, onBack, breadcrumbItems, initialOperation, initialOperationIndex, skipOperationsMenu, } = props;
12
+ return (_jsx(DevboxActionsMenu, { devbox: resource, onBack: onBack, breadcrumbItems: breadcrumbItems, initialOperation: initialOperation, initialOperationIndex: initialOperationIndex, skipOperationsMenu: skipOperationsMenu }));
13
13
  }
14
14
  // Blueprint generic actions menu
15
15
  const { resource, onBack, breadcrumbItems = [
@@ -101,10 +101,10 @@ export const ResourceActionsMenu = (props) => {
101
101
  // Screens
102
102
  if (operationResult || operationError) {
103
103
  const label = operations.find((o) => o.key === executingOperation)?.label;
104
- return (_jsxs(_Fragment, { children: [_jsx(Breadcrumb, { items: breadcrumbItems, showVersionCheck: true }), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsx(Text, { color: operationError ? colors.error : colors.success, children: operationError ? `${label} failed` : `${label} completed` }), !!operationResult && (_jsx(Text, { color: colors.textDim, dimColor: true, children: operationResult })), _jsxs(Text, { color: colors.textDim, dimColor: true, children: [figures.pointerSmall, " Press [Enter] to go back"] })] })] }));
104
+ return (_jsxs(_Fragment, { children: [_jsx(Breadcrumb, { items: breadcrumbItems }), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsx(Text, { color: operationError ? colors.error : colors.success, children: operationError ? `${label} failed` : `${label} completed` }), !!operationResult && (_jsx(Text, { color: colors.textDim, dimColor: true, children: operationResult })), _jsxs(Text, { color: colors.textDim, dimColor: true, children: [figures.pointerSmall, " Press [Enter] to go back"] })] })] }));
105
105
  }
106
106
  if (executingOperation && selectedOp?.needsInput) {
107
- return (_jsxs(_Fragment, { children: [_jsx(Breadcrumb, { items: breadcrumbItems, showVersionCheck: true }), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsxs(Text, { color: colors.textDim, children: [selectedOp.inputPrompt || "Input:", " "] }), _jsxs(Text, { children: [" ", operationInput] }), _jsx(Text, { color: colors.textDim, dimColor: true, children: "Press [Enter] to execute \u2022 [q or esc] Cancel" })] })] }));
107
+ return (_jsxs(_Fragment, { children: [_jsx(Breadcrumb, { items: breadcrumbItems }), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsxs(Text, { color: colors.textDim, children: [selectedOp.inputPrompt || "Input:", " "] }), _jsxs(Text, { children: [" ", operationInput] }), _jsx(Text, { color: colors.textDim, dimColor: true, children: "Press [Enter] to execute \u2022 [q or esc] Cancel" })] })] }));
108
108
  }
109
109
  // Operations menu
110
110
  return (_jsxs(_Fragment, { children: [_jsx(Breadcrumb, { items: breadcrumbItems }), _jsx(Box, { marginTop: 1, justifyContent: "center", children: _jsx(ActionsPopup, { devbox: resource, operations: operations.map((op) => ({
@@ -1,6 +1,6 @@
1
1
  import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import React from "react";
3
- import { Box, Text, useInput, useStdout, useApp } from "ink";
3
+ import { Box, Text, useInput, useApp } from "ink";
4
4
  import TextInput from "ink-text-input";
5
5
  import figures from "figures";
6
6
  import { Breadcrumb } from "./Breadcrumb.js";
@@ -8,6 +8,8 @@ import { SpinnerComponent } from "./Spinner.js";
8
8
  import { ErrorMessage } from "./ErrorMessage.js";
9
9
  import { Table } from "./Table.js";
10
10
  import { colors } from "../utils/theme.js";
11
+ import { useViewportHeight } from "../hooks/useViewportHeight.js";
12
+ import { useExitOnCtrlC } from "../hooks/useExitOnCtrlC.js";
11
13
  // Format time ago in a succinct way
12
14
  export const formatTimeAgo = (timestamp) => {
13
15
  const seconds = Math.floor((Date.now() - timestamp) / 1000);
@@ -29,8 +31,15 @@ export const formatTimeAgo = (timestamp) => {
29
31
  return `${years}y ago`;
30
32
  };
31
33
  export function ResourceListView({ config }) {
32
- const { stdout } = useStdout();
33
34
  const { exit: inkExit } = useApp();
35
+ const isMounted = React.useRef(true);
36
+ // Track mounted state
37
+ React.useEffect(() => {
38
+ isMounted.current = true;
39
+ return () => {
40
+ isMounted.current = false;
41
+ };
42
+ }, []);
34
43
  const [loading, setLoading] = React.useState(true);
35
44
  const [resources, setResources] = React.useState([]);
36
45
  const [error, setError] = React.useState(null);
@@ -38,29 +47,39 @@ export function ResourceListView({ config }) {
38
47
  const [selectedIndex, setSelectedIndex] = React.useState(0);
39
48
  const [searchMode, setSearchMode] = React.useState(false);
40
49
  const [searchQuery, setSearchQuery] = React.useState("");
41
- const [refreshing, setRefreshing] = React.useState(false);
42
- const [refreshIcon, setRefreshIcon] = React.useState(0);
43
- const pageSize = config.pageSize || 10;
44
- const maxFetch = config.maxFetch || 100;
45
- // Calculate responsive dimensions
46
- const terminalWidth = stdout?.columns || 120;
47
- const terminalHeight = stdout?.rows || 30;
50
+ // Calculate overhead for viewport height:
51
+ // - Breadcrumb (3 lines + marginBottom): 4 lines
52
+ // - Search bar (if visible, 1 line + marginBottom): 2 lines
53
+ // - Table (title + top border + header + bottom border): 4 lines
54
+ // - Stats bar (marginTop + content): 2 lines
55
+ // - Help bar (marginTop + content): 2 lines
56
+ // - Safety buffer for edge cases: 1 line
57
+ // Total: 13 lines base + 2 if searching
58
+ const overhead = 13 + (searchMode || searchQuery ? 2 : 0);
59
+ const { viewportHeight } = useViewportHeight({
60
+ overhead,
61
+ minHeight: 5,
62
+ });
63
+ // Use viewport height for dynamic page size, or fall back to config
64
+ const pageSize = config.pageSize || viewportHeight;
48
65
  // Fetch resources
49
- const fetchData = React.useCallback(async (isInitialLoad = false) => {
66
+ const fetchData = React.useCallback(async (_isInitialLoad = false) => {
67
+ if (!isMounted.current)
68
+ return;
50
69
  try {
51
- if (isInitialLoad) {
52
- setRefreshing(true);
53
- }
54
70
  const data = await config.fetchResources();
55
- setResources(data);
71
+ if (isMounted.current) {
72
+ setResources(data);
73
+ }
56
74
  }
57
75
  catch (err) {
58
- setError(err);
76
+ if (isMounted.current) {
77
+ setError(err);
78
+ }
59
79
  }
60
80
  finally {
61
- setLoading(false);
62
- if (isInitialLoad) {
63
- setTimeout(() => setRefreshing(false), 300);
81
+ if (isMounted.current) {
82
+ setLoading(false);
64
83
  }
65
84
  }
66
85
  }, [config.fetchResources]);
@@ -77,13 +96,7 @@ export function ResourceListView({ config }) {
77
96
  return () => clearInterval(interval);
78
97
  }
79
98
  }, [config.autoRefresh, fetchData]);
80
- // Animate refresh icon
81
- React.useEffect(() => {
82
- const interval = setInterval(() => {
83
- setRefreshIcon((prev) => (prev + 1) % 10);
84
- }, 80);
85
- return () => clearInterval(interval);
86
- }, []);
99
+ // Removed refresh icon animation to prevent constant re-renders and flashing
87
100
  // Filter resources based on search query
88
101
  const filteredResources = React.useMemo(() => {
89
102
  if (!config.searchConfig?.enabled || !searchQuery.trim()) {
@@ -108,13 +121,13 @@ export function ResourceListView({ config }) {
108
121
  }
109
122
  }, [currentResources.length, selectedIndex]);
110
123
  const selectedResource = currentResources[selectedIndex];
124
+ // Handle Ctrl+C to exit
125
+ useExitOnCtrlC();
111
126
  // Input handling
112
127
  useInput((input, key) => {
113
- // Handle Ctrl+C to force exit
114
- if (key.ctrl && input === "c") {
115
- process.stdout.write("\x1b[?1049l"); // Exit alternate screen
116
- process.exit(130);
117
- }
128
+ // Don't process input if unmounting
129
+ if (!isMounted.current)
130
+ return;
118
131
  const pageResourcesCount = currentResources.length;
119
132
  // Skip input handling when in search mode
120
133
  if (searchMode) {
@@ -141,7 +154,6 @@ export function ResourceListView({ config }) {
141
154
  setSelectedIndex(0);
142
155
  }
143
156
  else if (key.return && selectedResource && config.onSelect) {
144
- console.clear();
145
157
  config.onSelect(selectedResource);
146
158
  }
147
159
  else if (input === "/" && config.searchConfig?.enabled) {
@@ -173,8 +185,8 @@ export function ResourceListView({ config }) {
173
185
  }
174
186
  }
175
187
  });
176
- // Calculate stats
177
- const stats = React.useMemo(() => {
188
+ // Calculate stats (computed for potential future use)
189
+ const _stats = React.useMemo(() => {
178
190
  if (!config.statusConfig || !config.getStatus) {
179
191
  return null;
180
192
  }
@@ -208,6 +220,6 @@ export function ResourceListView({ config }) {
208
220
  setSearchMode(false);
209
221
  setCurrentPage(0);
210
222
  setSelectedIndex(0);
211
- } }), _jsxs(Text, { color: colors.textDim, dimColor: true, children: [" ", "[Esc to cancel]"] })] })), !searchMode && searchQuery && (_jsxs(Box, { marginBottom: 1, children: [_jsxs(Text, { color: colors.primary, children: [figures.info, " Searching for: "] }), _jsx(Text, { color: colors.warning, bold: true, children: searchQuery }), _jsxs(Text, { color: colors.textDim, dimColor: true, children: [" ", "(", currentResources.length, " results) [/ to edit, Esc to clear]"] })] }))] })), _jsx(Table, { data: currentResources, keyExtractor: config.keyExtractor, selectedIndex: selectedIndex, title: `${config.resourceNamePlural.toLowerCase()}[${searchQuery ? currentResources.length : resources.length}]`, columns: config.columns }, `table-${searchQuery}-${currentPage}`), _jsxs(Box, { marginTop: 1, paddingX: 1, children: [_jsxs(Text, { color: colors.primary, bold: true, children: [figures.hamburger, " ", resources.length] }), _jsxs(Text, { color: colors.textDim, dimColor: true, children: [" ", "total"] }), totalPages > 1 && (_jsxs(_Fragment, { children: [_jsxs(Text, { color: colors.textDim, dimColor: true, children: [" ", "\u2022", " "] }), _jsxs(Text, { color: colors.textDim, dimColor: true, children: ["Page ", currentPage + 1, " of ", totalPages] })] })), _jsxs(Text, { color: colors.textDim, dimColor: true, children: [" ", "\u2022", " "] }), _jsxs(Text, { color: colors.textDim, dimColor: true, children: ["Showing ", startIndex + 1, "-", endIndex, " of ", filteredResources.length] }), _jsx(Text, { children: " " }), refreshing ? (_jsx(Text, { color: colors.primary, children: ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"][refreshIcon % 10] })) : (_jsx(Text, { color: colors.success, children: figures.circleFilled }))] }), _jsxs(Box, { marginTop: 1, paddingX: 1, children: [_jsxs(Text, { color: colors.textDim, dimColor: true, children: [figures.arrowUp, figures.arrowDown, " Navigate"] }), totalPages > 1 && (_jsxs(Text, { color: colors.textDim, dimColor: true, children: [" ", "\u2022 ", figures.arrowLeft, figures.arrowRight, " Page"] })), config.onSelect && (_jsxs(Text, { color: colors.textDim, dimColor: true, children: [" ", "\u2022 [Enter] Details"] })), config.searchConfig?.enabled && (_jsxs(Text, { color: colors.textDim, dimColor: true, children: [" ", "\u2022 [/] Search"] })), config.additionalShortcuts &&
223
+ } }), _jsxs(Text, { color: colors.textDim, dimColor: true, children: [" ", "[Esc to cancel]"] })] })), !searchMode && searchQuery && (_jsxs(Box, { marginBottom: 1, children: [_jsxs(Text, { color: colors.primary, children: [figures.info, " Searching for: "] }), _jsx(Text, { color: colors.warning, bold: true, children: searchQuery }), _jsxs(Text, { color: colors.textDim, dimColor: true, children: [" ", "(", currentResources.length, " results) [/ to edit, Esc to clear]"] })] }))] })), _jsx(Table, { data: currentResources, keyExtractor: config.keyExtractor, selectedIndex: selectedIndex, title: `${config.resourceNamePlural.toLowerCase()}[${searchQuery ? currentResources.length : resources.length}]`, columns: config.columns }, `table-${searchQuery}-${currentPage}`), _jsxs(Box, { marginTop: 1, paddingX: 1, children: [_jsxs(Text, { color: colors.primary, bold: true, children: [figures.hamburger, " ", resources.length] }), _jsxs(Text, { color: colors.textDim, dimColor: true, children: [" ", "total"] }), totalPages > 1 && (_jsxs(_Fragment, { children: [_jsxs(Text, { color: colors.textDim, dimColor: true, children: [" ", "\u2022", " "] }), _jsxs(Text, { color: colors.textDim, dimColor: true, children: ["Page ", currentPage + 1, " of ", totalPages] })] })), _jsxs(Text, { color: colors.textDim, dimColor: true, children: [" ", "\u2022", " "] }), _jsxs(Text, { color: colors.textDim, dimColor: true, children: ["Showing ", startIndex + 1, "-", endIndex, " of ", filteredResources.length] }), _jsx(Text, { children: " " }), _jsx(Text, { color: colors.success, children: figures.circleFilled })] }), _jsxs(Box, { marginTop: 1, paddingX: 1, children: [_jsxs(Text, { color: colors.textDim, dimColor: true, children: [figures.arrowUp, figures.arrowDown, " Navigate"] }), totalPages > 1 && (_jsxs(Text, { color: colors.textDim, dimColor: true, children: [" ", "\u2022 ", figures.arrowLeft, figures.arrowRight, " Page"] })), config.onSelect && (_jsxs(Text, { color: colors.textDim, dimColor: true, children: [" ", "\u2022 [Enter] Details"] })), config.searchConfig?.enabled && (_jsxs(Text, { color: colors.textDim, dimColor: true, children: [" ", "\u2022 [/] Search"] })), config.additionalShortcuts &&
212
224
  config.additionalShortcuts.map((shortcut) => (_jsxs(Text, { color: colors.textDim, dimColor: true, children: [" ", "\u2022 [", shortcut.key, "] ", shortcut.label] }, shortcut.key))), _jsxs(Text, { color: colors.textDim, dimColor: true, children: [" ", "\u2022 [Esc] Back"] })] })] }));
213
225
  }
@@ -2,6 +2,11 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { Box, Text } from "ink";
3
3
  import Spinner from "ink-spinner";
4
4
  import { colors } from "../utils/theme.js";
5
- export const SpinnerComponent = ({ message, }) => {
6
- return (_jsxs(Box, { marginBottom: 1, children: [_jsx(Text, { color: colors.primary, children: _jsx(Spinner, { type: "dots" }) }), _jsxs(Text, { children: [" ", message] })] }));
5
+ export const SpinnerComponent = ({ message }) => {
6
+ // Limit message length to prevent Yoga layout engine errors
7
+ const MAX_LENGTH = 200;
8
+ const truncatedMessage = message.length > MAX_LENGTH
9
+ ? message.substring(0, MAX_LENGTH) + "..."
10
+ : message;
11
+ return (_jsxs(Box, { marginBottom: 1, children: [_jsx(Text, { color: colors.primary, children: _jsx(Spinner, { type: "dots" }) }), _jsxs(Text, { children: [" ", truncatedMessage] })] }));
7
12
  };
@@ -88,7 +88,7 @@ export const getStatusDisplay = (status) => {
88
88
  };
89
89
  }
90
90
  };
91
- export const StatusBadge = ({ status, showText = true, }) => {
91
+ export const StatusBadge = ({ status, showText = true }) => {
92
92
  const statusDisplay = getStatusDisplay(status);
93
93
  return (_jsxs(_Fragment, { children: [_jsx(Text, { color: statusDisplay.color, children: statusDisplay.icon }), showText && (_jsxs(_Fragment, { children: [_jsx(Text, { children: " " }), _jsx(Text, { color: statusDisplay.color, children: statusDisplay.text })] }))] }));
94
94
  };
@@ -2,6 +2,16 @@ 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 SuccessMessage = ({ message, details, }) => {
6
- return (_jsxs(Box, { flexDirection: "column", marginY: 1, children: [_jsx(Box, { children: _jsxs(Text, { color: colors.success, bold: true, children: [figures.tick, " ", message] }) }), details && (_jsx(Box, { marginLeft: 2, flexDirection: "column", children: details.split("\n").map((line, i) => (_jsx(Text, { color: colors.textDim, dimColor: true, children: line }, i))) }))] }));
5
+ export const SuccessMessage = ({ message, details }) => {
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
+ return (_jsxs(Box, { flexDirection: "column", marginY: 1, children: [_jsx(Box, { children: _jsxs(Text, { color: colors.success, bold: true, children: [figures.tick, " ", truncatedMessage] }) }), details && (_jsx(Box, { marginLeft: 2, flexDirection: "column", children: details.split("\n").map((line, i) => {
12
+ const truncatedLine = line.length > MAX_LENGTH
13
+ ? line.substring(0, MAX_LENGTH) + "..."
14
+ : line;
15
+ return (_jsx(Text, { color: colors.textDim, dimColor: true, children: truncatedLine }, i));
16
+ }) }))] }));
7
17
  };
@@ -2,21 +2,29 @@ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-run
2
2
  import React from "react";
3
3
  import { Box, Text } from "ink";
4
4
  import figures from "figures";
5
- import { colors } from "../utils/theme.js";
5
+ import { colors, sanitizeWidth } from "../utils/theme.js";
6
6
  /**
7
7
  * Reusable table component for displaying lists of data with optional selection
8
8
  * Designed to be responsive and work across devboxes, blueprints, and snapshots
9
9
  */
10
10
  export function Table({ data, columns, selectedIndex = -1, showSelection = true, emptyState, keyExtractor, title, }) {
11
+ // Safety: Handle null/undefined data
12
+ if (!data || !Array.isArray(data)) {
13
+ return emptyState ? _jsx(_Fragment, { children: emptyState }) : null;
14
+ }
11
15
  if (data.length === 0 && emptyState) {
12
16
  return _jsx(_Fragment, { children: emptyState });
13
17
  }
14
18
  // Filter visible columns
15
19
  const visibleColumns = columns.filter((col) => col.visible !== false);
16
- return (_jsxs(Box, { flexDirection: "column", children: [title && (_jsx(Box, { paddingX: 1, marginBottom: 0, children: _jsxs(Text, { color: colors.primary, bold: true, children: ["\u256D\u2500 ", title, " ", "─".repeat(Math.max(0, 10)), "\u256E"] }) })), _jsxs(Box, { flexDirection: "column", borderStyle: title ? "single" : "round", borderColor: colors.border, paddingX: 1, children: [_jsxs(Box, { children: [showSelection && (_jsxs(_Fragment, { children: [_jsx(Text, { children: " " }), _jsx(Text, { children: " " })] })), visibleColumns.map((column) => (_jsx(Text, { bold: true, dimColor: true, children: column.label.slice(0, column.width).padEnd(column.width, " ") }, `header-${column.key}`)))] }), data.map((row, index) => {
20
+ return (_jsxs(Box, { flexDirection: "column", children: [title && (_jsx(Box, { paddingX: 1, marginBottom: 0, children: _jsxs(Text, { color: colors.primary, bold: true, children: ["\u256D\u2500 ", title.length > 50 ? title.substring(0, 50) + "..." : title, " ", "─".repeat(10), "\u256E"] }) })), _jsxs(Box, { flexDirection: "column", borderStyle: title ? "single" : "round", borderColor: colors.border, paddingX: 1, children: [_jsxs(Box, { children: [showSelection && (_jsxs(_Fragment, { children: [_jsx(Text, { children: " " }), _jsx(Text, { children: " " })] })), visibleColumns.map((column) => {
21
+ // Cap column width to prevent Yoga crashes from padEnd creating massive strings
22
+ const safeWidth = sanitizeWidth(column.width, 1, 100);
23
+ return (_jsx(Text, { bold: true, dimColor: true, children: column.label.slice(0, safeWidth).padEnd(safeWidth, " ") }, `header-${column.key}`));
24
+ })] }), data.map((row, index) => {
17
25
  const isSelected = index === selectedIndex;
18
26
  const rowKey = keyExtractor(row);
19
- return (_jsxs(Box, { children: [showSelection && (_jsxs(_Fragment, { children: [_jsx(Text, { color: isSelected ? colors.primary : colors.textDim, children: isSelected ? figures.pointer : " " }), _jsx(Text, { children: " " })] })), visibleColumns.map((column, colIndex) => (_jsx(React.Fragment, { children: column.render(row, index, isSelected) }, `${rowKey}-${column.key}`)))] }, rowKey));
27
+ return (_jsxs(Box, { children: [showSelection && (_jsxs(_Fragment, { children: [_jsx(Text, { color: isSelected ? colors.primary : colors.textDim, children: isSelected ? figures.pointer : " " }), _jsx(Text, { children: " " })] })), visibleColumns.map((column, colIndex) => (_jsx(React.Fragment, { children: column.render(row, index, isSelected) }, `${rowKey}-${column.key}-${colIndex}`)))] }, rowKey));
20
28
  })] })] }));
21
29
  }
22
30
  /**
@@ -29,8 +37,10 @@ export function createTextColumn(key, label, getValue, options) {
29
37
  width: options?.width || 20,
30
38
  visible: options?.visible,
31
39
  render: (row, index, isSelected) => {
32
- const value = getValue(row);
33
- const width = options?.width || 20;
40
+ const value = String(getValue(row) || "");
41
+ const rawWidth = options?.width || 20;
42
+ // CRITICAL: Sanitize width to prevent padEnd from creating invalid strings that crash Yoga
43
+ const width = sanitizeWidth(rawWidth, 1, 100);
34
44
  const color = options?.color || (isSelected ? colors.text : colors.text);
35
45
  const bold = options?.bold !== undefined ? options.bold : isSelected;
36
46
  const dimColor = options?.dimColor || false;
@@ -38,7 +48,7 @@ export function createTextColumn(key, label, getValue, options) {
38
48
  let truncated;
39
49
  if (value.length > width) {
40
50
  // Reserve space for ellipsis if truncating
41
- truncated = value.slice(0, width - 1) + "…";
51
+ truncated = value.slice(0, Math.max(1, width - 1)) + "…";
42
52
  }
43
53
  else {
44
54
  truncated = value;
@@ -0,0 +1,56 @@
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
+ import { VERSION } from "../version.js";
6
+ /**
7
+ * Version check component that checks npm for updates and displays a notification
8
+ * Restored from git history and enhanced with better visual styling
9
+ */
10
+ export const UpdateNotification = () => {
11
+ const [updateAvailable, setUpdateAvailable] = React.useState(null);
12
+ const [isChecking, setIsChecking] = React.useState(true);
13
+ React.useEffect(() => {
14
+ const checkForUpdates = async () => {
15
+ try {
16
+ const currentVersion = VERSION;
17
+ const response = await fetch("https://registry.npmjs.org/@runloop/rl-cli/latest");
18
+ if (response.ok) {
19
+ const data = (await response.json());
20
+ const latestVersion = data.version;
21
+ if (latestVersion && latestVersion !== currentVersion) {
22
+ // Check if current version is older than latest
23
+ const compareVersions = (version1, version2) => {
24
+ const v1parts = version1.split(".").map(Number);
25
+ const v2parts = version2.split(".").map(Number);
26
+ for (let i = 0; i < Math.max(v1parts.length, v2parts.length); i++) {
27
+ const v1part = v1parts[i] || 0;
28
+ const v2part = v2parts[i] || 0;
29
+ if (v1part > v2part)
30
+ return 1;
31
+ if (v1part < v2part)
32
+ return -1;
33
+ }
34
+ return 0;
35
+ };
36
+ const isUpdateAvailable = compareVersions(latestVersion, currentVersion) > 0;
37
+ if (isUpdateAvailable) {
38
+ setUpdateAvailable(latestVersion);
39
+ }
40
+ }
41
+ }
42
+ }
43
+ catch {
44
+ // Silently fail
45
+ }
46
+ finally {
47
+ setIsChecking(false);
48
+ }
49
+ };
50
+ checkForUpdates();
51
+ }, []);
52
+ if (isChecking || !updateAvailable) {
53
+ return null;
54
+ }
55
+ return (_jsxs(Box, { borderStyle: "round", borderColor: colors.warning, paddingX: 1, paddingY: 0, marginTop: 0, children: [_jsx(Text, { color: colors.warning, bold: true, children: "\u2728" }), _jsxs(Text, { color: colors.text, bold: true, children: [" ", "Update available:", " "] }), _jsx(Text, { color: colors.warning, bold: true, children: VERSION }), _jsxs(Text, { color: colors.primary, bold: true, children: [" ", "\u2192", " "] }), _jsx(Text, { color: colors.success, bold: true, children: updateAvailable }), _jsxs(Text, { color: colors.text, bold: true, children: [" ", "\u2022 Run:", " "] }), _jsx(Text, { color: colors.primary, bold: true, children: "npm install -g @runloop/rl-cli@latest" })] }));
56
+ };