@runloop/rl-cli 1.8.0 → 1.10.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 (66) hide show
  1. package/README.md +21 -7
  2. package/dist/cli.js +0 -0
  3. package/dist/commands/blueprint/delete.js +21 -0
  4. package/dist/commands/blueprint/list.js +226 -174
  5. package/dist/commands/blueprint/prune.js +13 -28
  6. package/dist/commands/devbox/create.js +41 -0
  7. package/dist/commands/devbox/list.js +142 -110
  8. package/dist/commands/devbox/rsync.js +69 -41
  9. package/dist/commands/devbox/scp.js +180 -39
  10. package/dist/commands/devbox/tunnel.js +4 -19
  11. package/dist/commands/gateway-config/create.js +53 -0
  12. package/dist/commands/gateway-config/delete.js +21 -0
  13. package/dist/commands/gateway-config/get.js +18 -0
  14. package/dist/commands/gateway-config/list.js +493 -0
  15. package/dist/commands/gateway-config/update.js +70 -0
  16. package/dist/commands/snapshot/list.js +11 -2
  17. package/dist/commands/snapshot/prune.js +265 -0
  18. package/dist/components/BenchmarkMenu.js +23 -3
  19. package/dist/components/DetailedInfoView.js +20 -0
  20. package/dist/components/DevboxActionsMenu.js +26 -62
  21. package/dist/components/DevboxCreatePage.js +763 -15
  22. package/dist/components/DevboxDetailPage.js +73 -24
  23. package/dist/components/GatewayConfigCreatePage.js +272 -0
  24. package/dist/components/LogsViewer.js +6 -40
  25. package/dist/components/ResourceDetailPage.js +143 -160
  26. package/dist/components/ResourceListView.js +3 -33
  27. package/dist/components/ResourcePicker.js +234 -0
  28. package/dist/components/SecretCreatePage.js +71 -27
  29. package/dist/components/SettingsMenu.js +12 -2
  30. package/dist/components/StateHistory.js +1 -20
  31. package/dist/components/StatusBadge.js +9 -2
  32. package/dist/components/StreamingLogsViewer.js +8 -42
  33. package/dist/components/form/FormTextInput.js +4 -2
  34. package/dist/components/resourceDetailTypes.js +18 -0
  35. package/dist/hooks/useInputHandler.js +103 -0
  36. package/dist/router/Router.js +79 -2
  37. package/dist/screens/BenchmarkDetailScreen.js +163 -0
  38. package/dist/screens/BenchmarkJobCreateScreen.js +524 -0
  39. package/dist/screens/BenchmarkJobDetailScreen.js +614 -0
  40. package/dist/screens/BenchmarkJobListScreen.js +479 -0
  41. package/dist/screens/BenchmarkListScreen.js +266 -0
  42. package/dist/screens/BenchmarkMenuScreen.js +6 -0
  43. package/dist/screens/BenchmarkRunDetailScreen.js +258 -22
  44. package/dist/screens/BenchmarkRunListScreen.js +21 -1
  45. package/dist/screens/BlueprintDetailScreen.js +5 -1
  46. package/dist/screens/DevboxCreateScreen.js +2 -2
  47. package/dist/screens/GatewayConfigDetailScreen.js +236 -0
  48. package/dist/screens/GatewayConfigListScreen.js +7 -0
  49. package/dist/screens/ScenarioRunDetailScreen.js +6 -0
  50. package/dist/screens/SecretDetailScreen.js +26 -2
  51. package/dist/screens/SettingsMenuScreen.js +3 -0
  52. package/dist/screens/SnapshotDetailScreen.js +6 -0
  53. package/dist/services/agentService.js +42 -0
  54. package/dist/services/benchmarkJobService.js +122 -0
  55. package/dist/services/benchmarkService.js +47 -0
  56. package/dist/services/gatewayConfigService.js +153 -0
  57. package/dist/services/scenarioService.js +34 -0
  58. package/dist/store/benchmarkJobStore.js +66 -0
  59. package/dist/store/benchmarkStore.js +63 -0
  60. package/dist/store/gatewayConfigStore.js +83 -0
  61. package/dist/utils/browser.js +22 -0
  62. package/dist/utils/clipboard.js +41 -0
  63. package/dist/utils/commands.js +105 -9
  64. package/dist/utils/gatewayConfigValidation.js +58 -0
  65. package/dist/utils/time.js +121 -0
  66. package/package.json +43 -43
@@ -4,35 +4,21 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
4
4
  * Can be used for devboxes, blueprints, snapshots, etc.
5
5
  */
6
6
  import React from "react";
7
- import { Box, Text, useInput } from "ink";
7
+ import { Box, Text } from "ink";
8
8
  import figures from "figures";
9
- import { Header } from "./Header.js";
10
9
  import { StatusBadge } from "./StatusBadge.js";
11
10
  import { Breadcrumb } from "./Breadcrumb.js";
11
+ import { DetailedInfoView } from "./DetailedInfoView.js";
12
12
  import { NavigationTips } from "./NavigationTips.js";
