@runloop/rl-cli 0.1.1 → 0.2.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 (96) hide show
  1. package/README.md +54 -0
  2. package/dist/cli.js +73 -60
  3. package/dist/commands/auth.js +0 -1
  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 +215 -213
  7. package/dist/commands/blueprint/logs.js +133 -37
  8. package/dist/commands/blueprint/preview.js +42 -38
  9. package/dist/commands/config.js +117 -0
  10. package/dist/commands/devbox/create.js +120 -40
  11. package/dist/commands/devbox/delete.js +17 -33
  12. package/dist/commands/devbox/download.js +29 -43
  13. package/dist/commands/devbox/exec.js +22 -39
  14. package/dist/commands/devbox/execAsync.js +20 -37
  15. package/dist/commands/devbox/get.js +13 -35
  16. package/dist/commands/devbox/getAsync.js +12 -34
  17. package/dist/commands/devbox/list.js +241 -402
  18. package/dist/commands/devbox/logs.js +20 -38
  19. package/dist/commands/devbox/read.js +29 -43
  20. package/dist/commands/devbox/resume.js +13 -35
  21. package/dist/commands/devbox/rsync.js +26 -78
  22. package/dist/commands/devbox/scp.js +25 -79
  23. package/dist/commands/devbox/sendStdin.js +41 -0
  24. package/dist/commands/devbox/shutdown.js +13 -35
  25. package/dist/commands/devbox/ssh.js +45 -78
  26. package/dist/commands/devbox/suspend.js +13 -35
  27. package/dist/commands/devbox/tunnel.js +36 -88
  28. package/dist/commands/devbox/upload.js +28 -36
  29. package/dist/commands/devbox/write.js +29 -44
  30. package/dist/commands/mcp-install.js +4 -3
  31. package/dist/commands/menu.js +24 -66
  32. package/dist/commands/object/delete.js +12 -34
  33. package/dist/commands/object/download.js +26 -74
  34. package/dist/commands/object/get.js +12 -34
  35. package/dist/commands/object/list.js +15 -93
  36. package/dist/commands/object/upload.js +35 -96
  37. package/dist/commands/snapshot/create.js +23 -39
  38. package/dist/commands/snapshot/delete.js +17 -33
  39. package/dist/commands/snapshot/get.js +16 -0
  40. package/dist/commands/snapshot/list.js +309 -80
  41. package/dist/commands/snapshot/status.js +12 -34
  42. package/dist/components/ActionsPopup.js +63 -39
  43. package/dist/components/Breadcrumb.js +10 -52
  44. package/dist/components/DevboxActionsMenu.js +182 -110
  45. package/dist/components/DevboxCreatePage.js +12 -7
  46. package/dist/components/DevboxDetailPage.js +76 -28
  47. package/dist/components/ErrorBoundary.js +29 -0
  48. package/dist/components/ErrorMessage.js +10 -2
  49. package/dist/components/Header.js +12 -4
  50. package/dist/components/InteractiveSpawn.js +94 -0
  51. package/dist/components/MainMenu.js +36 -32
  52. package/dist/components/MetadataDisplay.js +4 -4
  53. package/dist/components/OperationsMenu.js +1 -1
  54. package/dist/components/ResourceActionsMenu.js +4 -4
  55. package/dist/components/ResourceListView.js +46 -34
  56. package/dist/components/Spinner.js +7 -2
  57. package/dist/components/StatusBadge.js +1 -1
  58. package/dist/components/SuccessMessage.js +12 -2
  59. package/dist/components/Table.js +16 -6
  60. package/dist/hooks/useCursorPagination.js +125 -85
  61. package/dist/hooks/useExitOnCtrlC.js +14 -0
  62. package/dist/hooks/useViewportHeight.js +47 -0
  63. package/dist/mcp/server.js +65 -6
  64. package/dist/router/Router.js +68 -0
  65. package/dist/router/types.js +1 -0
  66. package/dist/screens/BlueprintListScreen.js +7 -0
  67. package/dist/screens/DevboxActionsScreen.js +25 -0
  68. package/dist/screens/DevboxCreateScreen.js +11 -0
  69. package/dist/screens/DevboxDetailScreen.js +60 -0
  70. package/dist/screens/DevboxListScreen.js +23 -0
  71. package/dist/screens/LogsSessionScreen.js +49 -0
  72. package/dist/screens/MenuScreen.js +23 -0
  73. package/dist/screens/SSHSessionScreen.js +55 -0
  74. package/dist/screens/SnapshotListScreen.js +7 -0
  75. package/dist/services/blueprintService.js +105 -0
  76. package/dist/services/devboxService.js +215 -0
  77. package/dist/services/snapshotService.js +81 -0
  78. package/dist/store/blueprintStore.js +89 -0
  79. package/dist/store/devboxStore.js +105 -0
  80. package/dist/store/index.js +7 -0
  81. package/dist/store/navigationStore.js +101 -0
  82. package/dist/store/snapshotStore.js +87 -0
  83. package/dist/utils/CommandExecutor.js +53 -24
  84. package/dist/utils/client.js +0 -2
  85. package/dist/utils/config.js +20 -90
  86. package/dist/utils/interactiveCommand.js +3 -2
  87. package/dist/utils/logFormatter.js +162 -0
  88. package/dist/utils/memoryMonitor.js +85 -0
  89. package/dist/utils/output.js +150 -59
  90. package/dist/utils/screen.js +23 -0
  91. package/dist/utils/ssh.js +3 -1
  92. package/dist/utils/sshSession.js +5 -29
  93. package/dist/utils/terminalDetection.js +97 -0
  94. package/dist/utils/terminalSync.js +39 -0
  95. package/dist/utils/theme.js +147 -13
  96. package/package.json +16 -13
