@runloop/rl-cli 0.0.3 → 0.1.1

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 (73) hide show
  1. package/README.md +64 -29
  2. package/dist/cli.js +401 -92
  3. package/dist/commands/auth.js +12 -11
  4. package/dist/commands/blueprint/create.js +108 -0
  5. package/dist/commands/blueprint/get.js +37 -0
  6. package/dist/commands/blueprint/list.js +293 -225
  7. package/dist/commands/blueprint/logs.js +40 -0
  8. package/dist/commands/blueprint/preview.js +45 -0
  9. package/dist/commands/devbox/create.js +10 -9
  10. package/dist/commands/devbox/delete.js +8 -8
  11. package/dist/commands/devbox/download.js +49 -0
  12. package/dist/commands/devbox/exec.js +23 -13
  13. package/dist/commands/devbox/execAsync.js +43 -0
  14. package/dist/commands/devbox/get.js +37 -0
  15. package/dist/commands/devbox/getAsync.js +37 -0
  16. package/dist/commands/devbox/list.js +328 -190
  17. package/dist/commands/devbox/logs.js +40 -0
  18. package/dist/commands/devbox/read.js +49 -0
  19. package/dist/commands/devbox/resume.js +37 -0
  20. package/dist/commands/devbox/rsync.js +118 -0
  21. package/dist/commands/devbox/scp.js +122 -0
  22. package/dist/commands/devbox/shutdown.js +37 -0
  23. package/dist/commands/devbox/ssh.js +104 -0
  24. package/dist/commands/devbox/suspend.js +37 -0
  25. package/dist/commands/devbox/tunnel.js +120 -0
  26. package/dist/commands/devbox/upload.js +10 -10
  27. package/dist/commands/devbox/write.js +51 -0
  28. package/dist/commands/mcp-http.js +37 -0
  29. package/dist/commands/mcp-install.js +120 -0
  30. package/dist/commands/mcp.js +30 -0
  31. package/dist/commands/menu.js +20 -20
  32. package/dist/commands/object/delete.js +37 -0
  33. package/dist/commands/object/download.js +88 -0
  34. package/dist/commands/object/get.js +37 -0
  35. package/dist/commands/object/list.js +112 -0
  36. package/dist/commands/object/upload.js +130 -0
  37. package/dist/commands/snapshot/create.js +12 -11
  38. package/dist/commands/snapshot/delete.js +8 -8
  39. package/dist/commands/snapshot/list.js +56 -97
  40. package/dist/commands/snapshot/status.js +37 -0
  41. package/dist/components/ActionsPopup.js +16 -13
  42. package/dist/components/Banner.js +4 -4
  43. package/dist/components/Breadcrumb.js +55 -5
  44. package/dist/components/DetailView.js +7 -4
  45. package/dist/components/DevboxActionsMenu.js +315 -178
  46. package/dist/components/DevboxCard.js +15 -14
  47. package/dist/components/DevboxCreatePage.js +147 -113
  48. package/dist/components/DevboxDetailPage.js +180 -102
  49. package/dist/components/ErrorMessage.js +5 -4
  50. package/dist/components/Header.js +4 -3
  51. package/dist/components/MainMenu.js +34 -33
  52. package/dist/components/MetadataDisplay.js +17 -9
  53. package/dist/components/OperationsMenu.js +6 -5
  54. package/dist/components/ResourceActionsMenu.js +117 -0
  55. package/dist/components/ResourceListView.js +213 -0
  56. package/dist/components/Spinner.js +5 -4
  57. package/dist/components/StatusBadge.js +81 -31
  58. package/dist/components/SuccessMessage.js +4 -3
  59. package/dist/components/Table.example.js +53 -23
  60. package/dist/components/Table.js +19 -11
  61. package/dist/hooks/useCursorPagination.js +125 -0
  62. package/dist/mcp/server-http.js +416 -0
  63. package/dist/mcp/server.js +397 -0
  64. package/dist/utils/CommandExecutor.js +16 -12
  65. package/dist/utils/client.js +7 -7
  66. package/dist/utils/config.js +130 -4
  67. package/dist/utils/interactiveCommand.js +2 -2
  68. package/dist/utils/output.js +17 -17
  69. package/dist/utils/ssh.js +160 -0
  70. package/dist/utils/sshSession.js +16 -12
  71. package/dist/utils/theme.js +22 -0
  72. package/dist/utils/url.js +4 -4
  73. package/package.json +29 -4