13
13
  import { colors } from "../utils/theme.js";
14
14
  import { useViewportHeight } from "../hooks/useViewportHeight.js";
15
15
  import { useExitOnCtrlC } from "../hooks/useExitOnCtrlC.js";
16
- // Format time ago in a succinct way
17
- const formatTimeAgo = (timestamp) => {
18
- const seconds = Math.floor((Date.now() - timestamp) / 1000);
19
- if (seconds < 60)
20
- return `${seconds}s ago`;
21
- const minutes = Math.floor(seconds / 60);
22
- if (minutes < 60)
23
- return `${minutes}m ago`;
24
- const hours = Math.floor(minutes / 60);
25
- if (hours < 24)
26
- return `${hours}h ago`;
27
- const days = Math.floor(hours / 24);
28
- if (days < 30)
29
- return `${days}d ago`;
30
- const months = Math.floor(days / 30);
31
- if (months < 12)
32
- return `${months}mo ago`;
33
- const years = Math.floor(months / 12);
34
- return `${years}y ago`;
35
- };
16
+ import { useInputHandler, scrollBindings, } from "../hooks/useInputHandler.js";
17
+ import { useNavigation } from "../store/navigationStore.js";
18
+ import { openInBrowser as openUrlInBrowser } from "../utils/browser.js";
19
+ import { copyToClipboard } from "../utils/clipboard.js";
20
+ import { collectActionableFields, } from "./resourceDetailTypes.js";
21
+ export { collectActionableFields } from "./resourceDetailTypes.js";
36
22
  // Truncate long strings to prevent layout issues
