@runloop/rl-cli 0.0.2 → 0.1.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 (73) hide show
  1. package/README.md +64 -29
  2. package/dist/cli.js +420 -76
  3. package/dist/commands/auth.js +12 -10
  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 +303 -224
  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 +390 -205
  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 +70 -0
  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 +59 -91
  40. package/dist/commands/snapshot/status.js +37 -0
  41. package/dist/components/ActionsPopup.js +16 -13
  42. package/dist/components/Banner.js +5 -8
  43. package/dist/components/Breadcrumb.js +6 -6
  44. package/dist/components/DetailView.js +7 -4
  45. package/dist/components/DevboxActionsMenu.js +347 -189
  46. package/dist/components/DevboxCard.js +15 -14
  47. package/dist/components/DevboxCreatePage.js +147 -113
  48. package/dist/components/DevboxDetailPage.js +182 -103
  49. package/dist/components/ErrorMessage.js +5 -4
  50. package/dist/components/Header.js +4 -3
  51. package/dist/components/MainMenu.js +72 -0
  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 +22 -6
  65. package/dist/utils/client.js +20 -3
  66. package/dist/utils/config.js +40 -4
  67. package/dist/utils/interactiveCommand.js +14 -0
  68. package/dist/utils/output.js +17 -17
  69. package/dist/utils/ssh.js +160 -0
  70. package/dist/utils/sshSession.js +29 -0
  71. package/dist/utils/theme.js +22 -0
  72. package/dist/utils/url.js +39 -0
  73. package/package.json +29 -4
@@ -1,44 +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
- // Format time ago in a succinct way
18
- const formatTimeAgo = (timestamp) => {
19
- const seconds = Math.floor((Date.now() - timestamp) / 1000);
20
- if (seconds < 60)
21
- return `${seconds}s ago`;
22
- const minutes = Math.floor(seconds / 60);
23
- if (minutes < 60)
24
- return `${minutes}m ago`;
25
- const hours = Math.floor(minutes / 60);
26
- if (hours < 24)
27
- return `${hours}h ago`;
28
- const days = Math.floor(hours / 24);
29
- if (days < 30)
30
- return `${days}d ago`;
31
- const months = Math.floor(days / 30);
32
- if (months < 12)
33
- return `${months}mo ago`;
34
- const years = Math.floor(months / 12);
35
- return `${years}y ago`;
36
- };
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";
37
21
  const DEFAULT_PAGE_SIZE = 10;
