@runloop/rl-cli 0.0.1 → 0.0.2

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.
@@ -2,7 +2,6 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
2
2
  import React from 'react';
3
3
  import { Box, Text } from 'ink';
4
4
  import { getClient } from '../../utils/client.js';
5
- import { Header } from '../../components/Header.js';
6
5
  import { Banner } from '../../components/Banner.js';
7
6
  import { SpinnerComponent } from '../../components/Spinner.js';
8
7
  import { SuccessMessage } from '../../components/SuccessMessage.js';
@@ -31,7 +30,7 @@ const CreateDevboxUI = ({ name, template }) => {
31
30
  };
32
31
  create();
33
32
  }, []);
34
- return (_jsxs(_Fragment, { children: [_jsx(Banner, {}), _jsx(Header, { title: "Create Devbox" }), loading && _jsx(SpinnerComponent, { message: "Creating..." }), result && (_jsxs(_Fragment, { children: [_jsx(SuccessMessage, { message: "Devbox created!", details: `ID: ${result.id}\nStatus: ${result.status}` }), _jsxs(Box, { marginTop: 1, children: [_jsx(Text, { color: "gray", children: "Try: " }), _jsxs(Text, { color: "cyan", children: ["rln devbox exec ", result.id, " ls"] })] })] })), error && _jsx(ErrorMessage, { message: "Failed to create devbox", error: error })] }));
33
+ return (_jsxs(_Fragment, { children: [_jsx(Banner, {}), loading && _jsx(SpinnerComponent, { message: "Creating..." }), result && (_jsxs(_Fragment, { children: [_jsx(SuccessMessage, { message: "Devbox created!", details: `ID: ${result.id}\nStatus: ${result.status}` }), _jsxs(Box, { marginTop: 1, children: [_jsx(Text, { color: "gray", children: "Try: " }), _jsxs(Text, { color: "cyan", children: ["rln devbox exec ", result.id, " ls"] })] })] })), error && _jsx(ErrorMessage, { message: "Failed to create devbox", error: error })] }));
35
34
  };