@@ -1,46 +1,28 @@
1
- import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
2
- import React from 'react';
3
- import { Box, Text, useInput, useApp, useStdout } from 'ink';
4
- import TextInput from 'ink-text-input';
5
- import figures from 'figures';
6
- import { getClient } from '../../utils/client.js';
7
- import { SpinnerComponent } from '../../components/Spinner.js';
8
- import { ErrorMessage } from '../../components/ErrorMessage.js';
9
- import { getStatusDisplay } from '../../components/StatusBadge.js';
10
- import { Breadcrumb } from '../../components/Breadcrumb.js';
11
- import { Table, createTextColumn } from '../../components/Table.js';
12
- import { createExecutor } from '../../utils/CommandExecutor.js';
13
- import { DevboxDetailPage } from '../../components/DevboxDetailPage.js';
14
- import { DevboxCreatePage } from '../../components/DevboxCreatePage.js';
15
- import { DevboxActionsMenu } from '../../components/DevboxActionsMenu.js';
16
- import { ActionsPopup } from '../../components/ActionsPopup.js';
17
- import { getDevboxUrl } from '../../utils/url.js';
18
- import { runSSHSession } from '../../utils/sshSession.js';
19
- // Format time ago in a succinct way
20
- const formatTimeAgo = (timestamp) => {
21
- const seconds = Math.floor((Date.now() - timestamp) / 1000);
22
- if (seconds < 60)
23
- return `${seconds}s ago`;
24
- const minutes = Math.floor(seconds / 60);
25
- if (minutes < 60)
26
- return `${minutes}m ago`;
27
- const hours = Math.floor(minutes / 60);
28
- if (hours < 24)
29
- return `${hours}h ago`;
30
- const days = Math.floor(hours / 24);
31
- if (days < 30)
32
- return `${days}d ago`;
33
- const months = Math.floor(days / 30);
34
- if (months < 12)
35
- return `${months}mo ago`;
36
- const years = Math.floor(months / 12);
37
- return `${years}y ago`;
38
- };
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ import React from "react";
3
+ import { Box, Text, useInput, useApp, useStdout } from "ink";
4
+ import TextInput from "ink-text-input";
5
+ import figures from "figures";
6
+ import { getClient } from "../../utils/client.js";
7
+ import { SpinnerComponent } from "../../components/Spinner.js";
8
+ import { ErrorMessage } from "../../components/ErrorMessage.js";
9
+ import { getStatusDisplay } from "../../components/StatusBadge.js";
10
+ import { Breadcrumb } from "../../components/Breadcrumb.js";
11
+ import { Table, createTextColumn } from "../../components/Table.js";
12
+ import { formatTimeAgo } from "../../components/ResourceListView.js";
13
+ import { createExecutor } from "../../utils/CommandExecutor.js";
14
+ import { DevboxDetailPage } from "../../components/DevboxDetailPage.js";
15
+ import { DevboxCreatePage } from "../../components/DevboxCreatePage.js";
16
+ import { ResourceActionsMenu } from "../../components/ResourceActionsMenu.js";
17
+ import { ActionsPopup } from "../../components/ActionsPopup.js";
18
+ import { getDevboxUrl } from "../../utils/url.js";
19
+ import { runSSHSession, } from "../../utils/sshSession.js";
20
+ import { colors } from "../../utils/theme.js";
39
21
  const DEFAULT_PAGE_SIZE = 10;
