@runloop/rl-cli 1.5.0 → 1.7.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.
@@ -12,11 +12,13 @@ import { NavigationTips } from "../../components/NavigationTips.js";
12
12
  import { Table, createTextColumn } from "../../components/Table.js";
13
13
  import { ActionsPopup } from "../../components/ActionsPopup.js";
14
14
  import { formatTimeAgo } from "../../components/ResourceListView.js";
15
+ import { SearchBar } from "../../components/SearchBar.js";
15
16
  import { output, outputError } from "../../utils/output.js";
16
17
  import { colors } from "../../utils/theme.js";
17
18
  import { useViewportHeight } from "../../hooks/useViewportHeight.js";
18
19
  import { useExitOnCtrlC } from "../../hooks/useExitOnCtrlC.js";
19
20
  import { useCursorPagination } from "../../hooks/useCursorPagination.js";
21
+ import { useListSearch } from "../../hooks/useListSearch.js";
20
22
  import { DevboxCreatePage } from "../../components/DevboxCreatePage.js";
21
23
  import { useNavigation } from "../../store/navigationStore.js";
22
24
  import { ConfirmationPrompt } from "../../components/ConfirmationPrompt.js";
@@ -35,8 +37,13 @@ const ListSnapshotsUI = ({ devboxId, onBack, onExit, }) => {
35
37
  const [operationLoading, setOperationLoading] = React.useState(false);
36
38
  const [showCreateDevbox, setShowCreateDevbox] = React.useState(false);
37
39
  const [showDeleteConfirm, setShowDeleteConfirm] = React.useState(false);
40
+ // Search state
41
+ const search = useListSearch({
42
+ onSearchSubmit: () => setSelectedIndex(0),
43
+ onSearchClear: () => setSelectedIndex(0),
44
+ });
38
45
  // Calculate overhead for viewport height
39
- const overhead = 13;
46
+ const overhead = 13 + search.getSearchOverhead();
40
47
  const { viewportHeight, terminalWidth } = useViewportHeight({
41
48
  overhead,
42
49
  minHeight: 5,
@@ -67,6 +74,9 @@ const ListSnapshotsUI = ({ devboxId, onBack, onExit, }) => {
67
74
  if (devboxId) {
68
75
  queryParams.devbox_id = devboxId;
69
76
  }
77
+ if (search.submittedSearchQuery) {
78
+ queryParams.search = search.submittedSearchQuery;
79
+ }
70
80
  // Fetch ONE page only
71
81
  const page = (await client.devboxes.listDiskSnapshots(queryParams));
72
82
  // Extract data and create defensive copies
@@ -87,7 +97,7 @@ const ListSnapshotsUI = ({ devboxId, onBack, onExit, }) => {
87
97
  totalCount: page.total_count || pageSnapshots.length,
88
98
  };
89
99
  return result;
90
- }, [devboxId]);
100
+ }, [devboxId, search.submittedSearchQuery]);
91
101
  // Use the shared pagination hook