38
- const ListDevboxesUI = ({ status }) => {
39
- const { exit } = useApp();
22
+ const ListDevboxesUI = ({ status, onSSHRequest, focusDevboxId, onBack, onExit }) => {
23
+ const { exit: inkExit } = useApp();
40
24
  const { stdout } = useStdout();
41
- const [loading, setLoading] = React.useState(true);
25
+ const [initialLoading, setInitialLoading] = React.useState(true);
42
26
  const [devboxes, setDevboxes] = React.useState([]);
43
27
  const [error, setError] = React.useState(null);
44
28
  const [currentPage, setCurrentPage] = React.useState(0);
@@ -50,8 +34,9 @@ const ListDevboxesUI = ({ status }) => {
50
34
  const [selectedOperation, setSelectedOperation] = React.useState(0);
51
35
  const [refreshing, setRefreshing] = React.useState(false);
52
36
  const [refreshIcon, setRefreshIcon] = React.useState(0);
37
+ const isNavigating = React.useRef(false);
53
38
  const [searchMode, setSearchMode] = React.useState(false);
54
- const [searchQuery, setSearchQuery] = React.useState('');
39
+ const [searchQuery, setSearchQuery] = React.useState("");
55
40
  const [totalCount, setTotalCount] = React.useState(0);
56
41
  const [hasMore, setHasMore] = React.useState(false);
57
42
  const pageCache = React.useRef(new Map());
@@ -67,55 +52,155 @@ const ListDevboxesUI = ({ status }) => {
67
52
  const statusTextWidth = 10;
68
53
  const timeWidth = 20;
69
54
  const capabilitiesWidth = 18;
70
- const tagWidth = 6;
55
+ const sourceWidth = 26;
71
56
  // ID is always full width (25 chars for dbx_31CYd5LLFbBxst8mqnUjO format)
72
57
  const idWidth = 26;
73
58
  // Responsive layout based on terminal width
74
- const showCapabilities = terminalWidth >= 120;
75
- const showTags = terminalWidth >= 110;
59
+ const showCapabilities = terminalWidth >= 140;
60
+ const showSource = terminalWidth >= 120;
76
61
  // Name width is flexible and uses remaining space
77
62
  let nameWidth = 15;
78
63
  if (terminalWidth >= 120) {
79
- 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;
80
73
  nameWidth = Math.max(15, remainingWidth);
81
74
  }
82
75
  else if (terminalWidth >= 110) {
83
- 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;
84
84
  nameWidth = Math.max(12, remainingWidth);
85
85
  }
86
86
  else {
87
- 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;
88
94
  nameWidth = Math.max(8, remainingWidth);
89
95
  }
90
96
  // Define allOperations
91
97
  const allOperations = [
92
- { key: 'logs', label: 'View Logs', color: 'blue', icon: figures.info, shortcut: 'l' },
93
- { key: 'exec', label: 'Execute Command', color: 'green', icon: figures.play, shortcut: 'e' },
94
- { key: 'upload', label: 'Upload File', color: 'green', icon: figures.arrowUp, shortcut: 'u' },
95
- { key: 'snapshot', label: 'Create Snapshot', color: 'yellow', icon: figures.circleFilled, shortcut: 'n' },
96
- { key: 'ssh', label: 'SSH onto the box', color: 'cyan', icon: figures.arrowRight, shortcut: 's' },
97
- { key: 'tunnel', label: 'Open Tunnel', color: 'magenta', icon: figures.pointerSmall, shortcut: 't' },
98
- { key: 'suspend', label: 'Suspend Devbox', color: 'yellow', icon: figures.squareSmallFilled, shortcut: 'p' },
99
- { key: 'resume', label: 'Resume Devbox', color: 'green', icon: figures.play, shortcut: 'r' },
100
- { 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
+ },
101
161
  ];
162
+ // Check if we need to focus on a specific devbox after returning from SSH
163
+ React.useEffect(() => {
164
+ if (focusDevboxId && devboxes.length > 0 && !initialLoading) {
165
+ // Find the devbox in the current page
166
+ const devboxIndex = devboxes.findIndex((d) => d.id === focusDevboxId);
167
+ if (devboxIndex !== -1) {
168
+ setSelectedIndex(devboxIndex);
169
+ setShowDetails(true);
170
+ }
171
+ }
172
+ }, [devboxes, initialLoading, focusDevboxId]);
173
+ // Clear cache when search query changes
102
174
  React.useEffect(() => {
103
- const list = async (isInitialLoad = false) => {
175
+ pageCache.current.clear();
176
+ lastIdCache.current.clear();
177
+ setCurrentPage(0);
178
+ }, [searchQuery]);
179
+ React.useEffect(() => {
180
+ const list = async (isInitialLoad = false, isBackgroundRefresh = false) => {
104
181
  try {
182
+ // Set navigating flag at the start (but not for background refresh)
183
+ if (!isBackgroundRefresh) {
184
+ isNavigating.current = true;
185
+ }
105
186
  // Only show refreshing indicator on initial load
106
187
  if (isInitialLoad) {
107
188
  setRefreshing(true);
108
189
  }
109
190
  // Check if we have cached data for this page
110
- if (!isInitialLoad && pageCache.current.has(currentPage)) {
191
+ if (!isInitialLoad &&
192
+ !isBackgroundRefresh &&
193
+ pageCache.current.has(currentPage)) {
111
194
  setDevboxes(pageCache.current.get(currentPage) || []);
112
- setLoading(false);
195
+ isNavigating.current = false;
113
196
  return;
114
197
  }
115
198
  const client = getClient();
116
199
  const pageDevboxes = [];
117
200
  // Get starting_after cursor from previous page's last ID
118
- const startingAfter = currentPage > 0 ? lastIdCache.current.get(currentPage - 1) : undefined;
201
+ const startingAfter = currentPage > 0
202
+ ? lastIdCache.current.get(currentPage - 1)
203
+ : undefined;
119
204
  // Build query params
120
205
  const queryParams = {
121
206
  limit: PAGE_SIZE,
@@ -126,6 +211,9 @@ const ListDevboxesUI = ({ status }) => {
126
211
  if (status) {
127
212
  queryParams.status = status;
128
213
  }
214
+ if (searchQuery) {
215
+ queryParams.search = searchQuery;
216
+ }
129
217
  // Fetch only the current page
130
218
  const page = await client.devboxes.list(queryParams);
131
219
  // Collect items from the page - only get PAGE_SIZE items, don't auto-paginate
@@ -159,25 +247,31 @@ const ListDevboxesUI = ({ status }) => {
159
247
  setError(err);
160
248
  }
161
249
  finally {
162
- setLoading(false);
163
- // Show refresh indicator briefly
250
+ if (!isBackgroundRefresh) {
251
+ isNavigating.current = false;
252
+ }
253
+ // Only set initialLoading to false after first successful load
164
254
  if (isInitialLoad) {
255
+ setInitialLoading(false);
165
256
  setTimeout(() => setRefreshing(false), 300);
166
257
  }
167
258
  }
168
259
  };
169
- list(true);
170
- // 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
171
264
  const interval = setInterval(() => {
172
- if (!showDetails && !showCreate && !showActions) {
173
- // Clear cache on refresh to get latest data
174
- pageCache.current.clear();
175
- lastIdCache.current.clear();
176
- 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);
177
271
  }
178
272
  }, 3000);
179
273
  return () => clearInterval(interval);
180
- }, [showDetails, showCreate, showActions, currentPage]);
274
+ }, [showDetails, showCreate, showActions, currentPage, searchQuery]);
181
275
  // Animate refresh icon only when in list view
182
276
  React.useEffect(() => {
183
277
  if (showDetails || showCreate || showActions) {
@@ -189,12 +283,17 @@ const ListDevboxesUI = ({ status }) => {
189
283
  return () => clearInterval(interval);
190
284
  }, [showDetails, showCreate, showActions]);
191
285
  useInput((input, key) => {
286
+ // Handle Ctrl+C to force exit
287
+ if (key.ctrl && input === "c") {
288
+ process.stdout.write("\x1b[?1049l"); // Exit alternate screen
289
+ process.exit(130);
290
+ }
192
291
  const pageDevboxes = currentDevboxes.length;
193
292
  // Skip input handling when in search mode - let TextInput handle it
194
293
  if (searchMode) {
195
294
  if (key.escape) {
196
295
  setSearchMode(false);
197
- setSearchQuery('');
296
+ setSearchQuery("");
198
297
  }
199
298
  return;
200
299
  }
@@ -212,7 +311,7 @@ const ListDevboxesUI = ({ status }) => {
212
311
  }
213
312
  // Handle popup navigation
214
313
  if (showPopup) {
215
- if (key.escape || input === 'q') {
314
+ if (key.escape || input === "q") {
216
315
  console.clear();
217
316
  setShowPopup(false);
218
317
  setSelectedOperation(0);
@@ -231,7 +330,7 @@ const ListDevboxesUI = ({ status }) => {
231
330
  }
232
331
  else if (input) {
233
332
  // Check for shortcut match
234
- const matchedOpIndex = operations.findIndex(op => op.shortcut === input);
333
+ const matchedOpIndex = operations.findIndex((op) => op.shortcut === input);
235
334
  if (matchedOpIndex !== -1) {
236
335
  setSelectedOperation(matchedOpIndex);
237
336
  console.clear();
@@ -248,11 +347,15 @@ const ListDevboxesUI = ({ status }) => {
248
347
  else if (key.downArrow && selectedIndex < pageDevboxes - 1) {
249
348
  setSelectedIndex(selectedIndex + 1);
250
349
  }
251
- else if ((input === 'n' || key.rightArrow) && currentPage < totalPages - 1) {
350
+ else if ((input === "n" || key.rightArrow) &&
351
+ !isNavigating.current &&
352
+ currentPage < totalPages - 1) {
252
353
  setCurrentPage(currentPage + 1);
253
354
  setSelectedIndex(0);
254
355
  }
255
- else if ((input === 'p' || key.leftArrow) && currentPage > 0) {
356
+ else if ((input === "p" || key.leftArrow) &&
357
+ !isNavigating.current &&
358
+ currentPage > 0) {
256
359
  setCurrentPage(currentPage - 1);
257
360
  setSelectedIndex(0);
258
361
  }
@@ -260,26 +363,26 @@ const ListDevboxesUI = ({ status }) => {
260
363
  console.clear();
261
364
  setShowDetails(true);
262
365
  }
263
- else if (input === 'a') {
366
+ else if (input === "a") {
264
367
  console.clear();
265
368
  setShowPopup(true);
266
369
  setSelectedOperation(0);
267
370
  }
268
- else if (input === 'c') {
371
+ else if (input === "c") {
269
372
  console.clear();
270
373
  setShowCreate(true);
271
374
  }
272
- else if (input === 'o' && selectedDevbox) {
375
+ else if (input === "o" && selectedDevbox) {
273
376
  // Open in browser
274
- const url = `https://platform.runloop.ai/devboxes/${selectedDevbox.id}`;
377
+ const url = getDevboxUrl(selectedDevbox.id);
275
378
  const openBrowser = async () => {
276
- const { exec } = await import('child_process');
379
+ const { exec } = await import("child_process");
277
380
  const platform = process.platform;
278
381
  let openCommand;
279
- if (platform === 'darwin') {
382
+ if (platform === "darwin") {
280
383
  openCommand = `open "${url}"`;
281
384
  }
282
- else if (platform === 'win32') {
385
+ else if (platform === "win32") {
283
386
  openCommand = `start "${url}"`;
284
387
  }
285
388
  else {
@@ -289,57 +392,65 @@ const ListDevboxesUI = ({ status }) => {
289
392
  };
290
393
  openBrowser();
291
394
  }
292
- else if (input === '/') {
395
+ else if (input === "/") {
293
396
  setSearchMode(true);
294
397
  }
295
- else if (key.escape && searchQuery) {
296
- // Clear search when Esc is pressed and there's an active search
297
- setSearchQuery('');
298
- setCurrentPage(0);
299
- setSelectedIndex(0);
300
- }
301
- else if (input === 'q') {
302
- process.exit(0);
398
+ else if (key.escape) {
399
+ if (searchQuery) {
400
+ // Clear search when Esc is pressed and there's an active search
401
+ setSearchQuery("");
402
+ setCurrentPage(0);
403
+ setSelectedIndex(0);
404
+ }
405
+ else {
406
+ // Go back to home
407
+ if (onBack) {
408
+ onBack();
409
+ }
410
+ else if (onExit) {
411
+ onExit();
412
+ }
413
+ else {
414
+ inkExit();
415
+ }
416
+ }
303
417
  }
304
418
  });
305
- // Filter devboxes based on search query (client-side only for current page)
306
- const filteredDevboxes = React.useMemo(() => {
307
- if (!searchQuery.trim())
308
- return devboxes;
309
- const query = searchQuery.toLowerCase();
310
- return devboxes.filter(devbox => {
311
- return (devbox.id?.toLowerCase().includes(query) ||
312
- devbox.name?.toLowerCase().includes(query) ||
313
- devbox.status?.toLowerCase().includes(query));
314
- });
315
- }, [devboxes, searchQuery]);
316
- const running = filteredDevboxes.filter((d) => d.status === 'running').length;
317
- const stopped = filteredDevboxes.filter((d) => ['stopped', 'suspended'].includes(d.status)).length;
318
- // Current page is already fetched, no need to slice
319
- const currentDevboxes = filteredDevboxes;
419
+ // No client-side filtering - search is handled server-side
420
+ const currentDevboxes = devboxes;
421
+ // Ensure selected index is within bounds after filtering
422
+ React.useEffect(() => {
423
+ if (currentDevboxes.length > 0 && selectedIndex >= currentDevboxes.length) {
424
+ setSelectedIndex(Math.max(0, currentDevboxes.length - 1));
425
+ }
426
+ }, [currentDevboxes.length, selectedIndex]);
320
427
  const selectedDevbox = currentDevboxes[selectedIndex];
321
428
  // Calculate pagination info
322
429
  const totalPages = Math.ceil(totalCount / PAGE_SIZE);
323
430
  const startIndex = currentPage * PAGE_SIZE;
324
431
  const endIndex = startIndex + currentDevboxes.length;
325
432
  // Filter operations based on devbox status
326
- const operations = selectedDevbox ? allOperations.filter(op => {
327
- const status = selectedDevbox.status;
328
- // When suspended: logs and resume
329
- if (status === 'suspended') {
330
- return op.key === 'resume' || op.key === 'logs';
331
- }
332
- // When not running (shutdown, failure, etc): only logs
333
- if (status !== 'running' && status !== 'provisioning' && status !== 'initializing') {
334
- return op.key === 'logs';
335
- }
336
- // When running: everything except resume
337
- if (status === 'running') {
338
- return op.key !== 'resume';
339
- }
340
- // Default for transitional states (provisioning, initializing)
341
- return op.key === 'logs' || op.key === 'delete';
342
- }) : 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;
343
454
  // Create view
344
455
  if (showCreate) {
345
456
  return (_jsx(DevboxCreatePage, { onBack: () => {
@@ -353,135 +464,209 @@ const ListDevboxesUI = ({ status }) => {
353
464
  // Actions view
354
465
  if (showActions && selectedDevbox) {
355
466
  const selectedOp = operations[selectedOperation];
356
- return (_jsx(DevboxActionsMenu, { devbox: selectedDevbox, onBack: () => {
467
+ return (_jsx(ResourceActionsMenu, { resourceType: "devbox", resource: selectedDevbox, onBack: () => {
357
468
  setShowActions(false);
358
469
  setSelectedOperation(0);
359
470
  }, breadcrumbItems: [
360
- { label: 'Devboxes' },
361
- { label: selectedDevbox.name || selectedDevbox.id, active: true }
362
- ], initialOperation: selectedOp?.key, initialOperationIndex: selectedOperation }));
471
+ { label: "Devboxes" },
472
+ { label: selectedDevbox.name || selectedDevbox.id, active: true },
473
+ ], initialOperation: selectedOp?.key, skipOperationsMenu: true, onSSHRequest: onSSHRequest }));
363
474
  }
364
475
  // Details view
365
476
  if (showDetails && selectedDevbox) {
366
- return _jsx(DevboxDetailPage, { devbox: selectedDevbox, onBack: () => setShowDetails(false) });
477
+ return (_jsx(DevboxDetailPage, { devbox: selectedDevbox, onBack: () => setShowDetails(false), onSSHRequest: onSSHRequest }));
367
478
  }
368
479
  // Show popup with table in background
369
480
  if (showPopup && selectedDevbox) {
370
- return (_jsxs(_Fragment, { children: [_jsx(Breadcrumb, { items: [
371
- { label: 'Devboxes', active: true }
372
- ] }), !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 }] }), !initialLoading && !error && devboxes.length > 0 && (_jsx(_Fragment, { children: _jsx(Table, { data: currentDevboxes, keyExtractor: (devbox) => devbox.id, selectedIndex: selectedIndex, title: `devboxes[${totalCount}]`, columns: [
373
482
  {
374
- key: 'statusIcon',
375
- label: '',
483
+ key: "statusIcon",
484
+ label: "",
376
485
  width: statusIconWidth,
377
486
  render: (devbox, index, isSelected) => {
378
487
  const statusDisplay = getStatusDisplay(devbox.status);
379
- // Truncate icon to fit width and pad
380
- const icon = statusDisplay.icon.slice(0, statusIconWidth);
381
- const padded = icon.padEnd(statusIconWidth, ' ');
382
- return (_jsx(Text, { color: isSelected ? 'white' : statusDisplay.color, bold: true, inverse: isSelected, children: padded }));
383
- }
488
+ return (_jsxs(Text, { color: isSelected ? "white" : statusDisplay.color, bold: true, inverse: isSelected, wrap: "truncate", children: [statusDisplay.icon, " "] }));
489
+ },
384
490
  },
385
- 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
+ }),
386
497
  {
387
- key: 'statusText',
388
- label: 'Status',
498
+ key: "statusText",
499
+ label: "Status",
389
500
  width: statusTextWidth,
390
501
  render: (devbox, index, isSelected) => {
391
502
  const statusDisplay = getStatusDisplay(devbox.status);
392
503
  const truncated = statusDisplay.text.slice(0, statusTextWidth);
393
- const padded = truncated.padEnd(statusTextWidth, ' ');
394
- return (_jsx(Text, { color: isSelected ? 'white' : statusDisplay.color, bold: true, inverse: isSelected, children: padded }));
395
- }
504
+ const padded = truncated.padEnd(statusTextWidth, " ");
505
+ return (_jsx(Text, { color: isSelected ? "white" : statusDisplay.color, bold: true, inverse: isSelected, wrap: "truncate", children: padded }));
506
+ },
396
507
  },
397
- createTextColumn('name', 'Name', (devbox) => devbox.name || '', { width: nameWidth, dimColor: true }),
398
- createTextColumn('capabilities', 'Capabilities', (devbox) => {
399
- 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;
400
516
  return hasCapabilities
401
517
  ? `[${devbox.capabilities
402
- .filter((c) => c !== 'unknown')
403
- .map((c) => c === 'computer_usage' ? 'comp' : c === 'browser_usage' ? 'browser' : c === 'docker_in_docker' ? 'docker' : c)
404
- .join(',')}]`
405
- : '';
406
- }, { width: capabilitiesWidth, color: 'blue', dimColor: true, bold: false, visible: showCapabilities }),
407
- createTextColumn('tags', 'Tags', (devbox) => devbox.blueprint_id ? '[bp]' : devbox.snapshot_id ? '[snap]' : '', { width: tagWidth, color: 'yellow', dimColor: true, bold: false, visible: showTags }),
408
- 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
+ }),
409
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) }) })] }));
410
555
  }
411
- // List view
412
- return (_jsxs(_Fragment, { children: [_jsx(Breadcrumb, { items: [
413
- { label: 'Devboxes', active: true }
414
- ] }), loading && _jsx(SpinnerComponent, { message: "Loading..." }), !loading && !error && devboxes.length === 0 && (_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" })] })), !loading && !error && devboxes.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: () => {
556
+ // If initial loading or error, show that first
557
+ if (initialLoading) {
558
+ return (_jsxs(_Fragment, { children: [_jsx(Breadcrumb, { items: [{ label: "Devboxes", active: true }] }), _jsx(SpinnerComponent, { message: "Loading..." })] }));
559
+ }
560
+ if (error) {
561
+ return (_jsxs(_Fragment, { children: [_jsx(Breadcrumb, { items: [{ label: "Devboxes", active: true }] }), _jsx(ErrorMessage, { message: "Failed to list devboxes", error: error })] }));
562
+ }
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: () => {
415
565
  setSearchMode(false);
416
566
  setCurrentPage(0);
417
567
  setSelectedIndex(0);
418
- } }), _jsx(Text, { color: "gray", dimColor: true, children: " [Esc to cancel]" })] })), searchQuery && !searchMode && (_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: [" (", filteredDevboxes.length, " results) [/ to edit, Esc to clear]"] })] })), _jsx(Table, { data: currentDevboxes, keyExtractor: (devbox) => devbox.id, selectedIndex: selectedIndex, title: `devboxes[${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: [
419
569
  {
420
- key: 'statusIcon',
421
- label: '',
570
+ key: "statusIcon",
571
+ label: "",
422
572
  width: statusIconWidth,
423
573
  render: (devbox, index, isSelected) => {
424
574
  const statusDisplay = getStatusDisplay(devbox.status);
425
- // Truncate icon to fit width and pad
426
- const icon = statusDisplay.icon.slice(0, statusIconWidth);
427
- const padded = icon.padEnd(statusIconWidth, ' ');
428
- return (_jsx(Text, { color: isSelected ? 'white' : statusDisplay.color, bold: true, inverse: isSelected, children: padded }));
429
- }
575
+ return (_jsxs(Text, { color: isSelected ? "white" : statusDisplay.color, bold: true, inverse: isSelected, wrap: "truncate", children: [statusDisplay.icon, " "] }));
576
+ },
430
577
  },
431
- 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
+ }),
432
584
  {
433
- key: 'statusText',
434
- label: 'Status',
585
+ key: "statusText",
586
+ label: "Status",
435
587
  width: statusTextWidth,
436
588
  render: (devbox, index, isSelected) => {
437
589
  const statusDisplay = getStatusDisplay(devbox.status);
438
590
  const truncated = statusDisplay.text.slice(0, statusTextWidth);
439
- const padded = truncated.padEnd(statusTextWidth, ' ');
440
- return (_jsx(Text, { color: isSelected ? 'white' : statusDisplay.color, bold: true, inverse: isSelected, children: padded }));
441
- }
591
+ const padded = truncated.padEnd(statusTextWidth, " ");
592
+ return (_jsx(Text, { color: isSelected ? "white" : statusDisplay.color, bold: true, inverse: isSelected, wrap: "truncate", children: padded }));
593
+ },
442
594
  },
443
- createTextColumn('name', 'Name', (devbox) => devbox.name || '', { width: nameWidth }),
444
- createTextColumn('capabilities', 'Capabilities', (devbox) => {
445
- 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;
446
602
  return hasCapabilities
447
603
  ? `[${devbox.capabilities
448
- .filter((c) => c !== 'unknown')
449
- .map((c) => c === 'computer_usage' ? 'comp' : c === 'browser_usage' ? 'browser' : c === 'docker_in_docker' ? 'docker' : c)
450
- .join(',')}]`
451
- : '';
452
- }, { width: capabilitiesWidth, color: 'blue', dimColor: true, bold: false, visible: showCapabilities }),
453
- createTextColumn('tags', 'Tags', (devbox) => devbox.blueprint_id ? '[bp]' : devbox.snapshot_id ? '[snap]' : '', { width: tagWidth, color: 'yellow', dimColor: true, bold: false, visible: showTags }),
454
- createTextColumn('created', 'Created', (devbox) => devbox.create_time_ms ? formatTimeAgo(devbox.create_time_ms) : '', { width: timeWidth, color: 'gray', dimColor: true, bold: false }),
455
- ] }), _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 [q] Quit"] })] })] })), error && _jsx(ErrorMessage, { message: "Failed to list devboxes", error: error })] }));
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"] })] })] }))] }));
456
641
  };
457
- export async function listDevboxes(options) {
642
+ // Export the UI component for use in the main menu
643
+ export { ListDevboxesUI };
644
+ export async function listDevboxes(options, focusDevboxId) {
458
645
  const executor = createExecutor(options);
646
+ let sshSessionConfig = null;
459
647
  await executor.executeList(async () => {
460
648
  const client = executor.getClient();
461
649
  return executor.fetchFromIterator(client.devboxes.list(), {
462
- filter: options.status ? (devbox) => devbox.status === options.status : undefined,
650
+ filter: options.status
651
+ ? (devbox) => devbox.status === options.status
652
+ : undefined,
463
653
  limit: DEFAULT_PAGE_SIZE,
464
654
  });
465
- }, () => _jsx(ListDevboxesUI, { status: options.status }), DEFAULT_PAGE_SIZE);
466
- // Check if we need to spawn SSH after Ink exit
467
- const sshCommand = global.__sshCommand;
468
- if (sshCommand) {
469
- delete global.__sshCommand;
470
- // Import spawn
471
- const { spawnSync } = await import('child_process');
472
- // Clear and show connection message
473
- console.clear();
474
- console.log(`\nConnecting to devbox ${sshCommand.devboxName}...\n`);
475
- // Spawn SSH in foreground
476
- const result = spawnSync('ssh', [
477
- '-i', sshCommand.keyPath,
478
- '-o', `ProxyCommand=${sshCommand.proxyCommand}`,
479
- '-o', 'StrictHostKeyChecking=no',
480
- '-o', 'UserKnownHostsFile=/dev/null',
481
- `${sshCommand.sshUser}@${sshCommand.url}`
482
- ], {
483
- stdio: 'inherit'
484
- });
485
- process.exit(result.status || 0);
655
+ }, () => (_jsx(ListDevboxesUI, { status: options.status, focusDevboxId: focusDevboxId, onSSHRequest: (config) => {
656
+ sshSessionConfig = config;
657
+ } })), DEFAULT_PAGE_SIZE);
658
+ // If SSH was requested, handle it now after Ink has exited
659
+ if (sshSessionConfig) {
660
+ const result = await runSSHSession(sshSessionConfig);
661
+ if (result.shouldRestart) {
662
+ console.clear();
663
+ console.log(`\nSSH session ended. Returning to CLI...\n`);
664
+ await new Promise((resolve) => setTimeout(resolve, 500));
665
+ // Restart the list view with the devbox ID to focus on
666
+ await listDevboxes(options, result.returnToDevboxId);
667
+ }
668
+ else {
669
+ process.exit(result.exitCode);
670
+ }
486
671
  }
487
672
  }