37
23
  const truncateString = (str, maxLength) => {
38
24
  if (str.length <= maxLength)
@@ -41,6 +27,7 @@ const truncateString = (str, maxLength) => {
41
27
  };
42
28
  export function ResourceDetailPage({ resource: initialResource, resourceType, getDisplayName, getId, getStatus, getUrl, breadcrumbPrefix = [], detailSections, operations, onOperation, onBack, buildDetailLines, additionalContent, pollResource, pollInterval = 3000, }) {
43
29
  const isMounted = React.useRef(true);
30
+ const { navigate } = useNavigation();
44
31
  // Track mounted state
45
32
  React.useEffect(() => {
46
33
  isMounted.current = true;
@@ -51,45 +38,27 @@ export function ResourceDetailPage({ resource: initialResource, resourceType, ge
51
38
  // Local state for resource data (updated by polling)
52
39
  const [currentResource, setCurrentResource] = React.useState(initialResource);
53
40
  const [copyStatus, setCopyStatus] = React.useState(null);
54
- // Copy to clipboard helper
55
- const copyToClipboard = React.useCallback(async (text) => {
56
- const { spawn } = await import("child_process");
57
- const platform = process.platform;
58
- let command;
59
- let args;
60
- if (platform === "darwin") {
61
- command = "pbcopy";
62
- args = [];
63
- }
64
- else if (platform === "win32") {
65
- command = "clip";
66
- args = [];
67
- }
68
- else {
69
- command = "xclip";
70
- args = ["-selection", "clipboard"];
71
- }
72
- const proc = spawn(command, args);
73
- proc.stdin.write(text);
74
- proc.stdin.end();
75
- proc.on("exit", (code) => {
76
- if (code === 0) {
77
- setCopyStatus("Copied ID to clipboard!");
78
- setTimeout(() => setCopyStatus(null), 2000);
79
- }
80
- else {
81
- setCopyStatus("Failed to copy");
82
- setTimeout(() => setCopyStatus(null), 2000);
83
- }
84
- });
85
- proc.on("error", () => {
86
- setCopyStatus("Copy not supported");
87
- setTimeout(() => setCopyStatus(null), 2000);
88
- });
41
+ // Copy to clipboard with status feedback
42
+ const handleCopy = React.useCallback(async (text) => {
43
+ const status = await copyToClipboard(text);
44
+ setCopyStatus(status);
45
+ setTimeout(() => setCopyStatus(null), 2000);
89
46
  }, []);
90
47
  const [showDetailedInfo, setShowDetailedInfo] = React.useState(false);
91
48
  const [detailScroll, setDetailScroll] = React.useState(0);
92
- const [selectedOperation, setSelectedOperation] = React.useState(0);
49
+ // Unified selectable items: actionable detail fields followed by operations.
50
+ // Arrow keys move through the entire list seamlessly.
51
+ const actionableFields = React.useMemo(() => collectActionableFields(detailSections), [detailSections]);
52
+ const totalSelectableItems = actionableFields.length + operations.length;
53
+ // Default selection is the first operation (skip links)
54
+ const [selectedIndex, setSelectedIndex] = React.useState(actionableFields.length);
55
+ // Clamp selectedIndex when the number of selectable items shrinks
56
+ // (e.g. operations list changes due to a status change from polling)
57
+ React.useEffect(() => {
58
+ if (totalSelectableItems > 0 && selectedIndex >= totalSelectableItems) {
59
+ setSelectedIndex(totalSelectableItems - 1);
60
+ }
61
+ }, [totalSelectableItems, selectedIndex]);
93
62
  // Background polling for resource details
94
63
  React.useEffect(() => {
95
64
  if (!pollResource || showDetailedInfo)
@@ -114,98 +83,118 @@ export function ResourceDetailPage({ resource: initialResource, resourceType, ge
114
83
  const displayName = getDisplayName(currentResource);
115
84
  const resourceId = getId(currentResource);
116
85
  const status = getStatus(currentResource);
86
+ // Execute a field action
87
+ const executeFieldAction = React.useCallback((action) => {
88
+ if (action.type === "navigate" && action.screen) {
89
+ navigate(action.screen, action.params || {});
90
+ }
91
+ else if (action.type === "callback" && action.handler) {
92
+ action.handler();
93
+ }
94
+ }, [navigate]);
117
95
  // Handle Ctrl+C to exit
118
96
  useExitOnCtrlC();
119
- useInput((input, key) => {
120
- if (!isMounted.current)
97
+ // Helper: is the current selection on a link or an operation?
98
+ const isOnLink = selectedIndex < actionableFields.length;
99
+ const operationIndex = selectedIndex - actionableFields.length;
100
+ const handleOpenInBrowser = React.useCallback(() => {
101
+ if (!getUrl)
121
102
  return;
122
- // Handle detailed info mode
123
- if (showDetailedInfo) {
124
- if (input === "q" || key.escape) {
125
- setShowDetailedInfo(false);
126
- setDetailScroll(0);
127
- }
128
- else if (input === "j" || input === "s" || key.downArrow) {
129
- setDetailScroll(detailScroll + 1);
130
- }
131
- else if (input === "k" || input === "w" || key.upArrow) {
132
- setDetailScroll(Math.max(0, detailScroll - 1));
133
- }
134
- else if (key.pageDown) {
135
- setDetailScroll(detailScroll + 10);
136
- }
137
- else if (key.pageUp) {
138
- setDetailScroll(Math.max(0, detailScroll - 10));
103
+ openUrlInBrowser(getUrl(currentResource));
104
+ }, [getUrl, currentResource]);
105
+ const exitDetailedInfo = React.useCallback(() => {
106
+ setShowDetailedInfo(false);
107
+ setDetailScroll(0);
108
+ }, []);
109
+ const handleEnter = React.useCallback(() => {
110
+ if (isOnLink) {
111
+ const ref = actionableFields[selectedIndex];
112
+ if (ref) {
113
+ executeFieldAction(ref.action);
139
114
  }
140
- return;
141
- }
142
- // Main view input handling
143
- if (input === "q" || key.escape) {
144
- onBack();
145
- }
146
- else if (input === "c" && !key.ctrl) {
147
- // Copy resource ID to clipboard (ignore if Ctrl+C for quit)
148
- copyToClipboard(getId(currentResource));
149
- }
150
- else if (input === "i" && buildDetailLines) {
151
- setShowDetailedInfo(true);
152
- setDetailScroll(0);
153
115
  }
154
- else if (key.upArrow && selectedOperation > 0) {
155
- setSelectedOperation(selectedOperation - 1);
156
- }
157
- else if (key.downArrow && selectedOperation < operations.length - 1) {
158
- setSelectedOperation(selectedOperation + 1);
159
- }
160
- else if (key.return) {
161
- const op = operations[selectedOperation];
116
+ else {
117
+ const op = operations[operationIndex];
162
118
  if (op) {
163
119
  onOperation(op.key, currentResource);
164
120
  }
165
121
  }
166
- else if (input) {
167
- // Check if input matches any operation shortcut
168
- const matchedOpIndex = operations.findIndex((op) => op.shortcut === input);
169
- if (matchedOpIndex !== -1) {
170
- setSelectedOperation(matchedOpIndex);
171
- onOperation(operations[matchedOpIndex].key, currentResource);
172
- }
173
- }
174
- if (input === "o" && getUrl) {
175
- const url = getUrl(currentResource);
176
- const openBrowser = async () => {
177
- const { exec } = await import("child_process");
178
- const platform = process.platform;
179
- let openCommand;
180
- if (platform === "darwin") {
181
- openCommand = `open "${url}"`;
182
- }
183
- else if (platform === "win32") {
184
- openCommand = `start "${url}"`;
185
- }
186
- else {
187
- openCommand = `xdg-open "${url}"`;
122
+ }, [
123
+ isOnLink,
124
+ actionableFields,
125
+ selectedIndex,
126
+ operationIndex,
127
+ operations,
128
+ currentResource,
129
+ executeFieldAction,
130
+ onOperation,
131
+ ]);
132
+ const inputModes = React.useMemo(() => [
133
+ {
134
+ name: "detailedInfo",
135
+ active: () => showDetailedInfo,
136
+ bindings: {
137
+ ...scrollBindings(() => detailScroll, setDetailScroll),
138
+ q: exitDetailedInfo,
139
+ escape: exitDetailedInfo,
140
+ },
141
+ },
142
+ {
143
+ name: "mainView",
144
+ active: () => true,
145
+ bindings: {
146
+ q: onBack,
147
+ escape: onBack,
148
+ c: () => handleCopy(getId(currentResource)),
149
+ ...(buildDetailLines
150
+ ? {
151
+ i: () => {
152
+ setShowDetailedInfo(true);
153
+ setDetailScroll(0);
154
+ },
155
+ }
156
+ : {}),
157
+ up: () => {
158
+ if (selectedIndex > 0)
159
+ setSelectedIndex(selectedIndex - 1);
160
+ },
161
+ down: () => {
162
+ if (selectedIndex < totalSelectableItems - 1)
163
+ setSelectedIndex(selectedIndex + 1);
164
+ },
165
+ enter: handleEnter,
166
+ ...(getUrl ? { o: handleOpenInBrowser } : {}),
167
+ },
168
+ onUnmatched: (input) => {
169
+ // Operation shortcuts work from anywhere
170
+ const matchedOpIndex = operations.findIndex((op) => op.shortcut === input);
171
+ if (matchedOpIndex !== -1) {
172
+ setSelectedIndex(actionableFields.length + matchedOpIndex);
173
+ onOperation(operations[matchedOpIndex].key, currentResource);
188
174
  }
189
- exec(openCommand);
190
- };
191
- openBrowser();
192
- }
193
- });
175
+ },
176
+ },
177
+ ], [
178
+ showDetailedInfo,
179
+ detailScroll,
180
+ exitDetailedInfo,
181
+ onBack,
182
+ currentResource,
183
+ buildDetailLines,
184
+ selectedIndex,
185
+ totalSelectableItems,
186
+ handleEnter,
187
+ getUrl,
188
+ handleOpenInBrowser,
189
+ operations,
190
+ actionableFields,
191
+ onOperation,
192
+ getId,
193
+ ]);
194
+ useInputHandler(inputModes, { isActive: isMounted.current });
194
195
  // Detailed info mode - full screen
195
196
  if (showDetailedInfo && buildDetailLines) {
196
- const detailLines = buildDetailLines(currentResource);
197
- const viewportHeight = detailViewport.viewportHeight;
198
- const maxScroll = Math.max(0, detailLines.length - viewportHeight);
199
- const actualScroll = Math.min(detailScroll, maxScroll);
200
- const visibleLines = detailLines.slice(actualScroll, actualScroll + viewportHeight);
201
- const hasMore = actualScroll + viewportHeight < detailLines.length;
202
- const hasLess = actualScroll > 0;
203
- return (_jsxs(_Fragment, { children: [_jsx(Breadcrumb, { items: [
204
- ...breadcrumbPrefix,
205
- { label: resourceType },
206
- { label: displayName },
207
- { label: "Full Details", active: true },
208
- ] }), _jsx(Header, { title: `${displayName} - Complete Information` }), _jsx(Box, { flexDirection: "column", marginBottom: 1, children: _jsxs(Box, { marginBottom: 1, children: [_jsx(StatusBadge, { status: status }), _jsx(Text, { children: " " }), _jsx(Text, { color: colors.idColor, children: resourceId })] }) }), _jsx(Box, { flexDirection: "column", marginTop: 1, marginBottom: 1, borderStyle: "round", borderColor: colors.border, paddingX: 2, paddingY: 1, children: _jsx(Box, { flexDirection: "column", children: visibleLines }) }), _jsxs(Box, { marginTop: 1, children: [_jsxs(Text, { color: colors.textDim, dimColor: true, children: [figures.arrowUp, figures.arrowDown, " Scroll \u2022 Line ", actualScroll + 1, "-", Math.min(actualScroll + viewportHeight, detailLines.length), " of", " ", detailLines.length] }), hasLess && _jsxs(Text, { color: colors.primary, children: [" ", figures.arrowUp] }), hasMore && _jsxs(Text, { color: colors.primary, children: [" ", figures.arrowDown] }), _jsxs(Text, { color: colors.textDim, dimColor: true, children: [" ", "\u2022 [q or esc] Back to Details"] })] })] }));
197
+ return (_jsx(DetailedInfoView, { detailLines: buildDetailLines(currentResource), scrollOffset: detailScroll, viewportHeight: detailViewport.viewportHeight, displayName: displayName, resourceId: resourceId, status: status, resourceType: resourceType, breadcrumbPrefix: breadcrumbPrefix }));
209
198
  }
210
199
  // Main detail view
211
200
  return (_jsxs(_Fragment, { children: [_jsx(Breadcrumb, { items: [
@@ -214,33 +203,27 @@ export function ResourceDetailPage({ resource: initialResource, resourceType, ge
214
203
  { label: displayName, active: true },
215
204
  ] }), _jsxs(Box, { flexDirection: "column", marginTop: 1, marginBottom: 1, paddingX: 1, children: [_jsxs(Box, { flexDirection: "row", flexWrap: "wrap", children: [_jsx(Text, { color: colors.primary, bold: true, children: truncateString(displayName, Math.max(20, detailViewport.terminalWidth - 35)) }), displayName !== resourceId && (_jsxs(Text, { color: colors.idColor, children: [" \u2022 ", resourceId] }))] }), _jsx(Box, { children: _jsx(StatusBadge, { status: status, fullText: true }) })] }), detailSections.map((section, sectionIndex) => (_jsxs(Box, { flexDirection: "column", marginBottom: 1, children: [_jsxs(Text, { color: section.color || colors.warning, bold: true, children: [section.icon || figures.squareSmallFilled, " ", section.title] }), _jsx(Box, { flexDirection: "column", paddingLeft: 2, children: section.fields
216
205
  .filter((field) => field.value !== undefined && field.value !== null)
217
- .map((field, fieldIndex) => (_jsxs(Box, { children: [_jsxs(Text, { color: colors.textDim, children: [field.label, " "] }), typeof field.value === "string" ? (_jsx(Text, { color: field.color, dimColor: !field.color, children: field.value })) : (field.value)] }, fieldIndex))) })] }, sectionIndex))), additionalContent, operations.length > 0 && (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsxs(Text, { color: colors.primary, bold: true, children: [figures.play, " Actions"] }), _jsx(Box, { flexDirection: "column", paddingLeft: 2, children: operations.map((op, index) => {
218
- const isSelected = index === selectedOperation;
206
+ .map((field, fieldIndex) => {
207
+ // Check if this field is an actionable field and whether it's selected
208
+ const isActionable = !!field.action;
209
+ const actionableIdx = isActionable
210
+ ? actionableFields.findIndex((ref) => ref.sectionIndex === sectionIndex &&
211
+ ref.fieldIndex === fieldIndex)
212
+ : -1;
213
+ const isFieldSelected = isActionable && actionableIdx === selectedIndex;
214
+ return (_jsxs(Box, { children: [isActionable ? (_jsxs(Text, { color: isFieldSelected ? colors.primary : colors.textDim, children: [isFieldSelected ? figures.pointer : " ", " "] })) : null, _jsxs(Text, { color: isFieldSelected ? colors.primary : colors.textDim, bold: isFieldSelected, children: [field.label, field.label ? " " : ""] }), typeof field.value === "string" ? (_jsx(Text, { color: isFieldSelected
215
+ ? colors.primary
216
+ : field.color || undefined, dimColor: !isFieldSelected && !field.color, bold: isFieldSelected, children: field.value })) : (field.value), isFieldSelected && field.action?.hint && (_jsxs(Text, { color: colors.textDim, dimColor: true, children: [" ", "[Enter: ", field.action.hint, "]"] }))] }, fieldIndex));
217
+ }) })] }, sectionIndex))), additionalContent, operations.length > 0 && (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsxs(Text, { color: colors.primary, bold: true, children: [figures.play, " Actions"] }), _jsx(Box, { flexDirection: "column", paddingLeft: 2, children: operations.map((op, index) => {
218
+ const isSelected = index + actionableFields.length === selectedIndex;
219
219
  return (_jsxs(Box, { children: [_jsxs(Text, { color: isSelected ? colors.primary : colors.textDim, children: [isSelected ? figures.pointer : " ", " "] }), _jsxs(Text, { color: isSelected ? op.color : colors.textDim, bold: isSelected, children: [op.icon, " ", op.label] }), _jsxs(Text, { color: colors.textDim, dimColor: true, children: [" ", "[", op.shortcut, "]"] })] }, op.key));
220
220
  }) })] })), copyStatus && (_jsx(Box, { marginTop: 1, paddingX: 1, children: _jsx(Text, { color: colors.success, bold: true, children: copyStatus }) })), _jsx(NavigationTips, { showArrows: true, tips: [
221
- { key: "Enter", label: "Execute" },
221
+ { key: "Enter", label: isOnLink ? "Open Link" : "Execute" },
222
222
  { key: "c", label: "Copy ID" },
223
223
  { key: "i", label: "Full Details", condition: !!buildDetailLines },
224
224
  { key: "o", label: "Browser", condition: !!getUrl },
225
225
  { key: "q/Ctrl+C", label: "Back/Quit" },
226
226
  ] })] }));
227
227
  }
228
- // Helper to format timestamp as "time (ago)"
229
- export function formatTimestamp(timestamp) {
230
- if (!timestamp)
231
- return undefined;
232
- const formatted = new Date(timestamp).toLocaleString();
233
- const ago = formatTimeAgo(timestamp);
234
- return `${formatted} (${ago})`;
235
- }
236
- // Helper to format create time with arrow to end time
237
- export function formatTimeRange(createTime, endTime) {
238
- if (!createTime)
239
- return undefined;
240
- const start = new Date(createTime).toLocaleString();
241
- if (endTime) {
242
- const end = new Date(endTime).toLocaleString();
243
- return `${start} → ${end}`;
244
- }
245
- return `${start} (${formatTimeAgo(createTime)})`;
246
- }
228
+ // Re-export format helpers from utils/time for backward compatibility
229
+ export { formatTimestamp, formatTimeRange } from "../utils/time.js";
@@ -11,39 +11,9 @@ import { Table } from "./Table.js";
11
11
  import { colors } from "../utils/theme.js";
12
12
  import { useViewportHeight } from "../hooks/useViewportHeight.js";
13
13
  import { useExitOnCtrlC } from "../hooks/useExitOnCtrlC.js";
14
- // Format time ago - concise with ISO-style date for older items
15
- export const formatTimeAgo = (timestamp) => {
16
- const seconds = Math.floor((Date.now() - timestamp) / 1000);
17
- const date = new Date(timestamp);
18
- const now = new Date();
19
- // Format time as HH:MM:SS (24h)
20
- const time = date.toTimeString().slice(0, 8);
21
- // Less than 1 minute
22
- if (seconds < 60)
23
- return `${time} (${seconds}s ago)`;
24
- const minutes = Math.floor(seconds / 60);
25
- // Less than 1 hour
26
- if (minutes < 60)
27
- return `${time} (${minutes}m ago)`;
28
- const hours = Math.floor(minutes / 60);
29
- // Less than 24 hours - show time + relative
30
- if (hours < 24)
31
- return `${time} (${hours}hr ago)`;
32
- const days = Math.floor(hours / 24);
33
- const sameYear = date.getFullYear() === now.getFullYear();
34
- // Format date parts
35
- const month = String(date.getMonth() + 1).padStart(2, "0");
36
- const day = String(date.getDate()).padStart(2, "0");
37
- const year = date.getFullYear();
38
- // Date format: MM-DD or YYYY-MM-DD if different year
39
- const dateStr = `${month}-${day}`;
40
- // 1-7 days - show date + time + relative
41
- if (days <= 7) {
42
- return `${dateStr} ${time} (${days}d)`;
43
- }
44
- // More than 7 days - just date + time, no relative
45
- return `${dateStr} ${time}`;
46
- };
14
+ import { formatTimeAgoRich } from "../utils/time.js";
15
+ // Re-export for backwards compatibility with existing imports
16
+ export const formatTimeAgo = formatTimeAgoRich;
47
17
  export function ResourceListView({ config }) {
48
18
  const { exit: inkExit } = useApp();
49
19
  const isMounted = React.useRef(true);
@@ -0,0 +1,234 @@
1
+ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
2
+ /**
3
+ * ResourcePicker - Reusable component for selecting resources
4
+ * Supports single-select and multi-select modes with search and pagination
5
+ * Uses Table component for consistent styling with resource list views
6
+ */
7
+ import React from "react";
8
+ import { Box, Text, useInput } from "ink";
9
+ import figures from "figures";
10
+ import { Breadcrumb } from "./Breadcrumb.js";
11
+ import { NavigationTips } from "./NavigationTips.js";
12
+ import { SpinnerComponent } from "./Spinner.js";
13
+ import { ErrorMessage } from "./ErrorMessage.js";
14
+ import { SearchBar } from "./SearchBar.js";
15
+ import { Table, createTextColumn, createComponentColumn, } from "./Table.js";
16
+ export { createTextColumn, createComponentColumn };
17
+ import { colors } from "../utils/theme.js";
18
+ import { useViewportHeight } from "../hooks/useViewportHeight.js";
19
+ import { useExitOnCtrlC } from "../hooks/useExitOnCtrlC.js";
20
+ import { useCursorPagination } from "../hooks/useCursorPagination.js";
21
+ import { useListSearch } from "../hooks/useListSearch.js";
22
+ /**
23
+ * ResourcePicker component for selecting resources from a list
24
+ */
25
+ export function ResourcePicker({ config, onSelect, onCancel, initialSelected = [], }) {
26
+ const [selectedIndex, setSelectedIndex] = React.useState(0);
27
+ const [selectedIds, setSelectedIds] = React.useState(new Set(initialSelected));
28
+ // Search state
29
+ const search = useListSearch({
30
+ onSearchSubmit: () => setSelectedIndex(0),
31
+ onSearchClear: () => setSelectedIndex(0),
32
+ });
33
+ // Calculate overhead for viewport height
34
+ // Matches list pages: breadcrumb(4) + table chrome(4) + stats(2) + nav tips(2) + buffer(1) = 13
35
+ const overhead = 13 + search.getSearchOverhead();
36
+ const { viewportHeight, terminalWidth } = useViewportHeight({
37
+ overhead,
38
+ minHeight: 5,
39
+ });
40
+ const PAGE_SIZE = viewportHeight;
41
+ // Resolve columns - support both static array and function that receives terminalWidth
42
+ const resolvedColumns = React.useMemo(() => {
43
+ if (!config.columns)
44
+ return undefined;
45
+ if (typeof config.columns === "function") {
46
+ return config.columns(terminalWidth);
47
+ }
48
+ return config.columns;
49
+ }, [config.columns, terminalWidth]);
50
+ // Store fetchPage in a ref to avoid dependency issues
51
+ const fetchPageRef = React.useRef(config.fetchPage);
52
+ React.useEffect(() => {
53
+ fetchPageRef.current = config.fetchPage;
54
+ }, [config.fetchPage]);
55
+ // Fetch function for pagination hook
56
+ const fetchPage = React.useCallback(async (params) => {
57
+ return fetchPageRef.current({
58
+ limit: params.limit,
59
+ startingAt: params.startingAt,
60
+ search: search.submittedSearchQuery || undefined,
61
+ });
62
+ }, [search.submittedSearchQuery]);
63
+ // Use the shared pagination hook
64
+ const { items, loading, navigating, error, currentPage, hasMore, hasPrev, totalCount, nextPage, prevPage, } = useCursorPagination({
65
+ fetchPage,
66
+ pageSize: PAGE_SIZE,
67
+ getItemId: config.getItemId,
68
+ pollInterval: 0, // No polling for picker
69
+ pollingEnabled: false,
70
+ deps: [PAGE_SIZE, search.submittedSearchQuery],
71
+ });
72
+ // Handle Ctrl+C to exit
73
+ useExitOnCtrlC();
74
+ // Ensure selected index is within bounds
75
+ React.useEffect(() => {
76
+ if (items.length > 0 && selectedIndex >= items.length) {
77
+ setSelectedIndex(Math.max(0, items.length - 1));
78
+ }
79
+ }, [items.length, selectedIndex]);
80
+ const selectedItem = items[selectedIndex];
81
+ const minSelection = config.minSelection ?? 1;
82
+ const canConfirm = config.mode === "single"
83
+ ? selectedItem !== undefined
84
+ : selectedIds.size >= minSelection;
85
+ // Toggle selection for multi-select
86
+ const toggleSelection = React.useCallback((item) => {
87
+ const id = config.getItemId(item);
88
+ setSelectedIds((prev) => {
89
+ const next = new Set(prev);
90
+ if (next.has(id)) {
91
+ next.delete(id);
92
+ }
93
+ else {
94
+ if (config.maxSelection && next.size >= config.maxSelection) {
95
+ return prev; // Don't add if at max
96
+ }
97
+ next.add(id);
98
+ }
99
+ return next;
100
+ });
101
+ }, [config.getItemId, config.maxSelection]);
102
+ // Handle confirmation
103
+ const handleConfirm = React.useCallback(() => {
104
+ if (config.mode === "single") {
105
+ if (selectedItem) {
106
+ onSelect([selectedItem]);
107
+ }
108
+ }
109
+ else {
110
+ const selectedItems = items.filter((item) => selectedIds.has(config.getItemId(item)));
111
+ // Also include items from previous pages that were selected
112
+ // For now, we only return items from current view
113
+ // A more complete implementation would track full item objects
114
+ if (selectedItems.length >= minSelection) {
115
+ onSelect(selectedItems);
116
+ }
117
+ }
118
+ }, [config, selectedItem, selectedIds, items, minSelection, onSelect]);
119
+ // Calculate pagination info for display
120
+ const totalPages = Math.max(1, Math.ceil(totalCount / PAGE_SIZE));
121
+ useInput((input, key) => {
122
+ // Handle search mode input
123
+ if (search.searchMode) {
124
+ if (key.escape) {
125
+ search.cancelSearch();
126
+ }
127
+ return;
128
+ }
129
+ const pageItems = items.length;
130
+ // Navigation
131
+ if (key.upArrow && selectedIndex > 0) {
132
+ setSelectedIndex(selectedIndex - 1);
133
+ }
134
+ else if (key.downArrow && selectedIndex < pageItems - 1) {
135
+ setSelectedIndex(selectedIndex + 1);
136
+ }
137
+ else if ((input === "n" || key.rightArrow) &&
138
+ !loading &&
139
+ !navigating &&
140
+ hasMore) {
141
+ nextPage();
142
+ setSelectedIndex(0);
143
+ }
144
+ else if ((input === "p" || key.leftArrow) &&
145
+ !loading &&
146
+ !navigating &&
147
+ hasPrev) {
148
+ prevPage();
149
+ setSelectedIndex(0);
150
+ }
151
+ else if (input === " " && config.mode === "multi" && selectedItem) {
152
+ // Space toggles selection in multi mode
153
+ toggleSelection(selectedItem);
154
+ }
155
+ else if (key.return) {
156
+ if (config.mode === "single" && selectedItem) {
157
+ // Enter selects in single mode
158
+ onSelect([selectedItem]);
159
+ }
160
+ else if (config.mode === "multi" && canConfirm) {
161
+ // Enter confirms in multi mode
162
+ handleConfirm();
163
+ }
164
+ }
165
+ else if (input === "c" && config.onCreateNew) {
166
+ config.onCreateNew();
167
+ }
168
+ else if (input === "/") {
169
+ search.enterSearchMode();
170
+ }
171
+ else if (key.escape) {
172
+ if (search.handleEscape()) {
173
+ return;
174
+ }
175
+ onCancel();
176
+ }
177
+ else if (input === "q") {
178
+ onCancel();
179
+ }
180
+ });
181
+ // Loading state
182
+ if (loading && items.length === 0) {
183
+ return (_jsxs(_Fragment, { children: [config.breadcrumbItems && (_jsx(Breadcrumb, { items: config.breadcrumbItems })), _jsx(SpinnerComponent, { message: `Loading ${config.title.toLowerCase()}...` })] }));
184
+ }
185
+ // Error state
186
+ if (error) {
187
+ return (_jsxs(_Fragment, { children: [config.breadcrumbItems && (_jsx(Breadcrumb, { items: config.breadcrumbItems })), _jsx(ErrorMessage, { message: `Failed to load resources`, error: error }), _jsx(NavigationTips, { tips: [{ key: "Esc", label: "Cancel" }] })] }));
188
+ }
189
+ // Calculate pagination info for display
190
+ const startIndex = currentPage * PAGE_SIZE;
191
+ const endIndex = Math.min(startIndex + PAGE_SIZE, totalCount);
192
+ return (_jsxs(_Fragment, { children: [config.breadcrumbItems && _jsx(Breadcrumb, { items: config.breadcrumbItems }), _jsx(SearchBar, { searchMode: search.searchMode, searchQuery: search.searchQuery, submittedSearchQuery: search.submittedSearchQuery, resultCount: totalCount, onSearchChange: search.setSearchQuery, onSearchSubmit: search.submitSearch, placeholder: config.searchPlaceholder || "Search..." }), resolvedColumns ? (_jsx(Table, { data: items, keyExtractor: config.getItemId, selectedIndex: selectedIndex, title: `${config.title.toLowerCase()}[${totalCount}]${config.mode === "multi" ? ` (${selectedIds.size} selected)` : ""}`, columns: config.mode === "multi"
193
+ ? [
194
+ // Prepend checkbox column for multi-select mode
195
+ createComponentColumn("_selection", "", (row) => {
196
+ const isChecked = selectedIds.has(config.getItemId(row));
197
+ return (_jsxs(Text, { color: isChecked ? colors.success : colors.textDim, children: [isChecked
198
+ ? figures.checkboxOn
199
+ : figures.checkboxOff, " "] }));
200
+ }, { width: 3 }),
201
+ ...resolvedColumns,
202
+ ]
203
+ : resolvedColumns, emptyState: _jsxs(Text, { color: colors.textDim, children: [figures.info, " ", config.emptyMessage || "No items found", config.onCreateNew ? " Press [c] to create one." : ""] }) })) : (
204
+ // Fallback simple list view if no columns provided
205
+ _jsx(Box, { flexDirection: "column", borderStyle: "round", borderColor: colors.border, paddingX: 1, paddingY: 0, children: items.length === 0 ? (_jsx(Box, { paddingY: 1, children: _jsxs(Text, { color: colors.textDim, children: [figures.info, " ", config.emptyMessage || "No items found", config.onCreateNew ? " Press [c] to create one." : ""] }) })) : (items.map((item, index) => {
206
+ const isHighlighted = index === selectedIndex;
207
+ const id = config.getItemId(item);
208
+ const label = config.getItemLabel(item);
209
+ const status = config.getItemStatus?.(item);
210
+ const isChecked = selectedIds.has(id);
211
+ return (_jsxs(Box, { children: [_jsx(Text, { color: isHighlighted ? colors.primary : colors.textDim, children: isHighlighted ? figures.pointer : " " }), _jsx(Text, { children: " " }), config.mode === "multi" && (_jsxs(Text, { color: isChecked ? colors.success : colors.textDim, children: [isChecked
212
+ ? figures.checkboxOn
213
+ : figures.checkboxOff, " "] })), _jsx(Text, { color: colors.text, bold: isHighlighted, inverse: isHighlighted, children: label }), status && (_jsxs(_Fragment, { children: [_jsx(Text, { children: " " }), _jsx(Text, { color: colors.textDim, dimColor: true, children: status })] }))] }, id));
214
+ })) })), _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"] }), totalPages > 1 && (_jsxs(_Fragment, { children: [_jsxs(Text, { color: colors.textDim, dimColor: true, children: [" ", "\u2022", " "] }), navigating ? (_jsxs(Text, { color: colors.warning, children: [figures.pointer, " Loading page ", currentPage + 1, "..."] })) : (_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 ", totalCount] })] }), _jsx(NavigationTips, { showArrows: true, tips: [
215
+ {
216
+ icon: `${figures.arrowLeft}${figures.arrowRight}`,
217
+ label: "Page",
218
+ condition: hasMore || hasPrev,
219
+ },
220
+ ...(config.mode === "multi"
221
+ ? [{ key: "Space", label: "Toggle" }]
222
+ : []),
223
+ {
224
+ key: "Enter",
225
+ label: config.mode === "single" ? "Select" : "Confirm",
226
+ condition: canConfirm,
227
+ },
228
+ ...(config.onCreateNew
229
+ ? [{ key: "c", label: config.createNewLabel || "Create new" }]
230
+ : []),
231
+ { key: "/", label: "Search" },
232
+ { key: "Esc", label: "Cancel" },
233
+ ] })] }));
234
+ }