36
35
  export async function createDevbox(options) {
37
36
  const executor = createExecutor(options);
@@ -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,8 @@ 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';
14
17
  // Format time ago in a succinct way
15
18
  const formatTimeAgo = (timestamp) => {
16
19
  const seconds = Math.floor((Date.now() - timestamp) / 1000);
@@ -31,7 +34,6 @@ const formatTimeAgo = (timestamp) => {
31
34
  const years = Math.floor(months / 12);
32
35
  return `${years}y ago`;
33
36
  };
34
- const MAX_FETCH = 100;
35
37
  const DEFAULT_PAGE_SIZE = 10;
36
38
  const ListDevboxesUI = ({ status }) => {
37
39
  const { exit } = useApp();
@@ -43,8 +45,17 @@ const ListDevboxesUI = ({ status }) => {
43
45
  const [selectedIndex, setSelectedIndex] = React.useState(0);
44
46
  const [showDetails, setShowDetails] = React.useState(false);
45
47
  const [showCreate, setShowCreate] = React.useState(false);
48
+ const [showActions, setShowActions] = React.useState(false);
49
+ const [showPopup, setShowPopup] = React.useState(false);
50
+ const [selectedOperation, setSelectedOperation] = React.useState(0);
46
51
  const [refreshing, setRefreshing] = React.useState(false);
47
52
  const [refreshIcon, setRefreshIcon] = React.useState(0);
53
+ const [searchMode, setSearchMode] = React.useState(false);
54
+ const [searchQuery, setSearchQuery] = React.useState('');
55
+ const [totalCount, setTotalCount] = React.useState(0);
56
+ const [hasMore, setHasMore] = React.useState(false);
57
+ const pageCache = React.useRef(new Map());
58
+ const lastIdCache = React.useRef(new Map());
48
59
  // Calculate responsive dimensions
49
60
  const terminalWidth = stdout?.columns || 120;
50
61
  const terminalHeight = stdout?.rows || 30;
@@ -76,6 +87,18 @@ const ListDevboxesUI = ({ status }) => {
76
87
  const remainingWidth = terminalWidth - fixedWidth - statusIconWidth - idWidth - statusTextWidth - timeWidth - 10;
77
88
  nameWidth = Math.max(8, remainingWidth);
78
89
  }
90
+ // Define allOperations
91
+ 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' },
101
+ ];
79
102
  React.useEffect(() => {
80
103
  const list = async (isInitialLoad = false) => {
81
104
  try {
@@ -83,22 +106,53 @@ const ListDevboxesUI = ({ status }) => {
83
106
  if (isInitialLoad) {
84
107
  setRefreshing(true);
85
108
  }
109
+ // Check if we have cached data for this page
110
+ if (!isInitialLoad && pageCache.current.has(currentPage)) {
111
+ setDevboxes(pageCache.current.get(currentPage) || []);
112
+ setLoading(false);
113
+ return;
114
+ }
86
115
  const client = getClient();
87
- const allDevboxes = [];
116
+ const pageDevboxes = [];
117
+ // Get starting_after cursor from previous page's last ID
118
+ const startingAfter = currentPage > 0 ? lastIdCache.current.get(currentPage - 1) : undefined;
119
+ // Build query params
120
+ const queryParams = {
121
+ limit: PAGE_SIZE,
122
+ };
123
+ if (startingAfter) {
124
+ queryParams.starting_after = startingAfter;
125
+ }
126
+ if (status) {
127
+ queryParams.status = status;
128
+ }
129
+ // Fetch only the current page
130
+ const page = await client.devboxes.list(queryParams);
131
+ // Collect items from the page - only get PAGE_SIZE items, don't auto-paginate
88
132
  let count = 0;
89
- for await (const devbox of client.devboxes.list()) {
90
- if (!status || devbox.status === status) {
91
- allDevboxes.push(devbox);
92
- }
133
+ for await (const devbox of page) {
134
+ pageDevboxes.push(devbox);
93
135
  count++;
94
- if (count >= MAX_FETCH) {
136
+ // Break after getting PAGE_SIZE items to prevent auto-pagination
137
+ if (count >= PAGE_SIZE) {
95
138
  break;
96
139
  }
97
140
  }
98
- // Only update if data actually changed
141
+ // Update pagination metadata from the page object
142
+ // These properties are on the page object itself
143
+ const total = page.total_count || pageDevboxes.length;
144
+ const more = page.has_more || false;
145
+ setTotalCount(total);
146
+ setHasMore(more);
147
+ // Cache the page data and last ID
148
+ if (pageDevboxes.length > 0) {
149
+ pageCache.current.set(currentPage, pageDevboxes);
150
+ lastIdCache.current.set(currentPage, pageDevboxes[pageDevboxes.length - 1].id);
151
+ }
152
+ // Update devboxes for current page
99
153
  setDevboxes((prev) => {
100
- const hasChanged = JSON.stringify(prev) !== JSON.stringify(allDevboxes);
101
- return hasChanged ? allDevboxes : prev;
154
+ const hasChanged = JSON.stringify(prev) !== JSON.stringify(pageDevboxes);
155
+ return hasChanged ? pageDevboxes : prev;
102
156
  });
103
157
  }
104
158
  catch (err) {
@@ -115,24 +169,35 @@ const ListDevboxesUI = ({ status }) => {
115
169
  list(true);
116
170
  // Poll every 3 seconds (increased from 2), but only when in list view
117
171
  const interval = setInterval(() => {
118
- if (!showDetails && !showCreate) {
172
+ if (!showDetails && !showCreate && !showActions) {
173
+ // Clear cache on refresh to get latest data
174
+ pageCache.current.clear();
175
+ lastIdCache.current.clear();
119
176
  list(false);
120
177
  }
121
178
  }, 3000);
122
179
  return () => clearInterval(interval);
123
- }, [showDetails, showCreate]);
180
+ }, [showDetails, showCreate, showActions, currentPage]);
124
181
  // Animate refresh icon only when in list view
125
182
  React.useEffect(() => {
126
- if (showDetails || showCreate) {
183
+ if (showDetails || showCreate || showActions) {
127
184
  return; // Don't animate when not in list view
128
185
  }
129
186
  const interval = setInterval(() => {
130
187
  setRefreshIcon((prev) => (prev + 1) % 10);
131
188
  }, 80);
132
189
  return () => clearInterval(interval);
133
- }, [showDetails, showCreate]);
190
+ }, [showDetails, showCreate, showActions]);
134
191
  useInput((input, key) => {
135
192
  const pageDevboxes = currentDevboxes.length;
193
+ // Skip input handling when in search mode - let TextInput handle it
194
+ if (searchMode) {
195
+ if (key.escape) {
196
+ setSearchMode(false);
197
+ setSearchQuery('');
198
+ }
199
+ return;
200
+ }
136
201
  // Skip input handling when in details view - let DevboxDetailPage handle it
137
202
  if (showDetails) {
138
203
  return;
@@ -141,6 +206,41 @@ const ListDevboxesUI = ({ status }) => {
141
206
  if (showCreate) {
142
207
  return;
143
208
  }
209
+ // Skip input handling when in actions view - let DevboxActionsMenu handle it
210
+ if (showActions) {
211
+ return;
212
+ }
213
+ // Handle popup navigation
214
+ if (showPopup) {
215
+ if (key.escape || input === 'q') {
216
+ console.clear();
217
+ setShowPopup(false);
218
+ setSelectedOperation(0);
219
+ }
220
+ else if (key.upArrow && selectedOperation > 0) {
221
+ setSelectedOperation(selectedOperation - 1);
222
+ }
223
+ else if (key.downArrow && selectedOperation < operations.length - 1) {
224
+ setSelectedOperation(selectedOperation + 1);
225
+ }
226
+ else if (key.return) {
227
+ // Execute the selected operation
228
+ console.clear();
229
+ setShowPopup(false);
230
+ setShowActions(true);
231
+ }
232
+ else if (input) {
233
+ // Check for shortcut match
234
+ const matchedOpIndex = operations.findIndex(op => op.shortcut === input);
235
+ if (matchedOpIndex !== -1) {
236
+ setSelectedOperation(matchedOpIndex);
237
+ console.clear();
238
+ setShowPopup(false);
239
+ setShowActions(true);
240
+ }
241
+ }
242
+ return;
243
+ }
144
244
  // Handle list view
145
245
  if (key.upArrow && selectedIndex > 0) {
146
246
  setSelectedIndex(selectedIndex - 1);
@@ -160,6 +260,11 @@ const ListDevboxesUI = ({ status }) => {
160
260
  console.clear();
161
261
  setShowDetails(true);
162
262
  }
263
+ else if (input === 'a') {
264
+ console.clear();
265
+ setShowPopup(true);
266
+ setSelectedOperation(0);
267
+ }
163
268
  else if (input === 'c') {
164
269
  console.clear();
165
270
  setShowCreate(true);
@@ -184,17 +289,57 @@ const ListDevboxesUI = ({ status }) => {
184
289
  };
185
290
  openBrowser();
186
291
  }
292
+ else if (input === '/') {
293
+ setSearchMode(true);
294
+ }
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
+ }
187
301
  else if (input === 'q') {
188
302
  process.exit(0);
189
303
  }
190
304
  });
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);
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;
197
320
  const selectedDevbox = currentDevboxes[selectedIndex];
321
+ // Calculate pagination info
322
+ const totalPages = Math.ceil(totalCount / PAGE_SIZE);
323
+ const startIndex = currentPage * PAGE_SIZE;
324
+ const endIndex = startIndex + currentDevboxes.length;
325
+ // 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;
198
343
  // Create view
199
344
  if (showCreate) {
200
345
  return (_jsx(DevboxCreatePage, { onBack: () => {
@@ -205,32 +350,82 @@ const ListDevboxesUI = ({ status }) => {
205
350
  // The list will auto-refresh via the polling effect
206
351
  } }));
207
352
  }
353
+ // Actions view
354
+ if (showActions && selectedDevbox) {
355
+ const selectedOp = operations[selectedOperation];
356
+ return (_jsx(DevboxActionsMenu, { devbox: selectedDevbox, onBack: () => {
357
+ setShowActions(false);
358
+ setSelectedOperation(0);
359
+ }, breadcrumbItems: [
360
+ { label: 'Devboxes' },
361
+ { label: selectedDevbox.name || selectedDevbox.id, active: true }
362
+ ], initialOperation: selectedOp?.key, initialOperationIndex: selectedOperation }));
363
+ }
208
364
  // Details view
209
365
  if (showDetails && selectedDevbox) {
210
366
  return _jsx(DevboxDetailPage, { devbox: selectedDevbox, onBack: () => setShowDetails(false) });
211
367
  }
368
+ // Show popup with table in background
369
+ 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: [
373
+ {
374
+ key: 'statusIcon',
375
+ label: '',
376
+ width: statusIconWidth,
377
+ render: (devbox, index, isSelected) => {
378
+ 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
+ }
384
+ },
385
+ createTextColumn('id', 'ID', (devbox) => devbox.id, { width: idWidth, color: 'gray', dimColor: true, bold: false }),
386
+ {
387
+ key: 'statusText',
388
+ label: 'Status',
389
+ width: statusTextWidth,
390
+ render: (devbox, index, isSelected) => {
391
+ const statusDisplay = getStatusDisplay(devbox.status);
392
+ 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
+ }
396
+ },
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;
400
+ return hasCapabilities
401
+ ? `[${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 }),
409
+ ] }) })), _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
+ }
212
411
  // List view
213
412
  return (_jsxs(_Fragment, { children: [_jsx(Breadcrumb, { items: [
214
413
  { 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: [
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: () => {
415
+ setSearchMode(false);
416
+ setCurrentPage(0);
417
+ 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: [
216
419
  {
217
420
  key: 'statusIcon',
218
421
  label: '',
219
422
  width: statusIconWidth,
220
423
  render: (devbox, index, isSelected) => {
221
424
  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 }));
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 }));
234
429
  }
235
430
  },
236
431
  createTextColumn('id', 'ID', (devbox) => devbox.id, { width: idWidth, color: 'gray', dimColor: true, bold: false }),
@@ -240,19 +435,9 @@ const ListDevboxesUI = ({ status }) => {
240
435
  width: statusTextWidth,
241
436
  render: (devbox, index, isSelected) => {
242
437
  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
438
  const truncated = statusDisplay.text.slice(0, statusTextWidth);
254
439
  const padded = truncated.padEnd(statusTextWidth, ' ');
255
- return (_jsx(Text, { color: isSelected ? 'white' : color, bold: true, inverse: isSelected, children: padded }));
440
+ return (_jsx(Text, { color: isSelected ? 'white' : statusDisplay.color, bold: true, inverse: isSelected, children: padded }));
256
441
  }
257
442
  },
258
443
  createTextColumn('name', 'Name', (devbox) => devbox.name || '', { width: nameWidth }),
@@ -267,7 +452,7 @@ const ListDevboxesUI = ({ status }) => {
267
452
  }, { width: capabilitiesWidth, color: 'blue', dimColor: true, bold: false, visible: showCapabilities }),
268
453
  createTextColumn('tags', 'Tags', (devbox) => devbox.blueprint_id ? '[bp]' : devbox.snapshot_id ? '[snap]' : '', { width: tagWidth, color: 'yellow', dimColor: true, bold: false, visible: showTags }),
269
454
  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 })] }));
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 })] }));
271
456
  };
272
457
  export async function listDevboxes(options) {
273
458
  const executor = createExecutor(options);
@@ -0,0 +1,42 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Box, Text } from 'ink';
3
+ import figures from 'figures';
4
+ import chalk from 'chalk';
5
+ export const ActionsPopup = ({ devbox, operations, selectedOperation, onClose, }) => {
6
+ // Calculate the maximum width needed
7
+ const maxLabelLength = Math.max(...operations.map(op => op.label.length));
8
+ const contentWidth = maxLabelLength + 12; // Content + icon + pointer + shortcuts
9
+ // Strip ANSI codes to get real length, then pad
10
+ const stripAnsi = (str) => str.replace(/\u001b\[[0-9;]*m/g, '');
11
+ const bgLine = (content) => {
12
+ const cleanLength = stripAnsi(content).length;
13
+ const padding = Math.max(0, contentWidth - cleanLength);
14
+ return chalk.bgBlack(content + ' '.repeat(padding));
15
+ };
16
+ // Render all lines with background
17
+ const lines = [
18
+ bgLine(chalk.cyan.bold(` ${figures.play} Quick Actions`)),
19
+ chalk.bgBlack(' '.repeat(contentWidth)),
20
+ ...operations.map((op, index) => {
21
+ const isSelected = index === selectedOperation;
22
+ const pointer = isSelected ? figures.pointer : ' ';
23
+ const content = ` ${pointer} ${op.icon} ${op.label} [${op.shortcut}]`;
24
+ let styled;
25
+ if (isSelected) {
26
+ const colorFn = chalk[op.color];
27
+ styled = typeof colorFn === 'function' ? colorFn.bold(content) : chalk.white.bold(content);
28
+ }
29
+ else {
30
+ styled = chalk.gray(content);
31
+ }
32
+ return bgLine(styled);
33
+ }),
34
+ chalk.bgBlack(' '.repeat(contentWidth)),
35
+ bgLine(chalk.gray.dim(` ${figures.arrowUp}${figures.arrowDown} Nav • [Enter]`)),
36
+ bgLine(chalk.gray.dim(` [Esc] Close`)),
37
+ ];
38
+ // Draw custom border with background to fill gaps
39
+ const borderTop = chalk.cyan('╭' + '─'.repeat(contentWidth) + '╮');
40
+ const borderBottom = chalk.cyan('╰' + '─'.repeat(contentWidth) + '╯');
41
+ return (_jsx(Box, { flexDirection: "column", alignItems: "center", children: _jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { children: borderTop }), lines.map((line, i) => (_jsxs(Text, { children: [chalk.cyan('│'), line, chalk.cyan('│')] }, i))), _jsx(Text, { children: borderBottom })] }) }));
42
+ };