@@ -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;
@@ -1,125 +1,165 @@
1
1
  import React from "react";
2
+ /**
3
+ * Hook for cursor-based pagination with polling.
4
+ *
5
+ * Design:
6
+ * - No caching: always fetches fresh data on navigation or poll
7
+ * - Cursor history: tracks the last item ID of each visited page
8
+ * - cursorHistory[N] = last item ID of page N (used as startingAt for page N+1)
9
+ * - Polling: refreshes current page every pollInterval ms
10
+ *
11
+ * Navigation:
12
+ * - Page 0: startingAt = undefined
13
+ * - Page N: startingAt = cursorHistory[N-1]
14
+ * - Going back uses known cursor from history
15
+ */
2
16
  export function useCursorPagination(config) {
17
+ const { fetchPage, pageSize, getItemId, pollInterval = 2000, deps = [], pollingEnabled = true, } = config;
18
+ // State
3
19
  const [items, setItems] = React.useState([]);
4
20
  const [loading, setLoading] = React.useState(true);
21
+ const [navigating, setNavigating] = React.useState(false);
5
22
  const [error, setError] = React.useState(null);
6
23
  const [currentPage, setCurrentPage] = React.useState(0);
7
- const [totalCount, setTotalCount] = React.useState(0);
8
24
  const [hasMore, setHasMore] = React.useState(false);
9
- const [refreshing, setRefreshing] = React.useState(false);
10
- // Cache for page data and cursors
11
- const pageCache = React.useRef(new Map());
12
- const lastIdCache = React.useRef(new Map());
13
- const fetchData = React.useCallback(async (isInitialLoad = false) => {
25
+ const [totalCount, setTotalCount] = React.useState(0);
26
+ // Cursor history: cursorHistory[N] = last item ID of page N
27
+ // Used to determine startingAt for page N+1
28
+ const cursorHistoryRef = React.useRef([]);
29
+ // Track if component is mounted
30
+ const isMountedRef = React.useRef(true);
31
+ // Track if we're currently fetching (to prevent concurrent fetches)
32
+ const isFetchingRef = React.useRef(false);
33
+ // Store stable references to config
34
+ const fetchPageRef = React.useRef(fetchPage);
35
+ const getItemIdRef = React.useRef(getItemId);
36
+ const pageSizeRef = React.useRef(pageSize);
37
+ // Keep refs in sync
38
+ React.useEffect(() => {
39
+ fetchPageRef.current = fetchPage;
40
+ }, [fetchPage]);
41
+ React.useEffect(() => {
42
+ getItemIdRef.current = getItemId;
43
+ }, [getItemId]);
44
+ React.useEffect(() => {
45
+ pageSizeRef.current = pageSize;
46
+ }, [pageSize]);
47
+ // Cleanup on unmount
48
+ React.useEffect(() => {
49
+ isMountedRef.current = true;
50
+ return () => {
51
+ isMountedRef.current = false;
52
+ };
53
+ }, []);
54
+ /**
55
+ * Fetch a specific page
56
+ * @param page - Page number to fetch (0-indexed)
57
+ * @param isInitialLoad - Whether this is the initial load (shows loading state)
58
+ * @param isNavigation - Whether this is a page navigation (shows navigating state)
59
+ */
60
+ const fetchPageData = React.useCallback(async (page, isInitialLoad = false, isNavigation = false) => {
61
+ if (!isMountedRef.current)
62
+ return;
63
+ if (isFetchingRef.current)
64
+ return;
65
+ isFetchingRef.current = true;
14
66
  try {
15
67
  if (isInitialLoad) {
16
- setRefreshing(true);
17
- }
18
- setLoading(true);
19
- // Check cache first (skip on refresh)
20
- if (!isInitialLoad && pageCache.current.has(currentPage)) {
21
- setItems(pageCache.current.get(currentPage) || []);
22
- setLoading(false);
23
- return;
68
+ setLoading(true);
24
69
  }
25
- const pageItems = [];
26
- // Get starting_at cursor from previous page's last ID
27
- const startingAt = currentPage > 0
28
- ? lastIdCache.current.get(currentPage - 1)
29
- : undefined;
30
- // Build query params
31
- const queryParams = {
32
- limit: config.pageSize,
33
- ...config.queryParams,
34
- };
35
- if (startingAt) {
36
- queryParams.starting_at = startingAt;
70
+ if (isNavigation) {
71
+ setNavigating(true);
37
72
  }
38
- // Fetch the page
39
- const result = await config.fetchPage(queryParams);
40
- // Extract items (handle both array response and paginated response)
41
- const fetchedItems = Array.isArray(result) ? result : result.items;
42
- pageItems.push(...fetchedItems.slice(0, config.pageSize));
43
- // Update pagination metadata
44
- if (!Array.isArray(result)) {
45
- setTotalCount(result.total_count || pageItems.length);
46
- setHasMore(result.has_more || false);
47
- }
48
- else {
49
- setTotalCount(pageItems.length);
50
- setHasMore(false);
73
+ setError(null);
74
+ // Determine startingAt cursor:
75
+ // - Page 0: undefined
76
+ // - Page N: cursorHistory[N-1] (last item ID from previous page)
77
+ const startingAt = page > 0 ? cursorHistoryRef.current[page - 1] : undefined;
78
+ const result = await fetchPageRef.current({
79
+ limit: pageSizeRef.current,
80
+ startingAt,
81
+ });
82
+ if (!isMountedRef.current)
83
+ return;
84
+ // Update items
85
+ setItems(result.items);
86
+ // Update cursor history for this page
87
+ if (result.items.length > 0) {
88
+ const lastItemId = getItemIdRef.current(result.items[result.items.length - 1]);
89
+ cursorHistoryRef.current[page] = lastItemId;
51
90
  }
52
- // Cache the page data and last ID
53
- if (pageItems.length > 0) {
54
- pageCache.current.set(currentPage, pageItems);
55
- lastIdCache.current.set(currentPage, config.getItemId(pageItems[pageItems.length - 1]));
91
+ // Update pagination state
92
+ setHasMore(result.hasMore);
93
+ if (result.totalCount !== undefined) {
94
+ setTotalCount(result.totalCount);
56
95
  }
57
- // Update items for current page
58
- setItems(pageItems);
59
96
  }
60
97
  catch (err) {
98
+ if (!isMountedRef.current)
99
+ return;
61
100
  setError(err);
62
101
  }
63
102
  finally {
64
- setLoading(false);
65
- if (isInitialLoad) {
66
- setTimeout(() => setRefreshing(false), 300);
103
+ if (isMountedRef.current) {
104
+ setLoading(false);
105
+ setNavigating(false);
67
106
  }
107
+ isFetchingRef.current = false;
68
108
  }
69
- }, [currentPage, config]);
70
- // Initial load and page changes
109
+ }, []);
110
+ // Reset when deps change (e.g., filters, search)
111
+ const depsKey = JSON.stringify(deps);
71
112
  React.useEffect(() => {
72
- fetchData(true);
73
- }, [fetchData, currentPage]);
74
- // Auto-refresh
113
+ // Clear cursor history when deps change
114
+ cursorHistoryRef.current = [];
115
+ setCurrentPage(0);
116
+ setItems([]);
117
+ setHasMore(false);
118
+ setTotalCount(0);
119
+ // Fetch page 0
120
+ fetchPageData(0, true);
121
+ }, [depsKey, fetchPageData]);
122
+ // Polling effect
75
123
  React.useEffect(() => {
76
- if (!config.refreshInterval || config.refreshInterval <= 0) {
124
+ if (!pollInterval || pollInterval <= 0 || !pollingEnabled) {
77
125
  return;
78
126
  }
79
- const interval = setInterval(() => {
80
- // Clear cache on refresh
81
- pageCache.current.clear();
82
- lastIdCache.current.clear();
83
- fetchData(false);
84
- }, config.refreshInterval);
85
- return () => clearInterval(interval);
86
- }, [config.refreshInterval, fetchData]);
127
+ const timer = setInterval(() => {
128
+ if (isMountedRef.current && !isFetchingRef.current) {
129
+ fetchPageData(currentPage, false);
130
+ }
131
+ }, pollInterval);
132
+ return () => clearInterval(timer);
133
+ }, [pollInterval, pollingEnabled, currentPage, fetchPageData]);
134
+ // Navigation functions
87
135
  const nextPage = React.useCallback(() => {
88
- if (!loading && hasMore) {
89
- setCurrentPage((prev) => prev + 1);
136
+ if (!loading && !navigating && hasMore) {
137
+ const newPage = currentPage + 1;
138
+ setCurrentPage(newPage);
139
+ fetchPageData(newPage, false, true);
90
140
  }
91
- }, [loading, hasMore]);
141
+ }, [loading, navigating, hasMore, currentPage, fetchPageData]);
92
142
  const prevPage = React.useCallback(() => {
93
- if (!loading && currentPage > 0) {
94
- setCurrentPage((prev) => prev - 1);
95
- }
96
- }, [loading, currentPage]);
97
- const goToPage = React.useCallback((page) => {
98
- if (!loading && page >= 0) {
99
- setCurrentPage(page);
143
+ if (!loading && !navigating && currentPage > 0) {
144
+ const newPage = currentPage - 1;
145
+ setCurrentPage(newPage);
146
+ fetchPageData(newPage, false, true);
100
147
  }
101
- }, [loading]);
148
+ }, [loading, navigating, currentPage, fetchPageData]);
102
149
  const refresh = React.useCallback(() => {
103
- pageCache.current.clear();
104
- lastIdCache.current.clear();
105
- fetchData(true);
106
- }, [fetchData]);
107
- const clearCache = React.useCallback(() => {
108
- pageCache.current.clear();
109
- lastIdCache.current.clear();
110
- }, []);
150
+ fetchPageData(currentPage, false);
151
+ }, [currentPage, fetchPageData]);
111
152
  return {
112
153
  items,
113
154
  loading,
155
+ navigating,
114
156
  error,
115
157
  currentPage,
116
- totalCount,
117
158
  hasMore,
118
- refreshing,
159
+ hasPrev: currentPage > 0,
160
+ totalCount,
119
161
  nextPage,
120
162
  prevPage,
121
- goToPage,
122
163
  refresh,
123
- clearCache,
124
164
  };
