@runloop/rl-cli 0.0.1 → 0.0.3

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.
@@ -1,6 +1,7 @@
1
- import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
1
+ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import React from 'react';
3
3
  import { Box, Text, useInput, useApp, useStdout } from 'ink';
4
+ import TextInput from 'ink-text-input';
4
5
  import figures from 'figures';
5
6
  import { getClient } from '../../utils/client.js';
6
7
  import { SpinnerComponent } from '../../components/Spinner.js';
@@ -11,6 +12,10 @@ import { Table, createTextColumn } from '../../components/Table.js';
11
12
  import { createExecutor } from '../../utils/CommandExecutor.js';
12
13
  import { DevboxDetailPage } from '../../components/DevboxDetailPage.js';
13
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';
14
19
  // Format time ago in a succinct way
15
20
  const formatTimeAgo = (timestamp) => {
16
21
  const seconds = Math.floor((Date.now() - timestamp) / 1000);
@@ -31,10 +36,9 @@ const formatTimeAgo = (timestamp) => {
31
36
  const years = Math.floor(months / 12);
32
37
  return `${years}y ago`;
33
38
  };
34
- const MAX_FETCH = 100;
35
39
  const DEFAULT_PAGE_SIZE = 10;
36
- const ListDevboxesUI = ({ status }) => {
37
- const { exit } = useApp();
40
+ const ListDevboxesUI = ({ status, onSSHRequest, focusDevboxId, onBack, onExit }) => {
41
+ const { exit: inkExit } = useApp();
38
42
  const { stdout } = useStdout();
39
43
  const [loading, setLoading] = React.useState(true);
40
44
  const [devboxes, setDevboxes] = React.useState([]);
@@ -43,8 +47,17 @@ const ListDevboxesUI = ({ status }) => {
43
47
  const [selectedIndex, setSelectedIndex] = React.useState(0);
44
48
  const [showDetails, setShowDetails] = React.useState(false);
45
49
  const [showCreate, setShowCreate] = React.useState(false);
50
+ const [showActions, setShowActions] = React.useState(false);
51
+ const [showPopup, setShowPopup] = React.useState(false);
52
+ const [selectedOperation, setSelectedOperation] = React.useState(0);
46
53
  const [refreshing, setRefreshing] = React.useState(false);
47
54
  const [refreshIcon, setRefreshIcon] = React.useState(0);
55
+ const [searchMode, setSearchMode] = React.useState(false);
56
+ const [searchQuery, setSearchQuery] = React.useState('');
57
+ const [totalCount, setTotalCount] = React.useState(0);
58
+ const [hasMore, setHasMore] = React.useState(false);
59
+ const pageCache = React.useRef(new Map());
60
+ const lastIdCache = React.useRef(new Map());
48
61
  // Calculate responsive dimensions
49
62
  const terminalWidth = stdout?.columns || 120;
50
63
  const terminalHeight = stdout?.rows || 30;
@@ -76,6 +89,29 @@ const ListDevboxesUI = ({ status }) => {
76
89
  const remainingWidth = terminalWidth - fixedWidth - statusIconWidth - idWidth - statusTextWidth - timeWidth - 10;
77
90
  nameWidth = Math.max(8, remainingWidth);
78
91
  }
92
+ // Define allOperations
93
+ 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' },
103
+ ];
104
+ // Check if we need to focus on a specific devbox after returning from SSH
105
+ React.useEffect(() => {
106
+ if (focusDevboxId && devboxes.length > 0 && !loading) {
107
+ // Find the devbox in the current page
108
+ const devboxIndex = devboxes.findIndex(d => d.id === focusDevboxId);
109
+ if (devboxIndex !== -1) {
110
+ setSelectedIndex(devboxIndex);
111
+ setShowDetails(true);
112
+ }
113
+ }
114
+ }, [devboxes, loading, focusDevboxId]);
79
115
  React.useEffect(() => {
80
116
  const list = async (isInitialLoad = false) => {
81
117
  try {
@@ -83,22 +119,53 @@ const ListDevboxesUI = ({ status }) => {
83
119
  if (isInitialLoad) {
84
120
  setRefreshing(true);
85
121
  }
122
+ // Check if we have cached data for this page
123
+ if (!isInitialLoad && pageCache.current.has(currentPage)) {
124
+ setDevboxes(pageCache.current.get(currentPage) || []);
125
+ setLoading(false);
126
+ return;
127
+ }
86
128
  const client = getClient();
87
- const allDevboxes = [];
129
+ const pageDevboxes = [];
130
+ // Get starting_after cursor from previous page's last ID
131
+ const startingAfter = currentPage > 0 ? lastIdCache.current.get(currentPage - 1) : undefined;
132
+ // Build query params
133
+ const queryParams = {
134
+ limit: PAGE_SIZE,
135
+ };
136
+ if (startingAfter) {
137
+ queryParams.starting_after = startingAfter;
138
+ }
139
+ if (status) {
140
+ queryParams.status = status;
141
+ }
142
+ // Fetch only the current page
143
+ const page = await client.devboxes.list(queryParams);
144
+ // Collect items from the page - only get PAGE_SIZE items, don't auto-paginate
88
145
  let count = 0;
89
- for await (const devbox of client.devboxes.list()) {
90
- if (!status || devbox.status === status) {
91
- allDevboxes.push(devbox);
92
- }
146
+ for await (const devbox of page) {
147
+ pageDevboxes.push(devbox);
93
148
  count++;
94
- if (count >= MAX_FETCH) {
149
+ // Break after getting PAGE_SIZE items to prevent auto-pagination
150
+ if (count >= PAGE_SIZE) {
95
151
  break;
96
152
  }
97
153
  }
98
- // Only update if data actually changed
154
+ // Update pagination metadata from the page object
155
+ // These properties are on the page object itself
156
+ const total = page.total_count || pageDevboxes.length;
157
+ const more = page.has_more || false;
158
+ setTotalCount(total);
159
+ setHasMore(more);
160
+ // Cache the page data and last ID
161
+ if (pageDevboxes.length > 0) {
162
+ pageCache.current.set(currentPage, pageDevboxes);
163
+ lastIdCache.current.set(currentPage, pageDevboxes[pageDevboxes.length - 1].id);
164
+ }
165
+ // Update devboxes for current page
99
166
  setDevboxes((prev) => {
100
- const hasChanged = JSON.stringify(prev) !== JSON.stringify(allDevboxes);
101
- return hasChanged ? allDevboxes : prev;
167
+ const hasChanged = JSON.stringify(prev) !== JSON.stringify(pageDevboxes);
168
+ return hasChanged ? pageDevboxes : prev;
102
169
  });
103
170
  }
104
171
  catch (err) {
@@ -115,24 +182,40 @@ const ListDevboxesUI = ({ status }) => {
115
182
  list(true);
116
183
  // Poll every 3 seconds (increased from 2), but only when in list view
117
184
  const interval = setInterval(() => {
118
- if (!showDetails && !showCreate) {
185
+ if (!showDetails && !showCreate && !showActions) {
186
+ // Clear cache on refresh to get latest data
187
+ pageCache.current.clear();
188
+ lastIdCache.current.clear();
119
189
  list(false);
120
190
  }
121
191
  }, 3000);
122
192
  return () => clearInterval(interval);
123
- }, [showDetails, showCreate]);
193
+ }, [showDetails, showCreate, showActions, currentPage]);
124
194
  // Animate refresh icon only when in list view
125
195
  React.useEffect(() => {
126
- if (showDetails || showCreate) {
196
+ if (showDetails || showCreate || showActions) {
127
197
  return; // Don't animate when not in list view
128
198
  }
129
199
  const interval = setInterval(() => {
130
200
  setRefreshIcon((prev) => (prev + 1) % 10);
131
201
  }, 80);
132
202
  return () => clearInterval(interval);
133
- }, [showDetails, showCreate]);
203
+ }, [showDetails, showCreate, showActions]);
134
204
  useInput((input, key) => {
205
+ // Handle Ctrl+C to force exit
206
+ if (key.ctrl && input === 'c') {
207
+ process.stdout.write('\x1b[?1049l'); // Exit alternate screen
208
+ process.exit(130);
209
+ }
135
210
  const pageDevboxes = currentDevboxes.length;
211
+ // Skip input handling when in search mode - let TextInput handle it
212
+ if (searchMode) {
213
+ if (key.escape) {
214
+ setSearchMode(false);
215
+ setSearchQuery('');
216
+ }
217
+ return;
218
+ }
136
219
  // Skip input handling when in details view - let DevboxDetailPage handle it
137
220
  if (showDetails) {
138
221
  return;
@@ -141,6 +224,41 @@ const ListDevboxesUI = ({ status }) => {
141
224
  if (showCreate) {
142
225
  return;
143
226
  }
227
+ // Skip input handling when in actions view - let DevboxActionsMenu handle it
228
+ if (showActions) {
229
+ return;
230
+ }
231
+ // Handle popup navigation
232
+ if (showPopup) {
233
+ if (key.escape || input === 'q') {
234
+ console.clear();
235
+ setShowPopup(false);
236
+ setSelectedOperation(0);
237
+ }
238
+ else if (key.upArrow && selectedOperation > 0) {
239
+ setSelectedOperation(selectedOperation - 1);
240
+ }
241
+ else if (key.downArrow && selectedOperation < operations.length - 1) {
242
+ setSelectedOperation(selectedOperation + 1);
243
+ }
244
+ else if (key.return) {
245
+ // Execute the selected operation
246
+ console.clear();
247
+ setShowPopup(false);
248
+ setShowActions(true);
249
+ }
250
+ else if (input) {
251
+ // Check for shortcut match
252
+ const matchedOpIndex = operations.findIndex(op => op.shortcut === input);
253
+ if (matchedOpIndex !== -1) {
254
+ setSelectedOperation(matchedOpIndex);
255
+ console.clear();
256
+ setShowPopup(false);
257
+ setShowActions(true);
258
+ }
259
+ }
260
+ return;
261
+ }
144
262
  // Handle list view
145
263
  if (key.upArrow && selectedIndex > 0) {
146
264
  setSelectedIndex(selectedIndex - 1);
@@ -160,13 +278,18 @@ const ListDevboxesUI = ({ status }) => {
160
278
  console.clear();
161
279
  setShowDetails(true);
162
280
  }
281
+ else if (input === 'a') {
282
+ console.clear();
283
+ setShowPopup(true);
284
+ setSelectedOperation(0);
285
+ }
163
286
  else if (input === 'c') {
164
287
  console.clear();
165
288
  setShowCreate(true);
166
289
  }
167
290
  else if (input === 'o' && selectedDevbox) {
168
291
  // Open in browser
169
- const url = `https://platform.runloop.ai/devboxes/${selectedDevbox.id}`;
292
+ const url = getDevboxUrl(selectedDevbox.id);
170
293
  const openBrowser = async () => {
171
294
  const { exec } = await import('child_process');
172
295
  const platform = process.platform;
@@ -184,17 +307,72 @@ const ListDevboxesUI = ({ status }) => {
184
307
  };
185
308
  openBrowser();
186
309
  }
187
- else if (input === 'q') {
188
- process.exit(0);
310
+ else if (input === '/') {
311
+ setSearchMode(true);
312
+ }
313
+ else if (key.escape) {
314
+ if (searchQuery) {
315
+ // Clear search when Esc is pressed and there's an active search
316
+ setSearchQuery('');
317
+ setCurrentPage(0);
318
+ setSelectedIndex(0);
319
+ }
320
+ else {
321
+ // Go back to home
322
+ if (onBack) {
323
+ onBack();
324
+ }
325
+ else if (onExit) {
326
+ onExit();
327
+ }
328
+ else {
329
+ inkExit();
330
+ }
331
+ }
189
332
  }
190
333
  });
191
- const running = devboxes.filter((d) => d.status === 'running').length;
192
- const stopped = devboxes.filter((d) => ['stopped', 'suspended'].includes(d.status)).length;
193
- const totalPages = Math.ceil(devboxes.length / PAGE_SIZE);
194
- const startIndex = currentPage * PAGE_SIZE;
195
- const endIndex = Math.min(startIndex + PAGE_SIZE, devboxes.length);
196
- const currentDevboxes = devboxes.slice(startIndex, endIndex);
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;
347
+ // Ensure selected index is within bounds after filtering
348
+ React.useEffect(() => {
349
+ if (currentDevboxes.length > 0 && selectedIndex >= currentDevboxes.length) {
350
+ setSelectedIndex(Math.max(0, currentDevboxes.length - 1));
351
+ }
352
+ }, [currentDevboxes.length, selectedIndex]);
197
353
  const selectedDevbox = currentDevboxes[selectedIndex];
354
+ // Calculate pagination info
355
+ const totalPages = Math.ceil(totalCount / PAGE_SIZE);
356
+ const startIndex = currentPage * PAGE_SIZE;
357
+ const endIndex = startIndex + currentDevboxes.length;
358
+ // 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;
198
376
  // Create view
199
377
  if (showCreate) {
200
378
  return (_jsx(DevboxCreatePage, { onBack: () => {
@@ -205,32 +383,98 @@ const ListDevboxesUI = ({ status }) => {
205
383
  // The list will auto-refresh via the polling effect
206
384
  } }));
207
385
  }
386
+ // Actions view
387
+ if (showActions && selectedDevbox) {
388
+ const selectedOp = operations[selectedOperation];
389
+ return (_jsx(DevboxActionsMenu, { devbox: selectedDevbox, onBack: () => {
390
+ setShowActions(false);
391
+ setSelectedOperation(0);
392
+ }, breadcrumbItems: [
393
+ { label: 'Devboxes' },
394
+ { label: selectedDevbox.name || selectedDevbox.id, active: true }
395
+ ], initialOperation: selectedOp?.key, skipOperationsMenu: true, onSSHRequest: onSSHRequest }));
396
+ }
208
397
  // Details view
209
398
  if (showDetails && selectedDevbox) {
210
- return _jsx(DevboxDetailPage, { devbox: selectedDevbox, onBack: () => setShowDetails(false) });
399
+ return (_jsx(DevboxDetailPage, { devbox: selectedDevbox, onBack: () => setShowDetails(false), onSSHRequest: onSSHRequest }));
211
400
  }
212
- // List view
401
+ // Show popup with table in background
402
+ 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: [
406
+ {
407
+ key: 'statusIcon',
408
+ label: '',
409
+ width: statusIconWidth,
410
+ render: (devbox, index, isSelected) => {
411
+ 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
+ }
417
+ },
418
+ createTextColumn('id', 'ID', (devbox) => devbox.id, { width: idWidth, color: 'gray', dimColor: true, bold: false }),
419
+ {
420
+ key: 'statusText',
421
+ label: 'Status',
422
+ width: statusTextWidth,
423
+ render: (devbox, index, isSelected) => {
424
+ const statusDisplay = getStatusDisplay(devbox.status);
425
+ 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
+ }
429
+ },
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;
433
+ return hasCapabilities
434
+ ? `[${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 }),
442
+ ] }) })), _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
+ }
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..." })] }));
449
+ }
450
+ 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" })] })] }));
459
+ }
460
+ // List view with data
213
461
  return (_jsxs(_Fragment, { children: [_jsx(Breadcrumb, { items: [
214
462
  { label: 'Devboxes', active: true }
215
- ] }), 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: [_jsx(Table, { data: currentDevboxes, keyExtractor: (devbox) => devbox.id, selectedIndex: selectedIndex, title: `devboxes[${devboxes.length}]`, columns: [
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: () => {
464
+ setSearchMode(false);
465
+ setCurrentPage(0);
466
+ 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: [
216
468
  {
217
469
  key: 'statusIcon',
218
470
  label: '',
219
471
  width: statusIconWidth,
220
472
  render: (devbox, index, isSelected) => {
221
473
  const statusDisplay = getStatusDisplay(devbox.status);
222
- const status = devbox.status;
223
- let color = 'gray';
224
- if (status === 'running')
225
- color = 'green';
226
- else if (status === 'stopped' || status === 'suspended')
227
- color = 'gray';
228
- else if (status === 'starting' || status === 'stopping')
229
- color = 'yellow';
230
- else if (status === 'failed')
231
- color = 'red';
232
- const padded = statusDisplay.icon.padEnd(statusIconWidth, ' ');
233
- return (_jsx(Text, { color: isSelected ? 'white' : color, bold: true, inverse: isSelected, children: padded }));
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 }));
234
478
  }
235
479
  },
236
480
  createTextColumn('id', 'ID', (devbox) => devbox.id, { width: idWidth, color: 'gray', dimColor: true, bold: false }),
@@ -240,19 +484,9 @@ const ListDevboxesUI = ({ status }) => {
240
484
  width: statusTextWidth,
241
485
  render: (devbox, index, isSelected) => {
242
486
  const statusDisplay = getStatusDisplay(devbox.status);
243
- const status = devbox.status;
244
- let color = 'gray';
245
- if (status === 'running')
246
- color = 'green';
247
- else if (status === 'stopped' || status === 'suspended')
248
- color = 'gray';
249
- else if (status === 'starting' || status === 'stopping')
250
- color = 'yellow';
251
- else if (status === 'failed')
252
- color = 'red';
253
487
  const truncated = statusDisplay.text.slice(0, statusTextWidth);
254
488
  const padded = truncated.padEnd(statusTextWidth, ' ');
255
- return (_jsx(Text, { color: isSelected ? 'white' : color, bold: true, inverse: isSelected, children: padded }));
489
+ return (_jsx(Text, { color: isSelected ? 'white' : statusDisplay.color, bold: true, inverse: isSelected, children: padded }));
256
490
  }
257
491
  },
258
492
  createTextColumn('name', 'Name', (devbox) => devbox.name || '', { width: nameWidth }),
@@ -267,36 +501,34 @@ const ListDevboxesUI = ({ status }) => {
267
501
  }, { width: capabilitiesWidth, color: 'blue', dimColor: true, bold: false, visible: showCapabilities }),
268
502
  createTextColumn('tags', 'Tags', (devbox) => devbox.blueprint_id ? '[bp]' : devbox.snapshot_id ? '[snap]' : '', { width: tagWidth, color: 'yellow', dimColor: true, bold: false, visible: showTags }),
269
503
  createTextColumn('created', 'Created', (devbox) => devbox.create_time_ms ? formatTimeAgo(devbox.create_time_ms) : '', { width: timeWidth, color: 'gray', dimColor: true, bold: false }),
270
- ] }), _jsxs(Box, { marginTop: 1, paddingX: 1, children: [_jsxs(Text, { color: "cyan", bold: true, children: [figures.hamburger, " ", devboxes.length, devboxes.length >= MAX_FETCH && '+'] }), _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 ", devboxes.length] }), _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] Operations \u2022 [c] Create \u2022 [o] Browser \u2022 [q] Quit"] })] })] })), error && _jsx(ErrorMessage, { message: "Failed to list devboxes", error: error })] }));
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"] })] })] }))] }));
271
505
  };
272
- export async function listDevboxes(options) {
506
+ // Export the UI component for use in the main menu
507
+ export { ListDevboxesUI };
508
+ export async function listDevboxes(options, focusDevboxId) {
273
509
  const executor = createExecutor(options);
510
+ let sshSessionConfig = null;
274
511
  await executor.executeList(async () => {
275
512
  const client = executor.getClient();
276
513
  return executor.fetchFromIterator(client.devboxes.list(), {
277
514
  filter: options.status ? (devbox) => devbox.status === options.status : undefined,
278
515
  limit: DEFAULT_PAGE_SIZE,
279
516
  });
280
- }, () => _jsx(ListDevboxesUI, { status: options.status }), DEFAULT_PAGE_SIZE);
281
- // Check if we need to spawn SSH after Ink exit
282
- const sshCommand = global.__sshCommand;
283
- if (sshCommand) {
284
- delete global.__sshCommand;
285
- // Import spawn
286
- const { spawnSync } = await import('child_process');
287
- // Clear and show connection message
288
- console.clear();
289
- console.log(`\nConnecting to devbox ${sshCommand.devboxName}...\n`);
290
- // Spawn SSH in foreground
291
- const result = spawnSync('ssh', [
292
- '-i', sshCommand.keyPath,
293
- '-o', `ProxyCommand=${sshCommand.proxyCommand}`,
294
- '-o', 'StrictHostKeyChecking=no',
295
- '-o', 'UserKnownHostsFile=/dev/null',
296
- `${sshCommand.sshUser}@${sshCommand.url}`
297
- ], {
298
- stdio: 'inherit'
299
- });
300
- process.exit(result.status || 0);
517
+ }, () => (_jsx(ListDevboxesUI, { status: options.status, focusDevboxId: focusDevboxId, onSSHRequest: (config) => {
518
+ sshSessionConfig = config;
519
+ } })), DEFAULT_PAGE_SIZE);
520
+ // If SSH was requested, handle it now after Ink has exited
521
+ if (sshSessionConfig) {
522
+ const result = await runSSHSession(sshSessionConfig);
523
+ if (result.shouldRestart) {
524
+ console.clear();
525
+ console.log(`\nSSH session ended. Returning to CLI...\n`);
526
+ await new Promise(resolve => setTimeout(resolve, 500));
527
+ // Restart the list view with the devbox ID to focus on
528
+ await listDevboxes(options, result.returnToDevboxId);
529
+ }
530
+ else {
531
+ process.exit(result.exitCode);
532
+ }
301
533
  }
302
534
  }
@@ -0,0 +1,70 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import React from 'react';
3
+ import { render, useApp } from 'ink';
4
+ import { MainMenu } from '../components/MainMenu.js';
5
+ import { runSSHSession } from '../utils/sshSession.js';
6
+ // Import the UI components directly
7
+ import { ListDevboxesUI } from './devbox/list.js';
8
+ import { ListBlueprintsUI } from './blueprint/list.js';
9
+ import { ListSnapshotsUI } from './snapshot/list.js';
10
+ import { Box } from 'ink';
11
+ const App = ({ onSSHRequest, initialScreen = 'menu', focusDevboxId }) => {
12
+ const { exit } = useApp();
13
+ const [currentScreen, setCurrentScreen] = React.useState(initialScreen);
14
+ const [, forceUpdate] = React.useReducer(x => x + 1, 0);
15
+ const handleMenuSelect = (key) => {
16
+ setCurrentScreen(key);
17
+ };
18
+ const handleBack = () => {
19
+ setCurrentScreen('menu');
20
+ };
21
+ const handleExit = () => {
22
+ exit();
23
+ };
24
+ // Wrap everything in a full-height container
25
+ return (_jsxs(Box, { flexDirection: "column", minHeight: process.stdout.rows || 24, children: [currentScreen === 'menu' && _jsx(MainMenu, { onSelect: handleMenuSelect }), currentScreen === 'devboxes' && (_jsx(ListDevboxesUI, { onBack: handleBack, onExit: handleExit, onSSHRequest: onSSHRequest, focusDevboxId: focusDevboxId })), currentScreen === 'blueprints' && _jsx(ListBlueprintsUI, { onBack: handleBack, onExit: handleExit }), currentScreen === 'snapshots' && _jsx(ListSnapshotsUI, { onBack: handleBack, onExit: handleExit })] }));
26
+ };
27
+ export async function runMainMenu(initialScreen = 'menu', focusDevboxId) {
28
+ // Enter alternate screen buffer once at the start
29
+ process.stdout.write('\x1b[?1049h');
30
+ let sshSessionConfig = null;
31
+ let shouldContinue = true;
32
+ let currentInitialScreen = initialScreen;
33
+ let currentFocusDevboxId = focusDevboxId;
34
+ while (shouldContinue) {
35
+ sshSessionConfig = null;
36
+ try {
37
+ const { waitUntilExit } = render(_jsx(App, { onSSHRequest: (config) => {
38
+ sshSessionConfig = config;
39
+ }, initialScreen: currentInitialScreen, focusDevboxId: currentFocusDevboxId }));
40
+ await waitUntilExit();
41
+ shouldContinue = false;
42
+ }
43
+ catch (error) {
44
+ console.error('Error in menu:', error);
45
+ shouldContinue = false;
46
+ }
47
+ // If SSH was requested, handle it now after Ink has exited
48
+ if (sshSessionConfig) {
49
+ // Exit alternate screen buffer for SSH
50
+ process.stdout.write('\x1b[?1049l');
51
+ const result = await runSSHSession(sshSessionConfig);
52
+ if (result.shouldRestart) {
53
+ console.clear();
54
+ console.log(`\nSSH session ended. Returning to menu...\n`);
55
+ await new Promise(resolve => setTimeout(resolve, 500));
56
+ // Re-enter alternate screen buffer and return to devboxes list
57
+ process.stdout.write('\x1b[?1049h');
58
+ currentInitialScreen = 'devboxes';
59
+ currentFocusDevboxId = result.returnToDevboxId;
60
+ shouldContinue = true;
61
+ }
62
+ else {
63
+ shouldContinue = false;
64
+ }
65
+ }
66
+ }
67
+ // Exit alternate screen buffer once at the end
68
+ process.stdout.write('\x1b[?1049l');
69
+ process.exit(0);
70
+ }