92
102
  const { items: snapshots, loading, navigating, error, currentPage, hasMore, hasPrev, totalCount, nextPage, prevPage, refresh, } = useCursorPagination({
93
103
  fetchPage,
@@ -97,8 +107,9 @@ const ListSnapshotsUI = ({ devboxId, onBack, onExit, }) => {
97
107
  pollingEnabled: !showPopup &&
98
108
  !executingOperation &&
99
109
  !showCreateDevbox &&
100
- !showDeleteConfirm,
101
- deps: [devboxId, PAGE_SIZE],
110
+ !showDeleteConfirm &&
111
+ !search.searchMode,
112
+ deps: [devboxId, PAGE_SIZE, search.submittedSearchQuery],
102
113
  });
103
114
  // Operations for snapshots
104
115
  const operations = React.useMemo(() => [
@@ -180,6 +191,13 @@ const ListSnapshotsUI = ({ devboxId, onBack, onExit, }) => {
180
191
  }
181
192
  };
182
193
  useInput((input, key) => {
194
+ // Handle search mode input
195
+ if (search.searchMode) {
196
+ if (key.escape) {
197
+ search.cancelSearch();
198
+ }
199
+ return;
200
+ }
183
201
  // Handle operation result display
184
202
  if (operationResult || operationError) {
185
203
  if (input === "q" || key.escape || key.return) {
@@ -289,7 +307,13 @@ const ListSnapshotsUI = ({ devboxId, onBack, onExit, }) => {
289
307
  setShowPopup(true);
290
308
  setSelectedOperation(0);
291
309
  }
310
+ else if (input === "/") {
311
+ search.enterSearchMode();
312
+ }
292
313
  else if (key.escape) {
314
+ if (search.handleEscape()) {
315
+ return;
316
+ }
293
317
  if (onBack) {
294
318
  onBack();
295
319
  }
@@ -374,7 +398,7 @@ const ListSnapshotsUI = ({ devboxId, onBack, onExit, }) => {
374
398
  return (_jsxs(_Fragment, { children: [_jsx(Breadcrumb, { items: [
375
399
  { label: "Snapshots", active: !devboxId },
376
400
  ...(devboxId ? [{ label: `Devbox: ${devboxId}`, active: true }] : []),
377
- ] }), !showPopup && (_jsx(Table, { data: snapshots, keyExtractor: (snapshot) => snapshot.id, selectedIndex: selectedIndex, title: `snapshots[${totalCount}]`, columns: columns, emptyState: _jsxs(Text, { color: colors.textDim, children: [figures.info, " No snapshots found. Try: rli snapshot create", " ", "<devbox-id>"] }) })), !showPopup && (_jsxs(Box, { marginTop: 1, paddingX: 1, children: [_jsxs(Text, { color: colors.primary, bold: true, children: [figures.hamburger, " ", totalCount] }), _jsxs(Text, { color: colors.textDim, dimColor: true, children: [" ", "total"] }), totalPages > 1 && (_jsxs(_Fragment, { children: [_jsxs(Text, { color: colors.textDim, dimColor: true, children: [" ", "\u2022", " "] }), navigating ? (_jsxs(Text, { color: colors.warning, children: [figures.pointer, " Loading page ", currentPage + 1, "..."] })) : (_jsxs(Text, { color: colors.textDim, dimColor: true, children: ["Page ", currentPage + 1, " of ", totalPages] }))] })), _jsxs(Text, { color: colors.textDim, dimColor: true, children: [" ", "\u2022", " "] }), _jsxs(Text, { color: colors.textDim, dimColor: true, children: ["Showing ", startIndex + 1, "-", endIndex, " of ", totalCount] })] })), showPopup && selectedSnapshotItem && (_jsx(Box, { marginTop: 2, justifyContent: "center", children: _jsx(ActionsPopup, { devbox: selectedSnapshotItem, operations: operations.map((op) => ({
401
+ ] }), _jsx(SearchBar, { searchMode: search.searchMode, searchQuery: search.searchQuery, submittedSearchQuery: search.submittedSearchQuery, resultCount: totalCount, onSearchChange: search.setSearchQuery, onSearchSubmit: search.submitSearch, placeholder: "Search snapshots..." }), !showPopup && (_jsx(Table, { data: snapshots, keyExtractor: (snapshot) => snapshot.id, selectedIndex: selectedIndex, title: `snapshots[${totalCount}]`, columns: columns, emptyState: _jsxs(Text, { color: colors.textDim, children: [figures.info, " No snapshots found. Try: rli snapshot create", " ", "<devbox-id>"] }) })), !showPopup && (_jsxs(Box, { marginTop: 1, paddingX: 1, children: [_jsxs(Text, { color: colors.primary, bold: true, children: [figures.hamburger, " ", totalCount] }), _jsxs(Text, { color: colors.textDim, dimColor: true, children: [" ", "total"] }), totalPages > 1 && (_jsxs(_Fragment, { children: [_jsxs(Text, { color: colors.textDim, dimColor: true, children: [" ", "\u2022", " "] }), navigating ? (_jsxs(Text, { color: colors.warning, children: [figures.pointer, " Loading page ", currentPage + 1, "..."] })) : (_jsxs(Text, { color: colors.textDim, dimColor: true, children: ["Page ", currentPage + 1, " of ", totalPages] }))] })), _jsxs(Text, { color: colors.textDim, dimColor: true, children: [" ", "\u2022", " "] }), _jsxs(Text, { color: colors.textDim, dimColor: true, children: ["Showing ", startIndex + 1, "-", endIndex, " of ", totalCount] }), search.submittedSearchQuery && (_jsxs(_Fragment, { children: [_jsxs(Text, { color: colors.textDim, dimColor: true, children: [" ", "\u2022", " "] }), _jsxs(Text, { color: colors.warning, children: ["Filtered: \"", search.submittedSearchQuery, "\""] })] }))] })), showPopup && selectedSnapshotItem && (_jsx(Box, { marginTop: 2, justifyContent: "center", children: _jsx(ActionsPopup, { devbox: selectedSnapshotItem, operations: operations.map((op) => ({
378
402
  key: op.key,
379
403
  label: op.label,
380
404
  color: op.color,
@@ -394,6 +418,7 @@ const ListSnapshotsUI = ({ devboxId, onBack, onExit, }) => {
394
418
  },
395
419
  { key: "Enter", label: "Details" },
396
420
  { key: "a", label: "Actions" },
421
+ { key: "/", label: "Search" },
397
422
  { key: "Esc", label: "Back" },
398
423
  ] })] }));
399
424
  };
@@ -14,8 +14,8 @@ import { colors } from "../utils/theme.js";
14
14
  import { useViewportHeight } from "../hooks/useViewportHeight.js";
15
15
  import { useNavigation } from "../store/navigationStore.js";
16
16
  import { useExitOnCtrlC } from "../hooks/useExitOnCtrlC.js";
17
- import { getDevboxLogs, execCommand, suspendDevbox, resumeDevbox, shutdownDevbox, uploadFile, createSnapshot as createDevboxSnapshot, createTunnel, createSSHKey, } from "../services/devboxService.js";
18
- import { LogsViewer } from "./LogsViewer.js";
17
+ import { suspendDevbox, resumeDevbox, shutdownDevbox, uploadFile, createSnapshot as createDevboxSnapshot, createTunnel, createSSHKey, } from "../services/devboxService.js";
18
+ import { StreamingLogsViewer } from "./StreamingLogsViewer.js";
19
19
  export const DevboxActionsMenu = ({ devbox, onBack, breadcrumbItems = [
20
20
  { label: "Devboxes" },
21
21
  { label: devbox.name || devbox.id, active: true },
@@ -30,6 +30,17 @@ export const DevboxActionsMenu = ({ devbox, onBack, breadcrumbItems = [
30
30
  const [execScroll, setExecScroll] = React.useState(0);
31
31
  const [copyStatus, setCopyStatus] = React.useState(null);
32
32
  const [showDeleteConfirm, setShowDeleteConfirm] = React.useState(false);
33
+ // Snapshot form state
34
+ const [snapshotFormMode, setSnapshotFormMode] = React.useState(false);
35
+ const [snapshotName, setSnapshotName] = React.useState("");
36
+ const [snapshotCommitMessage, setSnapshotCommitMessage] = React.useState("");
37
+ const [snapshotMetadata, setSnapshotMetadata] = React.useState({});
38
+ const [snapshotFormField, setSnapshotFormField] = React.useState("name");
39
+ const [inSnapshotMetadataSection, setInSnapshotMetadataSection] = React.useState(false);
40
+ const [snapshotMetadataKey, setSnapshotMetadataKey] = React.useState("");
41
+ const [snapshotMetadataValue, setSnapshotMetadataValue] = React.useState("");
42
+ const [snapshotMetadataInputMode, setSnapshotMetadataInputMode] = React.useState(null);
43
+ const [selectedSnapshotMetadataIndex, setSelectedSnapshotMetadataIndex] = React.useState(0);
33
44
  // Calculate viewport for exec output:
34
45
  // - Breadcrumb (3 lines + marginBottom): 4 lines
35
46
  // - Command header (border + 2 content + border + marginBottom): 5 lines
@@ -163,14 +174,193 @@ export const DevboxActionsMenu = ({ devbox, onBack, breadcrumbItems = [
163
174
  !showDeleteConfirm) {
164
175
  setShowDeleteConfirm(true);
165
176
  }
177
+ // Show snapshot form
178
+ if (executingOperation === "snapshot" &&
179
+ !loading &&
180
+ devbox &&
181
+ !snapshotFormMode &&
182
+ !operationResult &&
183
+ !operationError) {
184
+ setSnapshotFormMode(true);
185
+ setSnapshotFormField("name");
186
+ }
166
187
  }, [executingOperation]);
167
188
  // Handle Ctrl+C to exit
168
189
  useExitOnCtrlC();
169
190
  useInput((input, key) => {
170
- // Handle operation input mode
171
- if (executingOperation && !operationResult && !operationError) {
191
+ // Handle snapshot metadata section input
192
+ if (snapshotFormMode && inSnapshotMetadataSection) {
193
+ const metadataKeys = Object.keys(snapshotMetadata);
194
+ const maxIndex = metadataKeys.length + 1;
195
+ // Handle input mode (typing key or value)
196
+ if (snapshotMetadataInputMode) {
197
+ if (snapshotMetadataInputMode === "key" &&
198
+ key.return &&
199
+ snapshotMetadataKey.trim()) {
200
+ setSnapshotMetadataInputMode("value");
201
+ return;
202
+ }
203
+ else if (snapshotMetadataInputMode === "value" && key.return) {
204
+ if (snapshotMetadataKey.trim() && snapshotMetadataValue.trim()) {
205
+ setSnapshotMetadata({
206
+ ...snapshotMetadata,
207
+ [snapshotMetadataKey.trim()]: snapshotMetadataValue.trim(),
208
+ });
209
+ }
210
+ setSnapshotMetadataKey("");
211
+ setSnapshotMetadataValue("");
212
+ setSnapshotMetadataInputMode(null);
213
+ setSelectedSnapshotMetadataIndex(0);
214
+ return;
215
+ }
216
+ else if (key.escape) {
217
+ setSnapshotMetadataKey("");
218
+ setSnapshotMetadataValue("");
219
+ setSnapshotMetadataInputMode(null);
220
+ return;
221
+ }
222
+ else if (key.tab) {
223
+ setSnapshotMetadataInputMode(snapshotMetadataInputMode === "key" ? "value" : "key");
224
+ return;
225
+ }
226
+ return;
227
+ }
228
+ // Navigation mode in metadata section
229
+ if (key.upArrow && selectedSnapshotMetadataIndex > 0) {
230
+ setSelectedSnapshotMetadataIndex(selectedSnapshotMetadataIndex - 1);
231
+ }
232
+ else if (key.downArrow && selectedSnapshotMetadataIndex < maxIndex) {
233
+ setSelectedSnapshotMetadataIndex(selectedSnapshotMetadataIndex + 1);
234
+ }
235
+ else if (key.return) {
236
+ if (selectedSnapshotMetadataIndex === 0) {
237
+ setSnapshotMetadataKey("");
238
+ setSnapshotMetadataValue("");
239
+ setSnapshotMetadataInputMode("key");
240
+ }
241
+ else if (selectedSnapshotMetadataIndex === maxIndex) {
242
+ setInSnapshotMetadataSection(false);
243
+ setSelectedSnapshotMetadataIndex(0);
244
+ setSnapshotMetadataKey("");
245
+ setSnapshotMetadataValue("");
246
+ setSnapshotMetadataInputMode(null);
247
+ }
248
+ else if (selectedSnapshotMetadataIndex >= 1 &&
249
+ selectedSnapshotMetadataIndex <= metadataKeys.length) {
250
+ const keyToEdit = metadataKeys[selectedSnapshotMetadataIndex - 1];
251
+ setSnapshotMetadataKey(keyToEdit || "");
252
+ setSnapshotMetadataValue(snapshotMetadata[keyToEdit] || "");
253
+ const newMetadata = { ...snapshotMetadata };
254
+ delete newMetadata[keyToEdit];
255
+ setSnapshotMetadata(newMetadata);
256
+ setSnapshotMetadataInputMode("key");
257
+ }
258
+ }
259
+ else if ((input === "d" || key.delete) &&
260
+ selectedSnapshotMetadataIndex >= 1 &&
261
+ selectedSnapshotMetadataIndex <= metadataKeys.length) {
262
+ const keyToDelete = metadataKeys[selectedSnapshotMetadataIndex - 1];
263
+ const newMetadata = { ...snapshotMetadata };
264
+ delete newMetadata[keyToDelete];
265
+ setSnapshotMetadata(newMetadata);
266
+ const newLength = Object.keys(newMetadata).length;
267
+ if (selectedSnapshotMetadataIndex > newLength) {
268
+ setSelectedSnapshotMetadataIndex(Math.max(0, newLength));
269
+ }
270
+ }
271
+ else if (key.escape || input === "q") {
272
+ setInSnapshotMetadataSection(false);
273
+ setSelectedSnapshotMetadataIndex(0);
274
+ setSnapshotMetadataKey("");
275
+ setSnapshotMetadataValue("");
276
+ setSnapshotMetadataInputMode(null);
277
+ }
278
+ return;
279
+ }
280
+ // Handle snapshot form mode (main form navigation)
281
+ if (snapshotFormMode && !inSnapshotMetadataSection) {
282
+ const snapshotFields = [
283
+ "name",
284
+ "commit_message",
285
+ "metadata",
286
+ "create",
287
+ ];
288
+ const currentFieldIndex = snapshotFields.indexOf(snapshotFormField);
289
+ if (input === "q" || key.escape) {
290
+ // Cancel snapshot form
291
+ setSnapshotFormMode(false);
292
+ setSnapshotName("");
293
+ setSnapshotCommitMessage("");
294
+ setSnapshotMetadata({});
295
+ setSnapshotFormField("name");
296
+ setExecutingOperation(null);
297
+ if (skipOperationsMenu) {
298
+ onBack();
299
+ }
300
+ return;
301
+ }
302
+ // Navigate between fields (only when not actively editing text fields)
303
+ if (snapshotFormField !== "name" &&
304
+ snapshotFormField !== "commit_message") {
305
+ if (key.upArrow && currentFieldIndex > 0) {
306
+ setSnapshotFormField(snapshotFields[currentFieldIndex - 1]);
307
+ return;
308
+ }
309
+ if (key.downArrow && currentFieldIndex < snapshotFields.length - 1) {
310
+ setSnapshotFormField(snapshotFields[currentFieldIndex + 1]);
311
+ return;
312
+ }
313
+ }
314
+ // Handle Enter key
315
+ if (key.return) {
316
+ if (snapshotFormField === "name") {
317
+ // Move to commit_message field
318
+ setSnapshotFormField("commit_message");
319
+ }
320
+ else if (snapshotFormField === "commit_message") {
321
+ // Move to metadata field
322
+ setSnapshotFormField("metadata");
323
+ }
324
+ else if (snapshotFormField === "metadata") {
325
+ // Enter metadata section
326
+ setInSnapshotMetadataSection(true);
327
+ setSelectedSnapshotMetadataIndex(0);
328
+ }
329
+ else if (snapshotFormField === "create") {
330
+ // Execute snapshot creation
331
+ executeOperation();
332
+ }
333
+ return;
334
+ }
335
+ // Tab navigation (when not in text input fields)
336
+ if (key.tab &&
337
+ snapshotFormField !== "name" &&
338
+ snapshotFormField !== "commit_message") {
339
+ const nextIndex = key.shift
340
+ ? Math.max(0, currentFieldIndex - 1)
341
+ : Math.min(snapshotFields.length - 1, currentFieldIndex + 1);
342
+ setSnapshotFormField(snapshotFields[nextIndex]);
343
+ return;
344
+ }
345
+ return;
346
+ }
347
+ // Handle operation input mode (for exec, upload, tunnel)
348
+ if (executingOperation &&
349
+ !operationResult &&
350
+ !operationError &&
351
+ !snapshotFormMode) {
172
352
  if (key.return && operationInput.trim()) {
173
- executeOperation();
353
+ // For exec, navigate to dedicated exec screen
354
+ if (executingOperation === "exec") {
355
+ navigate("devbox-exec", {
356
+ devboxId: devbox.id,
357
+ devboxName: devbox.name || devbox.id,
358
+ execCommand: operationInput,
359
+ });
360
+ }
361
+ else {
362
+ executeOperation();
363
+ }
174
364
  }
175
365
  else if (input === "q" || key.escape) {
176
366
  setExecutingOperation(null);
@@ -196,6 +386,40 @@ export const DevboxActionsMenu = ({ devbox, onBack, breadcrumbItems = [
196
386
  setExecutingOperation(null);
197
387
  }
198
388
  }
389
+ else if (input === "o" &&
390
+ operationResult &&
391
+ typeof operationResult === "object" &&
392
+ operationResult.__customRender === "tunnel") {
393
+ // Open tunnel URL in browser
394
+ const tunnelUrl = operationResult.__tunnelUrl;
395
+ if (tunnelUrl) {
396
+ const openBrowser = async () => {
397
+ const { exec } = await import("child_process");
398
+ const platform = process.platform;
399
+ let openCommand;
400
+ if (platform === "darwin") {
401
+ openCommand = `open "${tunnelUrl}"`;
402
+ }
403
+ else if (platform === "win32") {
404
+ openCommand = `start "${tunnelUrl}"`;
405
+ }
406
+ else {
407
+ openCommand = `xdg-open "${tunnelUrl}"`;
408
+ }
409
+ exec(openCommand, (error) => {
410
+ if (error) {
411
+ setCopyStatus("Could not open browser");
412
+ setTimeout(() => setCopyStatus(null), 2000);
413
+ }
414
+ else {
415
+ setCopyStatus("Opened in browser!");
416
+ setTimeout(() => setCopyStatus(null), 2000);
417
+ }
418
+ });
419
+ };
420
+ openBrowser();
421
+ }
422
+ }
199
423
  else if ((key.upArrow || input === "k") &&
200
424
  operationResult &&
201
425
  typeof operationResult === "object" &&
@@ -238,6 +462,7 @@ export const DevboxActionsMenu = ({ devbox, onBack, breadcrumbItems = [
238
462
  setExecScroll(maxScroll);
239
463
  }
240
464
  else if (input === "c" &&
465
+ !key.ctrl && // Ignore if Ctrl+C for quit
241
466
  operationResult &&
242
467
  typeof operationResult === "object" &&
243
468
  operationResult.__customRender === "exec") {
@@ -316,19 +541,7 @@ export const DevboxActionsMenu = ({ devbox, onBack, breadcrumbItems = [
316
541
  try {
317
542
  setLoading(true);
318
543
  switch (executingOperation) {
319
- case "exec":
320
- // Use service layer (already truncates output to prevent Yoga crashes)
321
- const execResult = await execCommand(devbox.id, operationInput);
322
- // Format exec result for custom rendering
323
- const formattedExecResult = {
324
- __customRender: "exec",
325
- command: operationInput,
326
- stdout: execResult.stdout || "",
327
- stderr: execResult.stderr || "",
328
- exitCode: execResult.exit_code ?? 0,
329
- };
330
- setOperationResult(formattedExecResult);
331
- break;
544
+ // Note: "exec" is now handled by ExecViewer component directly
332
545
  case "upload":
333
546
  // Use service layer
334
547
  const filename = operationInput.split("/").pop() || "file";
@@ -336,9 +549,28 @@ export const DevboxActionsMenu = ({ devbox, onBack, breadcrumbItems = [
336
549
  setOperationResult(`File ${filename} uploaded successfully`);
337
550
  break;
338
551
  case "snapshot":
339
- // Use service layer
340
- const snapshot = await createDevboxSnapshot(devbox.id, operationInput || `snapshot-${Date.now()}`);
552
+ // Use service layer with form data
553
+ const snapshotOptions = {};
554
+ if (snapshotName.trim()) {
555
+ snapshotOptions.name = snapshotName.trim();
556
+ }
557
+ else {
558
+ snapshotOptions.name = `snapshot-${Date.now()}`;
559
+ }
560
+ if (snapshotCommitMessage.trim()) {
561
+ snapshotOptions.commit_message = snapshotCommitMessage.trim();
562
+ }
563
+ if (Object.keys(snapshotMetadata).length > 0) {
564
+ snapshotOptions.metadata = snapshotMetadata;
565
+ }
566
+ const snapshot = await createDevboxSnapshot(devbox.id, snapshotOptions);
341
567
  setOperationResult(`Snapshot created: ${snapshot.id}`);
568
+ // Reset snapshot form state
569
+ setSnapshotFormMode(false);
570
+ setSnapshotName("");
571
+ setSnapshotCommitMessage("");
572
+ setSnapshotMetadata({});
573
+ setSnapshotFormField("name");
342
574
  break;
343
575
  case "ssh":
344
576
  // Use service layer
@@ -372,19 +604,11 @@ export const DevboxActionsMenu = ({ devbox, onBack, breadcrumbItems = [
372
604
  });
373
605
  break;
374
606
  case "logs":
375
- // Use service layer (already truncates and escapes log messages)
376
- const logs = await getDevboxLogs(devbox.id);
377
- if (logs.length === 0) {
378
- setOperationResult("No logs available for this devbox.");
379
- }
380
- else {
381
- const logsResult = {
382
- __customRender: "logs",
383
- __logs: logs,
384
- __totalCount: logs.length,
385
- };
386
- setOperationResult(logsResult);
387
- }
607
+ // Set flag to show streaming logs viewer
608
+ const logsResult = {
609
+ __customRender: "logs",
610
+ };
611
+ setOperationResult(logsResult);
388
612
  break;
389
613
  case "tunnel":
390
614
  // Use service layer
@@ -394,10 +618,13 @@ export const DevboxActionsMenu = ({ devbox, onBack, breadcrumbItems = [
394
618
  }
395
619
  else {
396
620
  const tunnel = await createTunnel(devbox.id, port);
397
- setOperationResult(`Tunnel created!\n\n` +
398
- `Local Port: ${port}\n` +
399
- `Public URL: ${tunnel.url}\n\n` +
400
- `You can now access port ${port} on the devbox via:\n${tunnel.url}`);
621
+ // Store tunnel result with custom render type to enable "open in browser"
622
+ const tunnelResult = {
623
+ __customRender: "tunnel",
624
+ __tunnelUrl: tunnel.url,
625
+ __port: port,
626
+ };
627
+ setOperationResult(tunnelResult);
401
628
  }
402
629
  break;
403
630
  case "suspend":
@@ -477,16 +704,15 @@ export const DevboxActionsMenu = ({ devbox, onBack, breadcrumbItems = [
477
704
  { key: "Enter/q/esc", label: "Back" },
478
705
  ] })] }));
479
706
  }
480
- // Check for custom logs rendering
707
+ // Check for custom logs rendering - use streaming logs viewer
481
708
  if (operationResult &&
482
709
  typeof operationResult === "object" &&
483
710
  operationResult.__customRender === "logs") {
484
- const logs = operationResult.__logs || [];
485
- return (_jsx(LogsViewer, { logs: logs, breadcrumbItems: [
711
+ return (_jsx(StreamingLogsViewer, { devboxId: devbox.id, breadcrumbItems: [
486
712
  ...breadcrumbItems,
487
713
  { label: "Logs", active: true },
488
714
  ], onBack: () => {
489
- // Clear large data structures immediately to prevent memory leaks
715
+ // Clear state
490
716
  setOperationResult(null);
491
717
  setOperationError(null);
492
718
  setOperationInput("");
@@ -498,15 +724,93 @@ export const DevboxActionsMenu = ({ devbox, onBack, breadcrumbItems = [
498
724
  else {
499
725
  setExecutingOperation(null);
500
726
  }
501
- }, title: "Logs" }));
727
+ } }));
728
+ }
729
+ // Check for custom tunnel rendering
730
+ if (operationResult &&
731
+ typeof operationResult === "object" &&
732
+ operationResult.__customRender === "tunnel") {
733
+ const tunnelUrl = operationResult.__tunnelUrl || "";
734
+ const tunnelPort = operationResult.__port || "";
735
+ return (_jsxs(_Fragment, { children: [_jsx(Breadcrumb, { items: [...breadcrumbItems, { label: "Open Tunnel", active: true }] }), _jsx(Header, { title: "Tunnel Created" }), _jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: colors.success, paddingX: 1, paddingY: 1, marginBottom: 1, children: [_jsx(Box, { marginBottom: 1, children: _jsxs(Text, { color: colors.success, bold: true, children: [figures.tick, " Tunnel created successfully!"] }) }), _jsxs(Box, { children: [_jsx(Text, { color: colors.textDim, children: "Port: " }), _jsx(Text, { color: colors.primary, bold: true, children: tunnelPort })] }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { color: colors.textDim, children: "Public URL: " }) }), _jsx(Box, { children: _jsx(Text, { color: colors.info, bold: true, children: tunnelUrl }) }), _jsx(Box, { marginTop: 1, children: _jsxs(Text, { color: colors.textDim, dimColor: true, children: ["You can now access port ", tunnelPort, " on the devbox via this URL"] }) }), copyStatus && (_jsx(Box, { marginTop: 1, children: _jsx(Text, { color: colors.success, bold: true, children: copyStatus }) }))] }), _jsx(NavigationTips, { tips: [
736
+ { key: "o", label: "Open in Browser" },
737
+ { key: "Enter/q/esc", label: "Back" },
738
+ ] })] }));
502
739
  }
503
740
  return (_jsxs(_Fragment, { children: [_jsx(Breadcrumb, { items: [...breadcrumbItems, { label: operationLabel, active: true }] }), _jsx(Header, { title: "Operation Result" }), operationResult && _jsx(SuccessMessage, { message: operationResult }), operationError && (_jsx(ErrorMessage, { message: "Operation failed", error: operationError })), _jsx(NavigationTips, { tips: [{ key: "Enter/q/esc", label: "Continue" }] })] }));
504
741
  }
742
+ // Snapshot form mode
743
+ if (snapshotFormMode && executingOperation === "snapshot" && devbox) {
744
+ if (loading) {
745
+ return (_jsxs(_Fragment, { children: [_jsx(Breadcrumb, { items: [
746
+ ...breadcrumbItems,
747
+ { label: "Create Snapshot", active: true },
748
+ ] }), _jsx(Header, { title: "Creating Snapshot" }), _jsx(SpinnerComponent, { message: "Creating snapshot..." })] }));
749
+ }
750
+ const snapshotFields = [
751
+ { key: "name", label: "Name (optional)" },
752
+ { key: "metadata", label: "Metadata (optional)" },
753
+ { key: "create", label: "Create Snapshot" },
754
+ ];
755
+ const currentFieldIndex = snapshotFields.findIndex((f) => f.key === snapshotFormField);
756
+ // Expanded metadata section
757
+ if (inSnapshotMetadataSection) {
758
+ const metadataKeys = Object.keys(snapshotMetadata);
759
+ const maxIndex = metadataKeys.length + 1;
760
+ return (_jsxs(_Fragment, { children: [_jsx(Breadcrumb, { items: [
761
+ ...breadcrumbItems,
762
+ { label: "Create Snapshot", active: true },
763
+ ] }), _jsx(Header, { title: "Create Snapshot - Metadata" }), _jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: colors.primary, paddingX: 1, paddingY: 1, marginBottom: 1, children: [_jsxs(Text, { color: colors.primary, bold: true, children: [figures.hamburger, " Manage Metadata"] }), snapshotMetadataInputMode && (_jsxs(Box, { flexDirection: "column", marginTop: 1, borderStyle: "single", borderColor: selectedSnapshotMetadataIndex === 0
764
+ ? colors.success
765
+ : colors.warning, paddingX: 1, children: [_jsx(Text, { color: selectedSnapshotMetadataIndex === 0
766
+ ? colors.success
767
+ : colors.warning, bold: true, children: selectedSnapshotMetadataIndex === 0
768
+ ? "Adding New"
769
+ : "Editing" }), _jsx(Box, { children: snapshotMetadataInputMode === "key" ? (_jsxs(_Fragment, { children: [_jsx(Text, { color: colors.primary, children: "Key: " }), _jsx(TextInput, { value: snapshotMetadataKey || "", onChange: setSnapshotMetadataKey, placeholder: "env" })] })) : (_jsxs(Text, { dimColor: true, children: ["Key: ", snapshotMetadataKey || ""] })) }), _jsx(Box, { children: snapshotMetadataInputMode === "value" ? (_jsxs(_Fragment, { children: [_jsx(Text, { color: colors.primary, children: "Value: " }), _jsx(TextInput, { value: snapshotMetadataValue || "", onChange: setSnapshotMetadataValue, placeholder: "production" })] })) : (_jsxs(Text, { dimColor: true, children: ["Value: ", snapshotMetadataValue || ""] })) })] })), !snapshotMetadataInputMode && (_jsxs(_Fragment, { children: [_jsxs(Box, { marginTop: 1, children: [_jsxs(Text, { color: selectedSnapshotMetadataIndex === 0
770
+ ? colors.primary
771
+ : colors.textDim, children: [selectedSnapshotMetadataIndex === 0
772
+ ? figures.pointer
773
+ : " ", " "] }), _jsx(Text, { color: selectedSnapshotMetadataIndex === 0
774
+ ? colors.success
775
+ : colors.textDim, bold: selectedSnapshotMetadataIndex === 0, children: "+ Add new metadata" })] }), metadataKeys.length > 0 && (_jsx(Box, { flexDirection: "column", marginTop: 1, children: metadataKeys.map((key, index) => {
776
+ const itemIndex = index + 1;
777
+ const isSelected = selectedSnapshotMetadataIndex === itemIndex;
778
+ return (_jsxs(Box, { children: [_jsxs(Text, { color: isSelected ? colors.primary : colors.textDim, children: [isSelected ? figures.pointer : " ", " "] }), _jsxs(Text, { color: isSelected ? colors.primary : colors.textDim, bold: isSelected, children: [key, ": ", snapshotMetadata[key]] })] }, key));
779
+ }) })), _jsxs(Box, { marginTop: 1, children: [_jsxs(Text, { color: selectedSnapshotMetadataIndex === maxIndex
780
+ ? colors.primary
781
+ : colors.textDim, children: [selectedSnapshotMetadataIndex === maxIndex
782
+ ? figures.pointer
783
+ : " ", " "] }), _jsxs(Text, { color: selectedSnapshotMetadataIndex === maxIndex
784
+ ? colors.success
785
+ : colors.textDim, bold: selectedSnapshotMetadataIndex === maxIndex, children: [figures.tick, " Done"] })] })] })), _jsx(Box, { marginTop: 1, borderStyle: "single", borderColor: colors.border, paddingX: 1, children: _jsx(Text, { color: colors.textDim, dimColor: true, children: snapshotMetadataInputMode
786
+ ? `[Tab] Switch field • [Enter] ${snapshotMetadataInputMode === "key" ? "Next" : "Save"} • [esc] Cancel`
787
+ : `${figures.arrowUp}${figures.arrowDown} Navigate • [Enter] ${selectedSnapshotMetadataIndex === 0 ? "Add" : selectedSnapshotMetadataIndex === maxIndex ? "Done" : "Edit"} • [d] Delete • [esc] Back` }) })] })] }));
788
+ }
789
+ // Main snapshot form
790
+ return (_jsxs(_Fragment, { children: [_jsx(Breadcrumb, { items: [
791
+ ...breadcrumbItems,
792
+ { label: "Create Snapshot", active: true },
793
+ ] }), _jsx(Header, { title: "Create Snapshot" }), _jsxs(Box, { flexDirection: "column", marginBottom: 1, children: [_jsx(Box, { marginBottom: 1, children: _jsx(Text, { color: colors.primary, bold: true, children: (() => {
794
+ const name = devbox.name || devbox.id;
795
+ return name.length > 100
796
+ ? name.substring(0, 100) + "..."
797
+ : name;
798
+ })() }) }), _jsxs(Box, { marginBottom: 1, children: [_jsxs(Text, { color: snapshotFormField === "name" ? colors.primary : colors.textDim, children: [snapshotFormField === "name" ? figures.pointer : " ", " Name:", " "] }), snapshotFormField === "name" ? (_jsx(TextInput, { value: snapshotName, onChange: setSnapshotName, placeholder: "my-snapshot (optional)" })) : (_jsx(Text, { color: colors.text, children: snapshotName || "(auto-generated)" }))] }), _jsxs(Box, { marginBottom: 1, children: [_jsxs(Text, { color: snapshotFormField === "commit_message"
799
+ ? colors.primary
800
+ : colors.textDim, children: [snapshotFormField === "commit_message" ? figures.pointer : " ", " ", "Commit Message:", " "] }), snapshotFormField === "commit_message" ? (_jsx(TextInput, { value: snapshotCommitMessage, onChange: setSnapshotCommitMessage, placeholder: "Describe this snapshot (optional)" })) : (_jsx(Text, { color: colors.text, children: snapshotCommitMessage || "(none)" }))] }), _jsxs(Box, { marginBottom: 1, flexDirection: "column", children: [_jsxs(Box, { children: [_jsxs(Text, { color: snapshotFormField === "metadata"
801
+ ? colors.primary
802
+ : colors.textDim, children: [snapshotFormField === "metadata" ? figures.pointer : " ", " ", "Metadata:", " "] }), _jsxs(Text, { color: colors.text, children: [Object.keys(snapshotMetadata).length, " item(s)"] }), snapshotFormField === "metadata" && (_jsxs(Text, { color: colors.textDim, dimColor: true, children: [" ", "[Enter to manage]"] }))] }), Object.keys(snapshotMetadata).length > 0 && (_jsx(Box, { marginLeft: 4, flexDirection: "column", children: Object.entries(snapshotMetadata).map(([key, value]) => (_jsxs(Text, { color: colors.textDim, dimColor: true, children: [key, ": ", value] }, key))) }))] }), _jsx(Box, { marginTop: 1, children: _jsxs(Text, { color: snapshotFormField === "create" ? colors.success : colors.textDim, bold: snapshotFormField === "create", children: [snapshotFormField === "create" ? figures.pointer : " ", " ", figures.play, " Create Snapshot"] }) })] }), _jsx(NavigationTips, { showArrows: true, tips: [
803
+ {
804
+ key: "Enter",
805
+ label: snapshotFormField === "create" ? "Create" : "Select",
806
+ },
807
+ { key: "q/esc", label: "Cancel" },
808
+ ] })] }));
809
+ }
505
810
  // Operation input mode
506
811
  if (executingOperation && devbox) {
507
812
  const needsInput = executingOperation === "exec" ||
508
813
  executingOperation === "upload" ||
509
- executingOperation === "snapshot" ||
510
814
  executingOperation === "tunnel";
511
815
  if (loading) {
512
816
  return (_jsxs(_Fragment, { children: [_jsx(Breadcrumb, { items: [
@@ -530,7 +834,6 @@ export const DevboxActionsMenu = ({ devbox, onBack, breadcrumbItems = [
530
834
  const prompts = {
531
835
  exec: "Command to execute:",
532
836
  upload: "File path to upload:",
533
- snapshot: "Snapshot name (optional):",
534
837
  tunnel: "Port number to expose:",
535
838
  };
536
839
  return (_jsxs(_Fragment, { children: [_jsx(Breadcrumb, { items: [...breadcrumbItems, { label: operationLabel, active: true }] }), _jsx(Header, { title: operationLabel }), _jsxs(Box, { flexDirection: "column", marginBottom: 1, children: [_jsx(Box, { marginBottom: 1, children: _jsx(Text, { color: colors.primary, bold: true, children: (() => {
@@ -542,12 +845,15 @@ export const DevboxActionsMenu = ({ devbox, onBack, breadcrumbItems = [
542
845
  ? "ls -la"
543
846
  : executingOperation === "upload"
544
847
  ? "/path/to/file"
545
- : executingOperation === "tunnel"
546
- ? "8080"
547
- : "my-snapshot" }) }), _jsx(NavigationTips, { tips: [
548
- { key: "Enter", label: "Execute" },
549
- { key: "q/esc", label: "Cancel" },
550
- ] })] })] }));
848
+ : "8080" }) }), _jsx(NavigationTips, { tips: executingOperation === "exec"
849
+ ? [
850
+ { key: "Enter", label: "Execute" },
851
+ { key: "q/esc", label: "Cancel" },
852
+ ]
853
+ : [
854
+ { key: "Enter", label: "Execute" },
855
+ { key: "q/esc", label: "Cancel" },
856
+ ] })] })] }));
551
857
  }
552
858
  // Operations selection mode - only show if not skipping
553
859
  if (!skipOperationsMenu || !executingOperation) {