125
165
  }
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Hook to handle Ctrl+C (SIGINT) consistently across all screens
3
+ * Exits the program with proper cleanup of alternate screen buffer
4
+ */
5
+ import { useInput } from "ink";
6
+ import { exitAlternateScreenBuffer } from "../utils/screen.js";
7
+ export function useExitOnCtrlC() {
8
+ useInput((input, key) => {
9
+ if (key.ctrl && input === "c") {
10
+ exitAlternateScreenBuffer();
11
+ process.exit(130); // Standard exit code for SIGINT
12
+ }
13
+ });
14
+ }
@@ -0,0 +1,47 @@
1
+ import React from "react";
2
+ import { useStdout } from "ink";
3
+ /**
4
+ * Custom hook to calculate available viewport height for content rendering.
5
+ * Ensures consistent layout calculations across all CLI screens and prevents overflow.
6
+ *
7
+ * @param options Configuration for viewport calculation
8
+ * @returns Viewport dimensions including available height for content
9
+ *
10
+ * @example
11
+ * ```tsx
12
+ * const { viewportHeight } = useViewportHeight({ overhead: 10 });
13
+ * const pageSize = viewportHeight; // Use for dynamic page sizing
14
+ * ```
15
+ */
16
+ export function useViewportHeight(options = {}) {
17
+ const { overhead = 0, minHeight = 5, maxHeight = 100 } = options;
18
+ const { stdout } = useStdout();
19
+ // Sample terminal dimensions ONCE and use fixed values - no reactive dependencies
20
+ // This prevents re-renders and Yoga WASM crashes from dynamic resizing
21
+ // CRITICAL: Initialize with safe fallback values to prevent null/undefined
22
+ const dimensions = React.useRef({
23
+ width: 120,
24
+ height: 30,
25
+ });
26
+ // Only sample on first call when still at default values
27
+ if (dimensions.current.width === 120 && dimensions.current.height === 30) {
28
+ // Only sample if stdout has valid dimensions
29
+ const sampledWidth = stdout?.columns && stdout.columns > 0 ? stdout.columns : 120;
30
+ const sampledHeight = stdout?.rows && stdout.rows > 0 ? stdout.rows : 30;
31
+ // Always enforce safe bounds to prevent Yoga crashes
32
+ dimensions.current = {
33
+ width: Math.max(80, Math.min(200, sampledWidth)),
34
+ height: Math.max(20, Math.min(100, sampledHeight)),
35
+ };
36
+ }
37
+ const terminalHeight = dimensions.current.height;
38
+ const terminalWidth = dimensions.current.width;
39
+ // Calculate viewport height with bounds
40
+ const viewportHeight = Math.max(minHeight, Math.min(maxHeight, terminalHeight - overhead));
41
+ // Removed console.logs to prevent rendering interference
42
+ return {
43
+ viewportHeight,
44
+ terminalHeight,
45
+ terminalWidth,
46
+ };
47
+ }