40
22
  const ListDevboxesUI = ({ status, onSSHRequest, focusDevboxId, onBack, onExit }) => {
41
23
  const { exit: inkExit } = useApp();
42
24
  const { stdout } = useStdout();
43
- const [loading, setLoading] = React.useState(true);
25
+ const [initialLoading, setInitialLoading] = React.useState(true);
44
26
  const [devboxes, setDevboxes] = React.useState([]);
45
27
  const [error, setError] = React.useState(null);
46
28
  const [currentPage, setCurrentPage] = React.useState(0);
@@ -52,8 +34,9 @@ const ListDevboxesUI = ({ status, onSSHRequest, focusDevboxId, onBack, onExit })
52
34
  const [selectedOperation, setSelectedOperation] = React.useState(0);
53
35
  const [refreshing, setRefreshing] = React.useState(false);
54
36
  const [refreshIcon, setRefreshIcon] = React.useState(0);
37
+ const isNavigating = React.useRef(false);
55
38
  const [searchMode, setSearchMode] = React.useState(false);
56
- const [searchQuery, setSearchQuery] = React.useState('');
39
+ const [searchQuery, setSearchQuery] = React.useState("");
57
40
  const [totalCount, setTotalCount] = React.useState(0);
58
41
  const [hasMore, setHasMore] = React.useState(false);
59
42
  const pageCache = React.useRef(new Map());
@@ -69,66 +52,155 @@ const ListDevboxesUI = ({ status, onSSHRequest, focusDevboxId, onBack, onExit })
69
52
  const statusTextWidth = 10;
70
53
  const timeWidth = 20;
71
54
  const capabilitiesWidth = 18;
72
- const tagWidth = 6;
55
+ const sourceWidth = 26;
73
56
  // ID is always full width (25 chars for dbx_31CYd5LLFbBxst8mqnUjO format)
74
57
  const idWidth = 26;
75
58
  // Responsive layout based on terminal width
76
- const showCapabilities = terminalWidth >= 120;
77
- const showTags = terminalWidth >= 110;
59
+ const showCapabilities = terminalWidth >= 140;
60
+ const showSource = terminalWidth >= 120;
78
61
  // Name width is flexible and uses remaining space
79
62
  let nameWidth = 15;
80
63
  if (terminalWidth >= 120) {
81
- const remainingWidth = terminalWidth - fixedWidth - statusIconWidth - idWidth - statusTextWidth - timeWidth - capabilitiesWidth - tagWidth - 12;
64
+ const remainingWidth = terminalWidth -
65
+ fixedWidth -
66
+ statusIconWidth -
67
+ idWidth -
68
+ statusTextWidth -
69
+ timeWidth -
70
+ capabilitiesWidth -
71
+ sourceWidth -
72
+ 12;
82
73
  nameWidth = Math.max(15, remainingWidth);
83
74
  }
84
75
  else if (terminalWidth >= 110) {
85
- const remainingWidth = terminalWidth - fixedWidth - statusIconWidth - idWidth - statusTextWidth - timeWidth - tagWidth - 10;
76
+ const remainingWidth = terminalWidth -
77
+ fixedWidth -
78
+ statusIconWidth -
79
+ idWidth -
80
+ statusTextWidth -
81
+ timeWidth -
82
+ sourceWidth -
83
+ 10;
86
84
  nameWidth = Math.max(12, remainingWidth);
87
85
  }
88
86
  else {
89
- const remainingWidth = terminalWidth - fixedWidth - statusIconWidth - idWidth - statusTextWidth - timeWidth - 10;
87
+ const remainingWidth = terminalWidth -
88
+ fixedWidth -
89
+ statusIconWidth -
90
+ idWidth -
91
+ statusTextWidth -
92
+ timeWidth -
93
+ 10;
90
94
  nameWidth = Math.max(8, remainingWidth);
91
95
  }
92
96
  // Define allOperations
93
97
  const allOperations = [
94
- { key: 'logs', label: 'View Logs', color: 'blue', icon: figures.info, shortcut: 'l' },
95
- { key: 'exec', label: 'Execute Command', color: 'green', icon: figures.play, shortcut: 'e' },
96
- { key: 'upload', label: 'Upload File', color: 'green', icon: figures.arrowUp, shortcut: 'u' },
97
- { key: 'snapshot', label: 'Create Snapshot', color: 'yellow', icon: figures.circleFilled, shortcut: 'n' },
98
- { key: 'ssh', label: 'SSH onto the box', color: 'cyan', icon: figures.arrowRight, shortcut: 's' },
99
- { key: 'tunnel', label: 'Open Tunnel', color: 'magenta', icon: figures.pointerSmall, shortcut: 't' },
100
- { key: 'suspend', label: 'Suspend Devbox', color: 'yellow', icon: figures.squareSmallFilled, shortcut: 'p' },
101
- { key: 'resume', label: 'Resume Devbox', color: 'green', icon: figures.play, shortcut: 'r' },
102
- { key: 'delete', label: 'Shutdown Devbox', color: 'red', icon: figures.cross, shortcut: 'd' },
98
+ {
99
+ key: "logs",
100
+ label: "View Logs",
101
+ color: colors.info,
102
+ icon: figures.info,
103
+ shortcut: "l",
104
+ },
105
+ {
106
+ key: "exec",
107
+ label: "Execute Command",
108
+ color: colors.success,
109
+ icon: figures.play,
110
+ shortcut: "e",
111
+ },
112
+ {
113
+ key: "upload",
114
+ label: "Upload File",
115
+ color: colors.success,
116
+ icon: figures.arrowUp,
117
+ shortcut: "u",
118
+ },
119
+ {
120
+ key: "snapshot",
121
+ label: "Create Snapshot",
122
+ color: colors.warning,
123
+ icon: figures.circleFilled,
124
+ shortcut: "n",
125
+ },
126
+ {
127
+ key: "ssh",
128
+ label: "SSH onto the box",
129
+ color: colors.primary,
130
+ icon: figures.arrowRight,
131
+ shortcut: "s",
132
+ },
133
+ {
134
+ key: "tunnel",
135
+ label: "Open Tunnel",
136
+ color: colors.secondary,
137
+ icon: figures.pointerSmall,
138
+ shortcut: "t",
139
+ },
140
+ {
141
+ key: "suspend",
142
+ label: "Suspend Devbox",
143
+ color: colors.warning,
144
+ icon: figures.squareSmallFilled,
145
+ shortcut: "p",
146
+ },
147
+ {
148
+ key: "resume",
149
+ label: "Resume Devbox",
150
+ color: colors.success,
151
+ icon: figures.play,
152
+ shortcut: "r",
153
+ },
154
+ {
155
+ key: "delete",
156
+ label: "Shutdown Devbox",
157
+ color: colors.error,
158
+ icon: figures.cross,
159
+ shortcut: "d",
160
+ },
103
161
  ];
104
162
  // Check if we need to focus on a specific devbox after returning from SSH
105
163
  React.useEffect(() => {
106
- if (focusDevboxId && devboxes.length > 0 && !loading) {
164
+ if (focusDevboxId && devboxes.length > 0 && !initialLoading) {
107
165
  // Find the devbox in the current page
108
- const devboxIndex = devboxes.findIndex(d => d.id === focusDevboxId);
166
+ const devboxIndex = devboxes.findIndex((d) => d.id === focusDevboxId);
109
167
  if (devboxIndex !== -1) {
110
168
  setSelectedIndex(devboxIndex);
111
169
  setShowDetails(true);
112
170
  }
113
171
  }
114
- }, [devboxes, loading, focusDevboxId]);
172
+ }, [devboxes, initialLoading, focusDevboxId]);
173
+ // Clear cache when search query changes
174
+ React.useEffect(() => {
175
+ pageCache.current.clear();
176
+ lastIdCache.current.clear();
177
+ setCurrentPage(0);
178
+ }, [searchQuery]);
115
179
  React.useEffect(() => {
116
- const list = async (isInitialLoad = false) => {
180
+ const list = async (isInitialLoad = false, isBackgroundRefresh = false) => {
117
181
  try {
182
+ // Set navigating flag at the start (but not for background refresh)
183
+ if (!isBackgroundRefresh) {
184
+ isNavigating.current = true;
185
+ }
118
186
  // Only show refreshing indicator on initial load
119
187
  if (isInitialLoad) {
120
188
  setRefreshing(true);
121
189
  }
122
190
  // Check if we have cached data for this page
123
- if (!isInitialLoad && pageCache.current.has(currentPage)) {
191
+ if (!isInitialLoad &&
192
+ !isBackgroundRefresh &&
193
+ pageCache.current.has(currentPage)) {
124
194
  setDevboxes(pageCache.current.get(currentPage) || []);
125
- setLoading(false);
195
+ isNavigating.current = false;
126
196
  return;
127
197
  }
128
198
  const client = getClient();
129
199
  const pageDevboxes = [];
130
200
  // Get starting_after cursor from previous page's last ID
131
- const startingAfter = currentPage > 0 ? lastIdCache.current.get(currentPage - 1) : undefined;
201
+ const startingAfter = currentPage > 0
202
+ ? lastIdCache.current.get(currentPage - 1)
203
+ : undefined;
132
204
  // Build query params
133
205
  const queryParams = {
134
206
  limit: PAGE_SIZE,
@@ -139,6 +211,9 @@ const ListDevboxesUI = ({ status, onSSHRequest, focusDevboxId, onBack, onExit })
139
211
  if (status) {
140
212
  queryParams.status = status;
141
213
  }
214
+ if (searchQuery) {
215
+ queryParams.search = searchQuery;
216
+ }
142
217
  // Fetch only the current page
143
218
  const page = await client.devboxes.list(queryParams);
144
219
  // Collect items from the page - only get PAGE_SIZE items, don't auto-paginate
@@ -172,25 +247,31 @@ const ListDevboxesUI = ({ status, onSSHRequest, focusDevboxId, onBack, onExit })
172
247
  setError(err);
173
248
  }
174
249
  finally {
175
- setLoading(false);
176
- // Show refresh indicator briefly
250
+ if (!isBackgroundRefresh) {
251
+ isNavigating.current = false;
252
+ }
253
+ // Only set initialLoading to false after first successful load
177
254
  if (isInitialLoad) {
255
+ setInitialLoading(false);
178
256
  setTimeout(() => setRefreshing(false), 300);
179
257
  }
180
258
  }
181
259
  };
182
- list(true);
183
- // Poll every 3 seconds (increased from 2), but only when in list view
260
+ // Only treat as initial load on first mount
261
+ const isFirstMount = initialLoading;
262
+ list(isFirstMount, false);
263
+ // Poll every 3 seconds (increased from 2), but only when in list view and not navigating
184
264
  const interval = setInterval(() => {
185
- if (!showDetails && !showCreate && !showActions) {
186
- // Clear cache on refresh to get latest data
187
- pageCache.current.clear();
188
- lastIdCache.current.clear();
189
- list(false);
265
+ if (!showDetails &&
266
+ !showCreate &&
267
+ !showActions &&
268
+ !isNavigating.current) {
269
+ // Don't clear cache on background refresh - just update the current page
270
+ list(false, true);
190
271
  }
191
272
  }, 3000);
192
273
  return () => clearInterval(interval);
193
- }, [showDetails, showCreate, showActions, currentPage]);
274
+ }, [showDetails, showCreate, showActions, currentPage, searchQuery]);
194
275
  // Animate refresh icon only when in list view
195
276
  React.useEffect(() => {
196
277
  if (showDetails || showCreate || showActions) {
@@ -203,8 +284,8 @@ const ListDevboxesUI = ({ status, onSSHRequest, focusDevboxId, onBack, onExit })
203
284
  }, [showDetails, showCreate, showActions]);
204
285
  useInput((input, key) => {
205
286
  // Handle Ctrl+C to force exit
206
- if (key.ctrl && input === 'c') {
207
- process.stdout.write('\x1b[?1049l'); // Exit alternate screen
287
+ if (key.ctrl && input === "c") {
288
+ process.stdout.write("\x1b[?1049l"); // Exit alternate screen
208
289
  process.exit(130);
209
290
  }
210
291
  const pageDevboxes = currentDevboxes.length;
@@ -212,7 +293,7 @@ const ListDevboxesUI = ({ status, onSSHRequest, focusDevboxId, onBack, onExit })
212
293
  if (searchMode) {
213
294
  if (key.escape) {
214
295
  setSearchMode(false);
215
- setSearchQuery('');
296
+ setSearchQuery("");
216
297
  }
217
298
  return;
218
299
  }
@@ -230,7 +311,7 @@ const ListDevboxesUI = ({ status, onSSHRequest, focusDevboxId, onBack, onExit })
230
311
  }
231
312
  // Handle popup navigation
232
313
  if (showPopup) {
233
- if (key.escape || input === 'q') {
314
+ if (key.escape || input === "q") {
234
315
  console.clear();
235
316
  setShowPopup(false);
236
317
  setSelectedOperation(0);
@@ -249,7 +330,7 @@ const ListDevboxesUI = ({ status, onSSHRequest, focusDevboxId, onBack, onExit })
249
330
  }
250
331
  else if (input) {
251
332
  // Check for shortcut match
252
- const matchedOpIndex = operations.findIndex(op => op.shortcut === input);
333
+ const matchedOpIndex = operations.findIndex((op) => op.shortcut === input);
253
334
  if (matchedOpIndex !== -1) {
254
335
  setSelectedOperation(matchedOpIndex);
255
336
  console.clear();
@@ -266,11 +347,15 @@ const ListDevboxesUI = ({ status, onSSHRequest, focusDevboxId, onBack, onExit })
266
347
  else if (key.downArrow && selectedIndex < pageDevboxes - 1) {
267
348
  setSelectedIndex(selectedIndex + 1);
268
349
  }
269
- else if ((input === 'n' || key.rightArrow) && currentPage < totalPages - 1) {
350
+ else if ((input === "n" || key.rightArrow) &&
351
+ !isNavigating.current &&
352
+ currentPage < totalPages - 1) {
270
353
  setCurrentPage(currentPage + 1);
271
354
  setSelectedIndex(0);
272
355
  }
273
- else if ((input === 'p' || key.leftArrow) && currentPage > 0) {
356
+ else if ((input === "p" || key.leftArrow) &&
357
+ !isNavigating.current &&
358
+ currentPage > 0) {
274
359
  setCurrentPage(currentPage - 1);
275
360
  setSelectedIndex(0);
276
361
  }
@@ -278,26 +363,26 @@ const ListDevboxesUI = ({ status, onSSHRequest, focusDevboxId, onBack, onExit })
278
363
  console.clear();
279
364
  setShowDetails(true);
280
365
  }
281
- else if (input === 'a') {
366
+ else if (input === "a") {
282
367
  console.clear();
283
368
  setShowPopup(true);
284
369
  setSelectedOperation(0);
285
370
  }
286
- else if (input === 'c') {
371
+ else if (input === "c") {
287
372
  console.clear();
288
373
  setShowCreate(true);
289
374
  }
290
- else if (input === 'o' && selectedDevbox) {
375
+ else if (input === "o" && selectedDevbox) {
291
376
  // Open in browser
292
377
  const url = getDevboxUrl(selectedDevbox.id);
293
378
  const openBrowser = async () => {
294
- const { exec } = await import('child_process');
379
+ const { exec } = await import("child_process");
295
380
  const platform = process.platform;
296
381
  let openCommand;
297
- if (platform === 'darwin') {
382
+ if (platform === "darwin") {
298
383
  openCommand = `open "${url}"`;
299
384
  }
300
- else if (platform === 'win32') {
385
+ else if (platform === "win32") {
301
386
  openCommand = `start "${url}"`;
302
387
  }
303
388
  else {
@@ -307,13 +392,13 @@ const ListDevboxesUI = ({ status, onSSHRequest, focusDevboxId, onBack, onExit })
307
392
  };
308
393
  openBrowser();
309
394
  }
310
- else if (input === '/') {
395
+ else if (input === "/") {
311
396
  setSearchMode(true);
312
397
  }
313
398
  else if (key.escape) {
314
399
  if (searchQuery) {
315
400
  // Clear search when Esc is pressed and there's an active search
316
- setSearchQuery('');
401
+ setSearchQuery("");
317
402
  setCurrentPage(0);
318
403
  setSelectedIndex(0);
319
404
  }
@@ -331,19 +416,8 @@ const ListDevboxesUI = ({ status, onSSHRequest, focusDevboxId, onBack, onExit })
331
416
  }
332
417
  }
333
418
  });
334
- // Filter devboxes based on search query (client-side only for current page)
335
- const filteredDevboxes = React.useMemo(() => {
336
- if (!searchQuery.trim())
337
- return devboxes;
338
- const query = searchQuery.toLowerCase();
339
- return devboxes.filter(devbox => {
340
- return (devbox.id?.toLowerCase().includes(query) ||
341
- devbox.name?.toLowerCase().includes(query) ||
342
- devbox.status?.toLowerCase().includes(query));
343
- });
344
- }, [devboxes, searchQuery]);
345
- // Current page is already fetched, no need to slice
346
- const currentDevboxes = filteredDevboxes;
419
+ // No client-side filtering - search is handled server-side
420
+ const currentDevboxes = devboxes;
347
421
  // Ensure selected index is within bounds after filtering
348
422
  React.useEffect(() => {
349
423
  if (currentDevboxes.length > 0 && selectedIndex >= currentDevboxes.length) {
@@ -356,23 +430,27 @@ const ListDevboxesUI = ({ status, onSSHRequest, focusDevboxId, onBack, onExit })
356
430
  const startIndex = currentPage * PAGE_SIZE;
357
431
  const endIndex = startIndex + currentDevboxes.length;
358
432
  // Filter operations based on devbox status
359
- const operations = selectedDevbox ? allOperations.filter(op => {
360
- const status = selectedDevbox.status;
361
- // When suspended: logs and resume
362
- if (status === 'suspended') {
363
- return op.key === 'resume' || op.key === 'logs';
364
- }
365
- // When not running (shutdown, failure, etc): only logs
366
- if (status !== 'running' && status !== 'provisioning' && status !== 'initializing') {
367
- return op.key === 'logs';
368
- }
369
- // When running: everything except resume
370
- if (status === 'running') {
371
- return op.key !== 'resume';
372
- }
373
- // Default for transitional states (provisioning, initializing)
374
- return op.key === 'logs' || op.key === 'delete';
375
- }) : allOperations;
433
+ const operations = selectedDevbox
434
+ ? allOperations.filter((op) => {
435
+ const status = selectedDevbox.status;
436
+ // When suspended: logs and resume
437
+ if (status === "suspended") {
438
+ return op.key === "resume" || op.key === "logs";
439
+ }
440
+ // When not running (shutdown, failure, etc): only logs
441
+ if (status !== "running" &&
442
+ status !== "provisioning" &&
443
+ status !== "initializing") {
444
+ return op.key === "logs";
445
+ }
446
+ // When running: everything except resume
447
+ if (status === "running") {
448
+ return op.key !== "resume";
449
+ }
450
+ // Default for transitional states (provisioning, initializing)
451
+ return op.key === "logs" || op.key === "delete";
452
+ })
453
+ : allOperations;
376
454
  // Create view
377
455
  if (showCreate) {
378
456
  return (_jsx(DevboxCreatePage, { onBack: () => {
@@ -386,12 +464,12 @@ const ListDevboxesUI = ({ status, onSSHRequest, focusDevboxId, onBack, onExit })
386
464
  // Actions view
387
465
  if (showActions && selectedDevbox) {
388
466
  const selectedOp = operations[selectedOperation];
389
- return (_jsx(DevboxActionsMenu, { devbox: selectedDevbox, onBack: () => {
467
+ return (_jsx(ResourceActionsMenu, { resourceType: "devbox", resource: selectedDevbox, onBack: () => {
390
468
  setShowActions(false);
391
469
  setSelectedOperation(0);
392
470
  }, breadcrumbItems: [
393
- { label: 'Devboxes' },
394
- { label: selectedDevbox.name || selectedDevbox.id, active: true }
471
+ { label: "Devboxes" },
472
+ { label: selectedDevbox.name || selectedDevbox.id, active: true },
395
473
  ], initialOperation: selectedOp?.key, skipOperationsMenu: true, onSSHRequest: onSSHRequest }));
396
474
  }
397
475
  // Details view
@@ -400,108 +478,166 @@ const ListDevboxesUI = ({ status, onSSHRequest, focusDevboxId, onBack, onExit })
400
478
  }
401
479
  // Show popup with table in background
402
480
  if (showPopup && selectedDevbox) {
403
- return (_jsxs(_Fragment, { children: [_jsx(Breadcrumb, { items: [
404
- { label: 'Devboxes', active: true }
405
- ] }), !loading && !error && devboxes.length > 0 && (_jsx(_Fragment, { children: _jsx(Table, { data: currentDevboxes, keyExtractor: (devbox) => devbox.id, selectedIndex: selectedIndex, title: `devboxes[${totalCount}]`, columns: [
481
+ return (_jsxs(_Fragment, { children: [_jsx(Breadcrumb, { items: [{ label: "Devboxes", active: true }], showVersionCheck: true }), !initialLoading && !error && devboxes.length > 0 && (_jsx(_Fragment, { children: _jsx(Table, { data: currentDevboxes, keyExtractor: (devbox) => devbox.id, selectedIndex: selectedIndex, title: `devboxes[${totalCount}]`, columns: [
406
482
  {
407
- key: 'statusIcon',
408
- label: '',
483
+ key: "statusIcon",
484
+ label: "",
409
485
  width: statusIconWidth,
410
486
  render: (devbox, index, isSelected) => {
411
487
  const statusDisplay = getStatusDisplay(devbox.status);
412
- // Truncate icon to fit width and pad
413
- const icon = statusDisplay.icon.slice(0, statusIconWidth);
414
- const padded = icon.padEnd(statusIconWidth, ' ');
415
- return (_jsx(Text, { color: isSelected ? 'white' : statusDisplay.color, bold: true, inverse: isSelected, children: padded }));
416
- }
488
+ return (_jsxs(Text, { color: isSelected ? "white" : statusDisplay.color, bold: true, inverse: isSelected, wrap: "truncate", children: [statusDisplay.icon, " "] }));
489
+ },
417
490
  },
418
- createTextColumn('id', 'ID', (devbox) => devbox.id, { width: idWidth, color: 'gray', dimColor: true, bold: false }),
491
+ createTextColumn("id", "ID", (devbox) => devbox.id, {
492
+ width: idWidth,
493
+ color: colors.textDim,
494
+ dimColor: false,
495
+ bold: false,
496
+ }),
419
497
  {
420
- key: 'statusText',
421
- label: 'Status',
498
+ key: "statusText",
499
+ label: "Status",
422
500
  width: statusTextWidth,
423
501
  render: (devbox, index, isSelected) => {
424
502
  const statusDisplay = getStatusDisplay(devbox.status);
425
503
  const truncated = statusDisplay.text.slice(0, statusTextWidth);
426
- const padded = truncated.padEnd(statusTextWidth, ' ');
427
- return (_jsx(Text, { color: isSelected ? 'white' : statusDisplay.color, bold: true, inverse: isSelected, children: padded }));
428
- }
504
+ const padded = truncated.padEnd(statusTextWidth, " ");
505
+ return (_jsx(Text, { color: isSelected ? "white" : statusDisplay.color, bold: true, inverse: isSelected, wrap: "truncate", children: padded }));
506
+ },
429
507
  },
430
- createTextColumn('name', 'Name', (devbox) => devbox.name || '', { width: nameWidth, dimColor: true }),
431
- createTextColumn('capabilities', 'Capabilities', (devbox) => {
432
- const hasCapabilities = devbox.capabilities && devbox.capabilities.filter((c) => c !== 'unknown').length > 0;
508
+ createTextColumn("name", "Name", (devbox) => devbox.name || "", {
509
+ width: nameWidth,
510
+ dimColor: false,
511
+ }),
512
+ createTextColumn("capabilities", "Capabilities", (devbox) => {
513
+ const hasCapabilities = devbox.capabilities &&
514
+ devbox.capabilities.filter((c) => c !== "unknown")
515
+ .length > 0;
433
516
  return hasCapabilities
434
517
  ? `[${devbox.capabilities
435
- .filter((c) => c !== 'unknown')
436
- .map((c) => c === 'computer_usage' ? 'comp' : c === 'browser_usage' ? 'browser' : c === 'docker_in_docker' ? 'docker' : c)
437
- .join(',')}]`
438
- : '';
439
- }, { width: capabilitiesWidth, color: 'blue', dimColor: true, bold: false, visible: showCapabilities }),
440
- createTextColumn('tags', 'Tags', (devbox) => devbox.blueprint_id ? '[bp]' : devbox.snapshot_id ? '[snap]' : '', { width: tagWidth, color: 'yellow', dimColor: true, bold: false, visible: showTags }),
441
- createTextColumn('created', 'Created', (devbox) => devbox.create_time_ms ? formatTimeAgo(devbox.create_time_ms) : '', { width: timeWidth, color: 'gray', dimColor: true, bold: false }),
518
+ .filter((c) => c !== "unknown")
519
+ .map((c) => c === "computer_usage"
520
+ ? "comp"
521
+ : c === "browser_usage"
522
+ ? "browser"
523
+ : c === "docker_in_docker"
524
+ ? "docker"
525
+ : c)
526
+ .join(",")}]`
527
+ : "";
528
+ }, {
529
+ width: capabilitiesWidth,
530
+ color: colors.info,
531
+ dimColor: false,
532
+ bold: false,
533
+ visible: showCapabilities,
534
+ }),
535
+ createTextColumn("source", "Source", (devbox) => devbox.blueprint_id
536
+ ? devbox.blueprint_id
537
+ : devbox.snapshot_id
538
+ ? devbox.snapshot_id
539
+ : "", {
540
+ width: sourceWidth,
541
+ color: colors.info,
542
+ dimColor: false,
543
+ bold: false,
544
+ visible: showSource,
545
+ }),
546
+ createTextColumn("created", "Created", (devbox) => devbox.create_time_ms
547
+ ? formatTimeAgo(devbox.create_time_ms)
548
+ : "", {
549
+ width: timeWidth,
550
+ color: colors.textDim,
551
+ dimColor: false,
552
+ bold: false,
553
+ }),
442
554
  ] }) })), _jsx(Box, { marginTop: -Math.min(operations.length + 10, PAGE_SIZE + 5), justifyContent: "center", children: _jsx(ActionsPopup, { devbox: selectedDevbox, operations: operations, selectedOperation: selectedOperation, onClose: () => setShowPopup(false) }) })] }));
443
555
  }
444
- // If loading or error, show that first
445
- if (loading) {
446
- return (_jsxs(_Fragment, { children: [_jsx(Breadcrumb, { items: [
447
- { label: 'Devboxes', active: true }
448
- ] }), _jsx(SpinnerComponent, { message: "Loading..." })] }));
556
+ // If initial loading or error, show that first
557
+ if (initialLoading) {
558
+ return (_jsxs(_Fragment, { children: [_jsx(Breadcrumb, { items: [{ label: "Devboxes", active: true }], showVersionCheck: true }), _jsx(SpinnerComponent, { message: "Loading..." })] }));
449
559
  }
450
560
  if (error) {
451
- return (_jsxs(_Fragment, { children: [_jsx(Breadcrumb, { items: [
452
- { label: 'Devboxes', active: true }
453
- ] }), _jsx(ErrorMessage, { message: "Failed to list devboxes", error: error })] }));
454
- }
455
- if (!loading && !error && devboxes.length === 0) {
456
- return (_jsxs(_Fragment, { children: [_jsx(Breadcrumb, { items: [
457
- { label: 'Devboxes', active: true }
458
- ] }), _jsxs(Box, { children: [_jsx(Text, { color: "yellow", children: figures.info }), _jsx(Text, { children: " No devboxes found. Try: " }), _jsx(Text, { color: "cyan", bold: true, children: "rln devbox create" })] })] }));
561
+ return (_jsxs(_Fragment, { children: [_jsx(Breadcrumb, { items: [{ label: "Devboxes", active: true }], showVersionCheck: true }), _jsx(ErrorMessage, { message: "Failed to list devboxes", error: error })] }));
459
562
  }
460
- // List view with data
461
- return (_jsxs(_Fragment, { children: [_jsx(Breadcrumb, { items: [
462
- { label: 'Devboxes', active: true }
463
- ] }), currentDevboxes && currentDevboxes.length >= 0 && (_jsxs(_Fragment, { children: [searchMode && (_jsxs(Box, { marginBottom: 1, children: [_jsxs(Text, { color: "cyan", children: [figures.pointerSmall, " Search: "] }), _jsx(TextInput, { value: searchQuery, onChange: setSearchQuery, placeholder: "Type to search (name, id, status)...", onSubmit: () => {
563
+ // List view with data (always show, even if empty)
564
+ return (_jsxs(_Fragment, { children: [_jsx(Breadcrumb, { items: [{ label: "Devboxes", active: true }] }), currentDevboxes && currentDevboxes.length >= 0 && (_jsxs(_Fragment, { children: [searchMode && (_jsxs(Box, { marginBottom: 1, children: [_jsxs(Text, { color: colors.primary, children: [figures.pointerSmall, " Search:", " "] }), _jsx(TextInput, { value: searchQuery, onChange: setSearchQuery, placeholder: "Type to search (name, id, status)...", onSubmit: () => {
464
565
  setSearchMode(false);
465
566
  setCurrentPage(0);
466
567
  setSelectedIndex(0);
467
- } }), _jsx(Text, { color: "gray", dimColor: true, children: " [Esc to cancel]" })] })), !searchMode && searchQuery && (_jsxs(Box, { marginBottom: 1, children: [_jsxs(Text, { color: "cyan", children: [figures.info, " Searching for: "] }), _jsx(Text, { color: "yellow", bold: true, children: searchQuery }), _jsxs(Text, { color: "gray", dimColor: true, children: [" (", currentDevboxes.length, " results) [/ to edit, Esc to clear]"] })] })), _jsx(Table, { data: currentDevboxes, keyExtractor: (devbox) => devbox.id, selectedIndex: selectedIndex, title: `devboxes[${searchQuery ? currentDevboxes.length : totalCount}]`, columns: [
568
+ } }), _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: [" ", "(", totalCount, " results) [/ to edit, Esc to clear]"] })] })), _jsx(Table, { data: currentDevboxes, keyExtractor: (devbox) => devbox.id, selectedIndex: selectedIndex, title: `devboxes[${totalCount}]`, columns: [
468
569
  {
469
- key: 'statusIcon',
470
- label: '',
570
+ key: "statusIcon",
571
+ label: "",
471
572
  width: statusIconWidth,
472
573
  render: (devbox, index, isSelected) => {
473
574
  const statusDisplay = getStatusDisplay(devbox.status);
474
- // Truncate icon to fit width and pad
475
- const icon = statusDisplay.icon.slice(0, statusIconWidth);
476
- const padded = icon.padEnd(statusIconWidth, ' ');
477
- return (_jsx(Text, { color: isSelected ? 'white' : statusDisplay.color, bold: true, inverse: isSelected, children: padded }));
478
- }
575
+ return (_jsxs(Text, { color: isSelected ? "white" : statusDisplay.color, bold: true, inverse: isSelected, wrap: "truncate", children: [statusDisplay.icon, " "] }));
576
+ },
479
577
  },
480
- createTextColumn('id', 'ID', (devbox) => devbox.id, { width: idWidth, color: 'gray', dimColor: true, bold: false }),
578
+ createTextColumn("id", "ID", (devbox) => devbox.id, {
579
+ width: idWidth,
580
+ color: colors.textDim,
581
+ dimColor: false,
582
+ bold: false,
583
+ }),
481
584
  {
482
- key: 'statusText',
483
- label: 'Status',
585
+ key: "statusText",
586
+ label: "Status",
484
587
  width: statusTextWidth,
485
588
  render: (devbox, index, isSelected) => {
486
589
  const statusDisplay = getStatusDisplay(devbox.status);
487
590
  const truncated = statusDisplay.text.slice(0, statusTextWidth);
488
- const padded = truncated.padEnd(statusTextWidth, ' ');
489
- return (_jsx(Text, { color: isSelected ? 'white' : statusDisplay.color, bold: true, inverse: isSelected, children: padded }));
490
- }
591
+ const padded = truncated.padEnd(statusTextWidth, " ");
592
+ return (_jsx(Text, { color: isSelected ? "white" : statusDisplay.color, bold: true, inverse: isSelected, wrap: "truncate", children: padded }));
593
+ },
491
594
  },
492
- createTextColumn('name', 'Name', (devbox) => devbox.name || '', { width: nameWidth }),
493
- createTextColumn('capabilities', 'Capabilities', (devbox) => {
494
- const hasCapabilities = devbox.capabilities && devbox.capabilities.filter((c) => c !== 'unknown').length > 0;
595
+ createTextColumn("name", "Name", (devbox) => devbox.name || "", {
596
+ width: nameWidth,
597
+ }),
598
+ createTextColumn("capabilities", "Capabilities", (devbox) => {
599
+ const hasCapabilities = devbox.capabilities &&
600
+ devbox.capabilities.filter((c) => c !== "unknown")
601
+ .length > 0;
495
602
  return hasCapabilities
496
603
  ? `[${devbox.capabilities
497
- .filter((c) => c !== 'unknown')
498
- .map((c) => c === 'computer_usage' ? 'comp' : c === 'browser_usage' ? 'browser' : c === 'docker_in_docker' ? 'docker' : c)
499
- .join(',')}]`
500
- : '';
501
- }, { width: capabilitiesWidth, color: 'blue', dimColor: true, bold: false, visible: showCapabilities }),
502
- createTextColumn('tags', 'Tags', (devbox) => devbox.blueprint_id ? '[bp]' : devbox.snapshot_id ? '[snap]' : '', { width: tagWidth, color: 'yellow', dimColor: true, bold: false, visible: showTags }),
503
- createTextColumn('created', 'Created', (devbox) => devbox.create_time_ms ? formatTimeAgo(devbox.create_time_ms) : '', { width: timeWidth, color: 'gray', dimColor: true, bold: false }),
504
- ] }, `table-${searchQuery}-${currentPage}`), _jsxs(Box, { marginTop: 1, paddingX: 1, children: [_jsxs(Text, { color: "cyan", bold: true, children: [figures.hamburger, " ", totalCount] }), _jsx(Text, { color: "gray", dimColor: true, children: " total" }), totalPages > 1 && (_jsxs(_Fragment, { children: [_jsx(Text, { color: "gray", dimColor: true, children: " \u2022 " }), _jsxs(Text, { color: "gray", dimColor: true, children: ["Page ", currentPage + 1, " of ", totalPages] })] })), _jsx(Text, { color: "gray", dimColor: true, children: " \u2022 " }), _jsxs(Text, { color: "gray", dimColor: true, children: ["Showing ", startIndex + 1, "-", endIndex, " of ", totalCount] }), hasMore && (_jsx(Text, { color: "gray", dimColor: true, children: " (more available)" })), _jsx(Text, { children: " " }), refreshing ? (_jsx(Text, { color: "cyan", children: ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'][refreshIcon % 10] })) : (_jsx(Text, { color: "green", children: figures.circleFilled }))] }), _jsxs(Box, { marginTop: 1, paddingX: 1, children: [_jsxs(Text, { color: "gray", dimColor: true, children: [figures.arrowUp, figures.arrowDown, " Navigate"] }), totalPages > 1 && (_jsxs(Text, { color: "gray", dimColor: true, children: [' ', "\u2022 ", figures.arrowLeft, figures.arrowRight, " Page"] })), _jsxs(Text, { color: "gray", dimColor: true, children: [' ', "\u2022 [Enter] Details \u2022 [a] Actions \u2022 [c] Create \u2022 [/] Search \u2022 [o] Browser \u2022 [Esc] Back"] })] })] }))] }));
604
+ .filter((c) => c !== "unknown")
605
+ .map((c) => c === "computer_usage"
606
+ ? "comp"
607
+ : c === "browser_usage"
608
+ ? "browser"
609
+ : c === "docker_in_docker"
610
+ ? "docker"
611
+ : c)
612
+ .join(",")}]`
613
+ : "";
614
+ }, {
615
+ width: capabilitiesWidth,
616
+ color: colors.info,
617
+ dimColor: false,
618
+ bold: false,
619
+ visible: showCapabilities,
620
+ }),
621
+ createTextColumn("source", "Source", (devbox) => devbox.blueprint_id
622
+ ? devbox.blueprint_id
623
+ : devbox.snapshot_id
624
+ ? devbox.snapshot_id
625
+ : "", {
626
+ width: sourceWidth,
627
+ color: colors.info,
628
+ dimColor: false,
629
+ bold: false,
630
+ visible: showSource,
631
+ }),
632
+ createTextColumn("created", "Created", (devbox) => devbox.create_time_ms
633
+ ? formatTimeAgo(devbox.create_time_ms)
634
+ : "", {
635
+ width: timeWidth,
636
+ color: colors.textDim,
637
+ dimColor: false,
638
+ bold: false,
639
+ }),
640
+ ] }, `table-${searchQuery}-${currentPage}`), _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", " "] }), _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] }), hasMore && (_jsxs(Text, { color: colors.textDim, dimColor: true, children: [" ", "(more available)"] })), _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"] })), _jsxs(Text, { color: colors.textDim, dimColor: true, children: [" ", "\u2022 [Enter] Details \u2022 [a] Actions \u2022 [c] Create \u2022 [/] Search \u2022 [o] Browser \u2022 [Esc] Back"] })] })] }))] }));
505
641
  };
506
642
  // Export the UI component for use in the main menu
507
643
  export { ListDevboxesUI };
@@ -511,7 +647,9 @@ export async function listDevboxes(options, focusDevboxId) {
511
647
  await executor.executeList(async () => {
512
648
  const client = executor.getClient();
513
649
  return executor.fetchFromIterator(client.devboxes.list(), {
514
- filter: options.status ? (devbox) => devbox.status === options.status : undefined,
650
+ filter: options.status
651
+ ? (devbox) => devbox.status === options.status
652
+ : undefined,
515
653
  limit: DEFAULT_PAGE_SIZE,
516
654
  });
517
655
  }, () => (_jsx(ListDevboxesUI, { status: options.status, focusDevboxId: focusDevboxId, onSSHRequest: (config) => {
@@ -523,7 +661,7 @@ export async function listDevboxes(options, focusDevboxId) {
523
661
  if (result.shouldRestart) {
524
662
  console.clear();
525
663
  console.log(`\nSSH session ended. Returning to CLI...\n`);
526
- await new Promise(resolve => setTimeout(resolve, 500));
664
+ await new Promise((resolve) => setTimeout(resolve, 500));
527
665
  // Restart the list view with the devbox ID to focus on
528
666
  await listDevboxes(options, result.returnToDevboxId);
529
667
  }